Compare commits

...

44 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.4.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: autoprefixer
  dependency-version: 10.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.4.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: knip
  dependency-version: 6.43.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-04 15:04:26 +01:00
dependabot[bot] f2b86dfc73 chore(deps-dev): bump jscpd (#198)
Bumps the quality-tooling group in /tools/quality with 1 update: [jscpd](https://github.com/kucherenko/jscpd/tree/HEAD/rust/jscpd).


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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:43:51 +01:00
Oliver Kieselbach 9c4039b3c3 bump pointer: internal (B-vm9, B-fdd), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:31:15 +01:00
Oliver Kieselbach b0fb8eb5fa bump pointer: internal (B-vm9), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:48:29 +01:00
Oliver Kieselbach dd2432fa62 bump pointer: internal (D-400 live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:41:41 +01:00
208 changed files with 6932 additions and 2153 deletions
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Caching.Memory;
@@ -72,7 +73,8 @@ public class AppHomingServiceTests
Mock.Of<System.Net.Http.IHttpClientFactory>(),
cache,
_registry,
_tenantConfigMock.Object)
_tenantConfigMock.Object,
Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
_detectorMock = new Mock<IGraphFeatureDetector>();
_maintenanceMock = new Mock<IMaintenanceRepository>();
@@ -287,6 +289,32 @@ public class AppHomingServiceTests
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
}
[Fact]
public async Task Probe_succeeds_when_the_budget_cuts_the_retries_of_a_missing_legacy_service_principal()
{
// The access check runs on 4 s and the first retry waits 5 s, so the answer arrives as
// transient. The Entra code still says the SP is gone: nothing left to lose.
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole)));
SetupLegacyToken(GraphTokenResult.TransientFailure(
new EntraTokenRejection(401, EntraTokenRejection.ServicePrincipalMissingCode, "no service principal")));
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
}
[Fact]
public async Task Probe_stays_transient_when_the_budget_cuts_the_retries_of_any_other_legacy_rejection()
{
// Any other refusal proves nothing about the roles the tenant would lose with the flip.
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole)));
SetupLegacyToken(GraphTokenResult.TransientFailure(
new EntraTokenRejection(400, "AADSTS53003", "blocked by Conditional Access")));
var probe = await _sut.ProbePrimaryConsentAsync(TenantId);
Assert.False(probe.Succeeded);
Assert.True(probe.IsTransient);
}
[Fact]
public async Task Probe_never_mints_a_legacy_token_when_the_primary_token_fails()
{
@@ -349,6 +377,43 @@ public class AppHomingServiceTests
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
}
[Fact]
public async Task AutoFlip_clears_the_graph_access_rejection_of_the_app_the_tenant_leaves()
{
_store.Row!.GraphAccessRejectedSince = DateTime.UtcNow.AddHours(-3);
_store.Row.GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode;
_store.Row.GraphAccessRejectionConfirmedAt = DateTime.UtcNow.AddHours(-2);
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome.Outcome);
Assert.Null(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectedCode);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
// The confirmed episode ends visibly: the ops trail never keeps an open rejection.
var closed = Assert.Single(_savedOpsEvents, e => e.EventType == OpsEventTypes.GraphAccessRecovered);
Assert.Contains("app-homing-flip", closed.Details);
}
[Fact]
public async Task AutoFlip_clears_the_addon_episodes_and_closes_only_the_confirmed_ones()
{
_store.Row!.IntuneDeviceBindingPermissionMissingSince = DateTime.UtcNow.AddHours(-3);
_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt = DateTime.UtcNow.AddHours(-2);
_store.Row.CloudPcPermissionMissingSince = DateTime.UtcNow.AddMinutes(-5);
await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Null(_store.Row.CloudPcPermissionMissingSince);
var resolved = Assert.Single(_savedOpsEvents, e => e.EventType == OpsEventTypes.AddOnPermissionResolved);
Assert.Contains(GraphFeatureCatalog.FeatureIntuneDeviceBinding, resolved.Details);
Assert.Contains(GraphAccessHealthRecorder.ResolvedByAppHomingFlip, resolved.Details);
// An unconfirmed rejection was never shown, so it ends without a trace.
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == OpsEventTypes.GraphAccessRecovered);
}
[Fact]
public async Task AutoFlip_does_not_flip_on_permanent_probe_failure()
{
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// A radar pass reads one tenant at a time so its memory follows the largest tenant, and it never
/// evaluates an empty list: re-arm would read that as "version drained" and delete open episodes.
/// </summary>
public class AppVersionRadarPassTests
{
private static List<AppInstallSummary> Rows(string tenantId, int count)
{
var rows = new List<AppInstallSummary>();
for (var i = 0; i < count; i++)
rows.Add(new AppInstallSummary { TenantId = tenantId });
return rows;
}
[Fact]
public async Task Each_tenant_is_finished_before_the_next_one_is_loaded()
{
var calls = new List<string>();
await MaintenanceService.RunAppVersionRadarPassAsync(
new[] { "a", "b" },
tenantId =>
{
calls.Add($"load {tenantId}");
return Task.FromResult(Rows(tenantId, 2));
},
(tenantId, summaries) =>
{
calls.Add($"evaluate {tenantId}");
Assert.All(summaries, s => Assert.Equal(tenantId, s.TenantId));
return Task.FromResult((0, 0, 0));
},
(_, ex) => throw ex);
Assert.Equal(new[] { "load a", "evaluate a", "load b", "evaluate b" }, calls);
}
[Fact]
public async Task A_tenant_without_rows_is_not_evaluated()
{
var evaluated = new List<string>();
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
new[] { "empty", "full" },
tenantId => Task.FromResult(tenantId == "empty" ? new List<AppInstallSummary>() : Rows(tenantId, 3)),
(tenantId, _) =>
{
evaluated.Add(tenantId);
return Task.FromResult((0, 0, 0));
},
(_, ex) => throw ex);
Assert.Equal(new[] { "full" }, evaluated);
Assert.Equal(1, pass.Tenants);
Assert.Equal(3, pass.Rows);
}
[Fact]
public async Task A_failing_tenant_does_not_cost_the_others_their_pass()
{
var failed = new List<string>();
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
new[] { "load-fails", "evaluate-fails", "ok" },
tenantId => tenantId == "load-fails"
? throw new InvalidOperationException("load")
: Task.FromResult(Rows(tenantId, 1)),
(tenantId, _) => tenantId == "evaluate-fails"
? throw new InvalidOperationException("evaluate")
: Task.FromResult((1, 2, 3)),
(tenantId, _) => failed.Add(tenantId));
Assert.Equal(new[] { "load-fails", "evaluate-fails" }, failed);
Assert.Equal((1, 2, 3), (pass.Fired, pass.Refreshed, pass.Rearmed));
}
[Fact]
public async Task The_totals_add_up_over_the_tenants()
{
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
new[] { "a", "b" },
tenantId => Task.FromResult(Rows(tenantId, 2)),
(_, _) => Task.FromResult((1, 2, 3)),
(_, ex) => throw ex);
Assert.Equal((2, 4L, 2, 4, 6), pass);
}
}
@@ -35,7 +35,6 @@ public class AuthFunctionSideEffectTests
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<McpUserService> _mcpUserMock;
private readonly Mock<TelegramNotificationService> _telegramMock;
private readonly Mock<OpsEventService> _opsEventsMock;
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
private readonly Mock<IMetricsRepository> _metricsRepoMock;
@@ -84,10 +83,6 @@ public class AuthFunctionSideEffectTests
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
{ CallBase = false };
_telegramMock = new Mock<TelegramNotificationService>(
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
{ CallBase = false };
_globalNotificationMock = new Mock<GlobalNotificationService>(
notificationRepo, new FakeSignalRNotificationService(), Mock.Of<ILogger<GlobalNotificationService>>())
{ CallBase = false };
@@ -117,7 +112,6 @@ public class AuthFunctionSideEffectTests
tenantAdmins ?? _tenantAdminsMock.Object,
_metricsRepoMock.Object,
preview ?? _previewMock.Object,
_telegramMock.Object,
_globalNotificationMock.Object,
_mcpUserMock.Object,
_autoApproveEnqueuerMock.Object,
@@ -136,9 +130,6 @@ public class AuthFunctionSideEffectTests
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
// a brand-new tenant). Default: all fire-and-forget calls succeed.
_store = _tenantConfigMock.StubWrites(TenantId, row: null);
_telegramMock
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
.Returns(Task.CompletedTask);
_globalNotificationMock
.Setup(x => x.CreateNotificationAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
@@ -233,7 +224,7 @@ public class AuthFunctionSideEffectTests
Assert.True(string.IsNullOrEmpty(config.DomainName));
Assert.Null(config.OnboardedBy);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
@@ -261,8 +252,7 @@ public class AuthFunctionSideEffectTests
Assert.Null(written.DpaAcceptedBy);
Assert.Null(written.DpaAcceptedAt);
Assert.Equal("auth", source);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record next to the ping
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
"preview_signup", "New Tenant Signup",
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
@@ -350,7 +340,7 @@ public class AuthFunctionSideEffectTests
Assert.True(string.IsNullOrEmpty(config.DomainName));
Assert.Null(config.OnboardedBy);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
@@ -371,7 +361,7 @@ public class AuthFunctionSideEffectTests
Assert.Empty(_store.Writes);
Assert.Equal("first@contoso.com", _store.Row!.OnboardedBy);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
@@ -389,7 +379,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(DefaultConfig(), TenantId, Upn);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
// -------------------------------------------------------------------------
@@ -624,7 +614,7 @@ public class AuthFunctionSideEffectTests
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
Assert.Empty(_store.Writes);
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
@@ -650,16 +640,17 @@ public class AuthFunctionSideEffectTests
}
[Fact]
public async Task HandleNewTenantDomain_TelegramFailure_DoesNotThrow()
public async Task HandleNewTenantDomain_BellFailure_DoesNotThrow()
{
var config = DefaultConfig();
config.DomainName = null!;
_telegramMock
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(new HttpRequestException("Telegram unreachable"));
_globalNotificationMock
.Setup(x => x.CreateNotificationAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
.ThrowsAsync(new HttpRequestException("Notification store unreachable"));
// Should not throw — Telegram is fire-and-forget
// Should not throw — the bell is fire-and-forget
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
// The write still landed before the fire-and-forget
@@ -69,7 +69,7 @@ public class AutopilotDeviceValidatorTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
var tokenService = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
.ReturnsAsync(GraphTokenResult.Success("token"));
@@ -95,7 +95,7 @@ public class AutopilotDeviceValidatorTests
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
Assert.True(result.IsValid);
Assert.Equal("ap-1", result.AutopilotDeviceId);
Assert.Equal("ap-1", result.Registration!.DeviceId);
Assert.Equal(2, handler.Requests.Count);
tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
tokens.Verify(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()), Times.Exactly(2));
@@ -168,7 +168,7 @@ public class AutopilotDeviceValidatorTests
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
Assert.True(result.IsValid);
Assert.Equal("ap-1", result.AutopilotDeviceId);
Assert.Equal("ap-1", result.Registration!.DeviceId);
Assert.Equal(2, handler.Requests.Count);
}
@@ -201,7 +201,7 @@ public class AutopilotDeviceValidatorTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
var tokenService = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
CancellationToken observed = default;
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
@@ -213,4 +213,64 @@ public class AutopilotDeviceValidatorTests
Assert.True(observed.CanBeCanceled);
}
// ── Registration fields the session keeps ────────────────────────────────
private const string Upn = "first.last@example.invalid";
[Fact]
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
{
var device = Newtonsoft.Json.Linq.JObject.Parse(
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
var registration = AutopilotDeviceValidator.ReadRegistration(device);
Assert.Equal("ap-1", registration.DeviceId);
Assert.Equal("TEST", registration.GroupTag);
Assert.Equal("PO-7", registration.PurchaseOrderId);
Assert.Equal("LAB-01", registration.DisplayName);
Assert.True(registration.UserAssigned);
var kept = Newtonsoft.Json.JsonConvert.SerializeObject(registration);
Assert.DoesNotContain(Upn, kept);
Assert.DoesNotContain("First Last", kept);
Assert.DoesNotContain("AAAAA-BBBBB", kept);
}
[Fact]
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
{
var device = Newtonsoft.Json.Linq.JObject.Parse(
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
var registration = AutopilotDeviceValidator.ReadRegistration(device);
Assert.Null(registration.GroupTag);
Assert.Null(registration.PurchaseOrderId);
Assert.Null(registration.DisplayName);
Assert.False(registration.UserAssigned);
}
[Fact]
public void ReadRegistration_CutsOverlongText()
{
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
var device = Newtonsoft.Json.Linq.JObject.Parse($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
}
[Fact]
public async Task CachedValidation_CarriesTheRegistration()
{
var (sut, handler, _) = BuildSut(Json(
"{\"value\":[{\"id\":\"ap-1\",\"serialNumber\":\"PF3XKQ7\",\"groupTag\":\"TEST\"}]}"));
await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
var cached = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
Assert.Single(handler.Requests);
Assert.Equal("TEST", cached.Registration!.GroupTag);
}
}
@@ -181,6 +181,49 @@ public class ConfigBackupHookTests
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoiseOnlyChange_GraphAccessState_NoSnapshot()
{
// Token side effect: stamped and cleared without anyone changing the configuration.
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var incoming = RoundtripClone(stored);
incoming.GraphAccessRejectedSince = DateTime.UtcNow.AddMinutes(-20);
incoming.GraphAccessRejectedCode = "AADSTS7000229";
incoming.GraphAccessRejectionConfirmedAt = DateTime.UtcNow;
incoming.LastUpdated = DateTime.UtcNow;
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "graph-access", null);
Assert.True(saved);
Assert.Single(harness.Upserts);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoiseOnlyChange_AddOnPermissionState_NoSnapshot()
{
// Agent and token side effect, like the Graph access state.
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var incoming = RoundtripClone(stored);
incoming.IntuneDeviceBindingPermissionMissingSince = DateTime.UtcNow.AddMinutes(-20);
incoming.IntuneDeviceBindingPermissionMissingConfirmedAt = DateTime.UtcNow;
incoming.CloudPcPermissionMissingSince = DateTime.UtcNow.AddMinutes(-20);
incoming.CloudPcPermissionMissingConfirmedAt = DateTime.UtcNow;
incoming.LastUpdated = DateTime.UtcNow;
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "graph-access", null);
Assert.True(saved);
Assert.Single(harness.Upserts);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoOpSave_IdenticalContent_NoSnapshot()
{
@@ -5,9 +5,9 @@ using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Cover for the ConnectionType predicate used on every search path that does NOT push the
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match
/// Cover for the ConnectionType predicate used on the search path that does NOT push the
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>). Sessions that
/// predate the projection (null ConnectionType) must never match
/// a set filter; an unset filter matches everything.
/// </summary>
public class ConnectionTypeFilterTests
@@ -65,7 +65,7 @@ public class CorporateIdentifierValidatorTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
var tokenService = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
.ReturnsAsync(GraphTokenResult.Success("token"));
@@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
@@ -12,8 +13,8 @@ using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path,
/// never the bot), recipient validation, the Global-Admin gate, the rendered message, and the
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path),
/// recipient validation, the Global-Admin gate, the rendered message, and the
/// send against a stubbed provider endpoint.
/// </summary>
public class EmailChannelProviderTests
@@ -30,15 +31,6 @@ public class EmailChannelProviderTests
return mock;
}
private static Mock<TelegramNotificationService> TelegramMock()
{
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
{ CallBase = false };
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true });
return mock;
}
private static Mock<IEmailChannelSender> EmailMock()
{
var mock = new Mock<IEmailChannelSender>();
@@ -50,6 +42,9 @@ public class EmailChannelProviderTests
private static NotificationChannel Channel(string? url, string id = "mail", bool enabled = true)
=> new() { Id = id, Name = id, ProviderType = Email, Url = url, Enabled = enabled };
private static NotificationChannel Webhook(WebhookProviderType provider, string url)
=> new() { Id = provider.ToString(), Name = provider.ToString(), ProviderType = (int)provider, Url = url, Enabled = true };
private static NotificationAlert Alert() => new()
{
EventType = "PushDeliveryFailed",
@@ -67,9 +62,8 @@ public class EmailChannelProviderTests
public async Task Email_channel_reaches_the_email_sender_with_its_recipients_and_never_the_webhook_path()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var email = EmailMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>(), email.Object);
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
var alert = Alert();
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid; two@example.invalid") }, alert, Platform);
@@ -77,7 +71,6 @@ public class EmailChannelProviderTests
email.Verify(e => e.SendAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once);
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
}
[Fact]
@@ -86,7 +79,7 @@ public class EmailChannelProviderTests
var email = EmailMock();
email.Setup(e => e.SendAlertAsync("ops@example.invalid", It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "Sent to 1 recipient(s)." });
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform);
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
@@ -99,7 +92,7 @@ public class EmailChannelProviderTests
[Fact]
public async Task Dispatcher_without_an_email_transport_skips_the_channel_and_says_so_on_test()
{
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>());
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, Mock.Of<IPushChannelSender>());
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid") }, Alert(), Platform); // no throw
var result = await dispatcher.SendTestAsync(Channel("ops@example.invalid"), Alert(), Platform);
@@ -108,6 +101,54 @@ public class EmailChannelProviderTests
Assert.Contains("not registered", result.Message);
}
[Fact]
public async Task Webhook_channel_reaches_the_webhook_path_and_never_the_email_sender()
{
var webhook = WebhookMock();
var email = EmailMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
var alert = Alert();
await dispatcher.SendToChannelsAsync(new[] { Webhook(WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, Platform);
webhook.Verify(w => w.SendAsync("https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
}
[Fact]
public async Task Mixed_list_each_channel_reaches_its_own_transport()
{
var webhook = WebhookMock();
var email = EmailMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
var alert = Alert();
await dispatcher.SendToChannelsAsync(new[]
{
Channel("ops@example.invalid"),
Webhook(WebhookProviderType.GenericJson, "https://sales.example/hook"),
}, alert, Platform);
email.Verify(e => e.SendAlertAsync("ops@example.invalid", alert), Times.Once);
webhook.Verify(w => w.SendAsync("https://sales.example/hook", WebhookProviderType.GenericJson, alert,
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task Channels_without_a_destination_are_skipped_on_every_transport()
{
var webhook = WebhookMock();
var email = EmailMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
await dispatcher.SendToChannelsAsync(new[] { Channel(null), Webhook(WebhookProviderType.Slack, "") }, Alert(), Platform);
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
}
// ── Validation and gate ───────────────────────────────────────────────
[Theory]
@@ -138,6 +179,10 @@ public class EmailChannelProviderTests
var bad = NotificationChannel.SerializeList(new[] { Channel("https://hooks.example.invalid/x") });
Assert.Contains("not a valid e-mail address", TenantConfigValidation.ValidateNotificationChannels(bad));
// The branch is per provider: a recipient list on a webhook channel still meets the URL gate.
var onWebhook = NotificationChannel.SerializeList(new[] { Webhook(WebhookProviderType.Slack, "ops@example.invalid") });
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(onWebhook));
}
[Fact]
@@ -146,14 +191,45 @@ public class EmailChannelProviderTests
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider(Email));
var candidate = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: true));
var refused = TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: false);
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: true));
var refused = TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: false);
Assert.Equal("E-mail channels can only be configured by a Global Administrator.", refused);
// A GA-created channel survives a tenant admin's unrelated save; retargeting it does not.
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, candidate, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, candidate, isGlobalAdmin: false));
var retargeted = NotificationChannel.SerializeList(new[] { Channel("other@example.invalid") });
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, candidate, isGlobalAdmin: false));
Assert.NotNull(TenantConfigValidation.ValidatePlatformProviderGate(retargeted, candidate, isGlobalAdmin: false));
}
[Fact]
public void Gate_refuses_a_tenant_admin_who_flips_the_enabled_state()
{
var existing = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid", enabled: true) });
var disabled = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid", enabled: false) });
Assert.NotNull(TenantConfigValidation.ValidatePlatformProviderGate(disabled, existing, isGlobalAdmin: false));
}
[Fact]
public void Gate_ignores_webhook_channels()
{
var candidate = NotificationChannel.SerializeList(new[] { Webhook(WebhookProviderType.GenericJson, "https://sales.example/hook") });
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: false));
}
[Fact]
public void ValidateModel_enforces_the_gate_for_every_client_write()
{
var candidate = new TenantConfiguration
{
TenantId = "t1",
NotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") }),
};
var existing = new TenantConfiguration { TenantId = "t1" };
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
}
// ── Rendering ─────────────────────────────────────────────────────────
@@ -0,0 +1,78 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Reads the AADSTS code out of Entra's token error body. The body below has the shape Entra
/// really sends for a tenant whose enterprise application was removed; the ids are synthetic.
/// </summary>
public class EntraTokenRejectionTests
{
private const string MissingServicePrincipalBody =
"{\"error\":\"invalid_client\"," +
"\"error_description\":\"AADSTS7000229: The client application aaaaaaaa-0000-0000-0000-000000000001 is missing service principal in the tenant 11111111-1111-1111-1111-111111111111. See instructions here: https://go.microsoft.com/fwlink/?linkid=2225119\\r\\nTrace ID: 00000000-0000-0000-0000-000000000002\\r\\nCorrelation ID: 00000000-0000-0000-0000-000000000003\\r\\nTimestamp: 2026-11-01 08:00:00Z\"," +
"\"error_codes\":[7000229]," +
"\"timestamp\":\"2026-11-01 08:00:00Z\"," +
"\"trace_id\":\"00000000-0000-0000-0000-000000000002\"," +
"\"correlation_id\":\"00000000-0000-0000-0000-000000000003\"," +
"\"error_uri\":\"https://login.microsoftonline.com/error?code=7000229\"}";
[Fact]
public void Parse_reads_the_code_and_keeps_only_the_first_line_of_the_description()
{
var rejection = EntraTokenRejection.Parse(401, MissingServicePrincipalBody);
Assert.Equal(401, rejection.StatusCode);
Assert.Equal("AADSTS7000229", rejection.ErrorCode);
Assert.True(rejection.IsServicePrincipalMissing);
Assert.False(rejection.IsOurCredential);
Assert.StartsWith("AADSTS7000229: The client application", rejection.Description);
Assert.DoesNotContain("Trace ID", rejection.Description);
}
[Fact]
public void Parse_falls_back_to_the_description_without_error_codes()
{
var rejection = EntraTokenRejection.Parse(400, "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: Access has been blocked by Conditional Access policies.\"}");
Assert.Equal("AADSTS53003", rejection.ErrorCode);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("<html>Bad Request</html>")]
[InlineData("[1,2]")]
[InlineData("{\"error\":\"invalid_request\"}")]
public void Parse_keeps_only_the_status_when_the_body_carries_no_code(string? body)
{
var rejection = EntraTokenRejection.Parse(400, body);
Assert.Equal(400, rejection.StatusCode);
Assert.Null(rejection.ErrorCode);
Assert.Null(rejection.Description);
Assert.False(rejection.IsServicePrincipalMissing);
Assert.False(rejection.IsOurCredential);
}
[Fact]
public void Parse_cuts_a_long_description()
{
var body = "{\"error_description\":\"AADSTS50000: " + new string('x', 1000) + "\"}";
Assert.Equal(300, EntraTokenRejection.Parse(400, body).Description!.Length);
}
[Theory]
[InlineData("AADSTS7000215", true)]
[InlineData("AADSTS7000222", true)]
[InlineData("AADSTS7000218", true)]
[InlineData("aadsts7000222", true)]
[InlineData("AADSTS7000229", false)]
[InlineData("AADSTS7000112", false)]
[InlineData("AADSTS53003", false)]
public void IsOurCredential_only_blames_our_own_secret(string code, bool expected)
{
Assert.Equal(expected, new EntraTokenRejection(401, code, null).IsOurCredential);
}
}
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Feedback;
@@ -19,8 +18,8 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The rating bubble's answer route, driven through <c>ProcessSubmitAsync</c> (the HTTP shell only
/// reads the body and maps the validation error). Pins that a recorded rating, and nothing else,
/// raises <c>FeedbackRatingReceived</c>, the Global Admin bell and the Telegram ping, and that the
/// alert carries neither the comment nor the sender.
/// raises <c>FeedbackRatingReceived</c> and the Global Admin bell, and that the alert carries neither
/// the comment nor the sender.
/// </summary>
public sealed class FeedbackFunctionSubmitTests
{
@@ -34,7 +33,7 @@ public sealed class FeedbackFunctionSubmitTests
private static readonly string EntryPath = Constants.PortalUserFeedbackEntryPath(FeedbackId);
[Fact]
public async Task Rating_IsStored_RaisesTheEvent_RingsTheBell_AndPings()
public async Task Rating_IsStored_RaisesTheEvent_AndRingsTheBell()
{
var h = new Harness();
@@ -64,13 +63,12 @@ public sealed class FeedbackFunctionSubmitTests
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", EntryPath), Times.Once);
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 4, Comment), Times.Once);
}
[Fact]
public async Task TheAlert_CarriesNeitherCommentNorSender()
{
// Channels bound to FeedbackRatingReceived (Telegram, Slack, webhooks, e-mail) receive message
// Channels bound to FeedbackRatingReceived (Slack, webhooks, e-mail) receive message
// and details; the comment is read in the portal. Personal data must never ride along.
var h = new Harness();
@@ -160,7 +158,6 @@ public sealed class FeedbackFunctionSubmitTests
Assert.Empty(h.OpsEvents);
h.Bell.Verify(b => b.CreateNotificationAsync(
"feedback", "New Rating", It.IsAny<string>(), EntryPath), Times.Once);
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 3, Comment), Times.Once);
}
[Fact]
@@ -183,7 +180,6 @@ public sealed class FeedbackFunctionSubmitTests
public FakeFeedbackRepository Repo { get; }
public List<OpsEventEntry> OpsEvents { get; } = new();
public Mock<GlobalNotificationService> Bell { get; }
public Mock<TelegramNotificationService> Telegram { get; }
public Harness(FeedbackEntry? current = null, bool configExists = true, bool opsEventThrows = false)
{
@@ -203,9 +199,6 @@ public sealed class FeedbackFunctionSubmitTests
Mock.Of<INotificationRepository>(), new FakeSignalRNotificationService(),
Mock.Of<ILogger<GlobalNotificationService>>())
{ CallBase = false };
Telegram = new Mock<TelegramNotificationService>(
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
{ CallBase = false };
var adminConfig = new Mock<AdminConfigurationService>(
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
@@ -216,7 +209,7 @@ public sealed class FeedbackFunctionSubmitTests
Sut = new FeedbackFunction(
NullLogger<FeedbackFunction>.Instance, tenantConfig.Object, adminConfig.Object, tenantAdmins.Object,
Telegram.Object, Mock.Of<ISessionRepository>(), Repo,
Mock.Of<ISessionRepository>(), Repo,
BuildOpsEventService(adminConfig.Object, opsEventThrows), Bell.Object);
}
@@ -228,8 +221,6 @@ public sealed class FeedbackFunctionSubmitTests
{
Bell.Verify(b => b.CreateNotificationAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()), Times.Never);
Telegram.Verify(t => t.SendFeedbackAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string?>()), Times.Never);
}
/// <summary>A real OpsEventService over a capturing repository, so the actual record method runs.</summary>
@@ -23,7 +23,8 @@ public class FleetHealthPayloadTests
string manufacturer = "Contoso",
string model = "Laptop",
string? failureReason = null,
DateTime? startedAt = null)
DateTime? startedAt = null,
string? groupTag = null)
=> new()
{
TenantId = Guid.NewGuid().ToString(),
@@ -34,8 +35,106 @@ public class FleetHealthPayloadTests
Model = model,
FailureReason = failureReason!,
StartedAt = startedAt ?? DateTime.UtcNow,
AutopilotGroupTag = groupTag,
};
private static FleetGroupTagFilter Filter(string? excludeGroupTags, bool excludeUntagged = false)
{
Assert.True(FleetGroupTagFilter.TryParse(excludeGroupTags, excludeUntagged ? "true" : null, out var filter, out _));
return filter;
}
// ── Group tag breakdown and view filter ──────────────────────────────────
[Fact]
public void GroupTagHealth_IsEmpty_WhenNoSessionCarriesATag()
{
var payload = MetricsMath.BuildFleetHealthPayload(
new List<SessionSummary> { S(SessionStatus.Succeeded), S(SessionStatus.Failed) }, days: 7);
Assert.Empty(payload.GroupTagHealth);
}
[Fact]
public void GroupTagHealth_GroupsWithoutCase_InTheMostFrequentSpelling_WithANoTagBucket()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Succeeded, groupTag: "TEST"),
S(SessionStatus.Failed, groupTag: "TEST"),
S(SessionStatus.Failed, groupTag: " test "),
S(SessionStatus.Succeeded, groupTag: "PROD"),
S(SessionStatus.InProgress),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
Assert.Equal(new[] { "TEST", "No group tag", "PROD" }.OrderBy(l => l), payload.GroupTagHealth.Select(g => g.Label).OrderBy(l => l));
var test = payload.GroupTagHealth.Single(g => g.GroupTag == "TEST");
Assert.Equal((3, 1, 2), (test.Total, test.Succeeded, test.Failed));
Assert.Equal(string.Empty, payload.GroupTagHealth.Single(g => g.Label == "No group tag").GroupTag);
Assert.Equal("TEST", payload.GroupTagHealth[0].GroupTag); // by volume
}
[Fact]
public void Filter_TakesExcludedTagsOutOfEveryFigure_ButKeepsThemFlaggedInTheBreakdown()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Failed, groupTag: "TEST", model: "Lab VM", failureReason: "lab"),
S(SessionStatus.Failed, groupTag: "test", model: "Lab VM", failureReason: "lab"),
S(SessionStatus.Succeeded, groupTag: "PROD"),
S(SessionStatus.Succeeded),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7, Filter("Test"));
Assert.Equal(2, payload.ExcludedSessions);
Assert.Equal(new[] { "Test" }, payload.ExcludedGroupTags);
Assert.False(payload.ExcludeUntagged);
Assert.Equal(2, payload.Stats.Total);
Assert.Equal(100d, payload.Stats.SuccessRate);
Assert.Empty(payload.FailureReasons);
Assert.DoesNotContain(payload.ModelHealth, m => m.Model == "Lab VM");
Assert.Equal(0, payload.DailyData.Sum(d => d.Failed));
var excluded = Assert.Single(payload.GroupTagHealth, g => g.Excluded);
Assert.Equal(("TEST", 2), (excluded.GroupTag, excluded.Total));
}
[Fact]
public void Filter_ExcludeUntagged_TakesTheSessionsWithoutATagOut()
{
var sessions = new List<SessionSummary>
{
S(SessionStatus.Failed),
S(SessionStatus.Succeeded, groupTag: "PROD"),
};
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7, Filter(null, excludeUntagged: true));
Assert.Equal(1, payload.ExcludedSessions);
Assert.True(payload.ExcludeUntagged);
Assert.Equal(100d, payload.Stats.SuccessRate);
Assert.True(payload.GroupTagHealth.Single(g => g.GroupTag.Length == 0).Excluded);
}
[Fact]
public void TryParse_TrimsDeduplicatesAndCapsTheList()
{
Assert.True(FleetGroupTagFilter.TryParse(" TEST , test,,LAB ", null, out var filter, out _));
Assert.Equal(new[] { "TEST", "LAB" }, filter.ExcludedTags);
Assert.True(filter.IsActive);
Assert.True(FleetGroupTagFilter.TryParse(null, null, out var none, out _));
Assert.False(none.IsActive);
var tooMany = string.Join(",", Enumerable.Range(0, FleetGroupTagFilter.MaxExcludedTags + 1).Select(i => $"T{i}"));
Assert.False(FleetGroupTagFilter.TryParse(tooMany, null, out _, out var error));
Assert.Contains("at most", error);
Assert.False(FleetGroupTagFilter.TryParse(new string('x', 300), null, out _, out _));
}
[Fact]
public void EmptyInput_YieldsZeroedStats_AndOneDailyPointPerDay()
{
@@ -1,5 +1,6 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
@@ -34,10 +35,13 @@ public class GetTenantFeatureFlagsPayloadTests
// — and that forces a deliberate code review of whether the new field is non-sensitive.
Assert.Equal(new[]
{
// Non-sensitive by review: booleans only, the timestamps stay in the admin-gated config.
"addOnPermissionMissingBlocksAll",
// Non-sensitive by review: bool only, exposes no client ids (dual app-reg
// self-service migration banner).
"appHomingFunnelActive",
"bootstrapTokenEnabled",
"cloudPcPermissionMissing",
// Non-sensitive by review: bool only ("is a company name set"), never the value
// itself (admin-gated full config only).
"companyNameSet",
@@ -55,6 +59,10 @@ public class GetTenantFeatureFlagsPayloadTests
"enableIntegrityBypassAnalyzer",
"enableSoftwareInventoryAnalyzer",
"entitlements",
// Non-sensitive by review: booleans only; the Entra code stays in the admin-gated config.
"graphAccessAppMissing",
"graphAccessRejected",
"intuneDeviceBindingPermissionMissing",
"isTrial",
"showScriptOutput",
"trialAvailable",
@@ -65,6 +73,122 @@ public class GetTenantFeatureFlagsPayloadTests
}, fieldNames);
}
[Fact]
public void Payload_GraphAccess_SuspicionAloneShowsNothing()
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
GraphAccessRejectedSince = Now.AddMinutes(-5),
GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode,
});
Assert.False(element.GetProperty("graphAccessRejected").GetBoolean());
Assert.False(element.GetProperty("graphAccessAppMissing").GetBoolean());
}
[Theory]
[InlineData(EntraTokenRejection.ServicePrincipalMissingCode, true)]
[InlineData("AADSTS53003", false)]
[InlineData(null, false)]
public void Payload_GraphAccess_ConfirmedShowsTheNotice_AndNamesOnlyTheMissingApp(string? code, bool appMissing)
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
GraphAccessRejectedSince = Now.AddHours(-1),
GraphAccessRejectedCode = code,
GraphAccessRejectionConfirmedAt = Now.AddMinutes(-45),
});
Assert.True(element.GetProperty("graphAccessRejected").GetBoolean());
Assert.Equal(appMissing, element.GetProperty("graphAccessAppMissing").GetBoolean());
Assert.DoesNotContain("AADSTS", element.GetRawText());
}
[Fact]
public void Payload_GraphAccess_StaysSilentWithoutDeviceValidation()
{
// The switch being off has its own banner; the agent does not depend on Graph then.
var element = Serialize(new TenantConfiguration
{
GraphAccessRejectedSince = Now.AddHours(-1),
GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode,
GraphAccessRejectionConfirmedAt = Now.AddMinutes(-45),
});
Assert.False(element.GetProperty("graphAccessRejected").GetBoolean());
Assert.False(element.GetProperty("graphAccessAppMissing").GetBoolean());
}
[Fact]
public void Payload_AddOn_SuspicionAloneShowsNothing()
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
ValidateIntuneDeviceBinding = true,
IntuneDeviceBindingPermissionMissingSince = Now.AddMinutes(-5),
});
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
Assert.False(element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
}
[Fact]
public void Payload_AddOn_ConfirmedShowsTheNotice_PerValidation()
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
ValidateIntuneDeviceBinding = true,
ValidateCloudPcDevice = true,
CloudPcPermissionMissingSince = Now.AddHours(-1),
CloudPcPermissionMissingConfirmedAt = Now.AddMinutes(-45),
});
Assert.True(element.GetProperty("cloudPcPermissionMissing").GetBoolean());
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
// Autopilot still admits its devices.
Assert.False(element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
}
[Fact]
public void Payload_AddOn_SwitchingTheValidationOffHidesTheNoticeAtOnce()
{
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = true,
IntuneDeviceBindingPermissionMissingSince = Now.AddHours(-1),
IntuneDeviceBindingPermissionMissingConfirmedAt = Now.AddMinutes(-45),
});
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
}
[Theory]
// Only the binding is on: no device can be validated.
[InlineData(false, false, true)]
// The Cloud PC check is on and still works for Cloud PCs.
[InlineData(true, false, false)]
// Both are on and both are confirmed missing.
[InlineData(true, true, true)]
public void Payload_AddOn_BlocksAllOnlyWhenNoOtherValidationAdmits(bool cloudPcOn, bool cloudPcConfirmed, bool blocksAll)
{
var element = Serialize(new TenantConfiguration
{
ValidateIntuneDeviceBinding = true,
IntuneDeviceBindingPermissionMissingSince = Now.AddHours(-1),
IntuneDeviceBindingPermissionMissingConfirmedAt = Now.AddMinutes(-45),
ValidateCloudPcDevice = cloudPcOn,
CloudPcPermissionMissingSince = cloudPcConfirmed ? Now.AddHours(-1) : null,
CloudPcPermissionMissingConfirmedAt = cloudPcConfirmed ? Now.AddMinutes(-45) : null,
});
Assert.True(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
Assert.Equal(blocksAll, element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
}
[Fact]
public void Payload_PassesThroughExplicitFieldValues()
{
@@ -0,0 +1,460 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The two stages of a lost Graph access: one refusal only stamps a suspicion, a refusal at least
/// 15 minutes later confirms it and alerts once, a freshly issued token ends the episode. Runs on
/// the real configuration service over a stored row, so the storage round trip of the three fields
/// and the decision on the fresh row are part of what is proven.
/// </summary>
public class GraphAccessHealthRecorderTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private static readonly DateTimeOffset Start = new(2026, 11, 2, 8, 0, 0, TimeSpan.Zero);
private static readonly EntraTokenRejection MissingApp = new(401, EntraTokenRejection.ServicePrincipalMissingCode, "missing");
private static readonly EntraTokenRejection ConditionalAccess = new(400, "AADSTS53003", "blocked");
private static readonly EntraTokenRejection ExpiredSecret = new(401, "AADSTS7000222", "expired");
private static readonly string[] CoreRoles = { GraphAppPermissions.DeviceManagementServiceConfigReadAll };
private static readonly string[] CoreAndBindingRoles =
{ GraphAppPermissions.DeviceManagementServiceConfigReadAll, GraphAppPermissions.DeviceManagementManagedDevicesReadAll };
private static readonly IReadOnlyList<AddOnValidation> Binding = new[] { AddOnValidation.IntuneDeviceBinding };
private readonly Mock<IConfigRepository> _repo = new();
private readonly StoredTenantConfig _store;
private readonly List<OpsEventEntry> _events = new();
private readonly TestTimeProvider _time = new(Start);
private readonly GraphAccessHealthRecorder _sut;
public GraphAccessHealthRecorderTests()
{
_store = _repo.BackWithRow(ValidatedRow());
_sut = NewInstance();
}
private static TenantConfiguration ValidatedRow()
{
var row = TenantConfiguration.CreateDefault(TenantId);
row.ValidateAutopilotDevice = true;
return row;
}
/// <summary>A backend instance of its own: its own config cache over the same stored row.</summary>
private GraphAccessHealthRecorder NewInstance()
{
var cache = new MemoryCache(new MemoryCacheOptions());
var tenantConfig = new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => _events.Add(e))
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
_repo.Object, NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
var opsEvents = new OpsEventService(
opsRepo.Object, NullLogger<OpsEventService>.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
return new GraphAccessHealthRecorder(
tenantConfig, new Lazy<OpsEventService>(() => opsEvents), NullLogger<GraphAccessHealthRecorder>.Instance, _time);
}
private void Advance(TimeSpan by) => _time.SetUtcNow(_time.GetUtcNow() + by);
// ── Rejected: the two stages ────────────────────────────────────────────
[Fact]
public async Task First_rejection_only_stamps_the_suspicion()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Equal(Start.UtcDateTime, _store.Row!.GraphAccessRejectedSince);
Assert.Equal(EntraTokenRejection.ServicePrincipalMissingCode, _store.Row.GraphAccessRejectedCode);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task Further_rejections_inside_the_window_write_nothing()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(TimeSpan.FromMinutes(14));
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Single(_store.Writes);
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task Rejection_after_the_window_confirms_and_alerts_once()
{
await _sut.RecordRejectedAsync(TenantId, ConditionalAccess);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(TimeSpan.FromMinutes(5));
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Equal(2, _store.Writes.Count);
Assert.Equal(Start.UtcDateTime, _store.Row!.GraphAccessRejectedSince);
Assert.Equal(Start.UtcDateTime + GraphAccessHealthRecorder.ConfirmAfter, _store.Row.GraphAccessRejectionConfirmedAt);
// The code of the latest refusal counts: it selects the text and the repair.
Assert.Equal(EntraTokenRejection.ServicePrincipalMissingCode, _store.Row.GraphAccessRejectedCode);
var alert = Assert.Single(_events);
Assert.Equal(OpsEventTypes.GraphAccessRejected, alert.EventType);
Assert.Equal(OpsEventSeverity.Error, alert.Severity);
Assert.Equal(TenantId, alert.TenantId);
Assert.Contains(EntraTokenRejection.ServicePrincipalMissingCode, alert.Details);
}
[Fact]
public async Task Across_instances_only_one_confirms_and_alerts()
{
var other = NewInstance();
// The other instance holds a view from before the suspicion.
await other.RecordRejectedAsync(TenantId, MissingApp);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
await other.RecordRejectedAsync(TenantId, MissingApp);
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Single(_events);
}
[Fact]
public async Task A_lost_write_alerts_nothing_and_the_next_rejection_decides_again()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
_store.RejectWrites = true;
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
_store.RejectWrites = false;
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Single(_events);
}
[Fact]
public async Task A_token_without_a_role_runs_through_the_same_two_stages()
{
await _sut.RecordRejectedAsync(TenantId, EntraTokenRejection.NoApplicationRole);
Assert.Equal("NoApplicationRole", _store.Row!.GraphAccessRejectedCode);
Assert.Empty(_events);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, EntraTokenRejection.NoApplicationRole);
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_tenant_without_device_validation_stays_untouched()
{
_store.Row = TenantConfiguration.CreateDefault(TenantId);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Empty(_store.Writes);
}
[Fact]
public async Task A_tenant_without_a_row_stays_without_one()
{
_store.Row = null;
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Empty(_store.Writes);
}
// ── Our own credentials ─────────────────────────────────────────────────
[Fact]
public async Task Our_expired_secret_never_touches_the_tenant_and_alerts_once_per_hour()
{
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
Advance(TimeSpan.FromMinutes(59));
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
Assert.Empty(_store.Writes);
var alert = Assert.Single(_events);
Assert.Equal(OpsEventTypes.GraphAppCredentialRejected, alert.EventType);
Assert.Equal(OpsEventSeverity.Critical, alert.Severity);
Advance(TimeSpan.FromMinutes(2));
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
Assert.Equal(2, _events.Count);
}
// ── Minted ──────────────────────────────────────────────────────────────
[Fact]
public async Task A_minted_token_writes_nothing_without_a_state()
{
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Empty(_store.Writes);
Assert.Empty(_events);
}
[Fact]
public async Task A_minted_token_clears_a_suspicion_silently()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Null(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectedCode);
Assert.Empty(_events);
}
[Fact]
public async Task A_minted_token_ends_a_confirmed_episode_with_one_recovery_event()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
_events.Clear();
await _sut.RecordMintedAsync(TenantId, CoreRoles);
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Null(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectedCode);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
var recovery = Assert.Single(_events);
Assert.Equal(OpsEventTypes.GraphAccessRecovered, recovery.EventType);
Assert.Equal(OpsEventSeverity.Info, recovery.Severity);
}
[Fact]
public async Task After_a_recovery_a_new_episode_starts_at_the_first_stage()
{
await _sut.RecordRejectedAsync(TenantId, MissingApp);
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Equal(_time.GetUtcNow().UtcDateTime, _store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
// ── Add-on validations: the same two stages per validation ─────────────
private void SwitchOn(bool binding = true, bool cloudPc = false, bool autopilot = true)
{
_store.Row!.ValidateAutopilotDevice = autopilot;
_store.Row.ValidateIntuneDeviceBinding = binding;
_store.Row.ValidateCloudPcDevice = cloudPc;
}
[Fact]
public async Task First_device_kept_out_only_stamps_the_addon_suspicion()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Assert.Equal(Start.UtcDateTime, _store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Null(_store.Row.CloudPcPermissionMissingSince);
Assert.Empty(_events);
}
[Fact]
public async Task A_device_kept_out_after_the_window_confirms_and_alerts_once()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(14));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(1));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(5));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Assert.Equal(2, _store.Writes.Count);
Assert.Equal(Start.UtcDateTime + GraphAccessHealthRecorder.ConfirmAfter, _store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
var alert = Assert.Single(_events);
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, alert.EventType);
Assert.Equal(OpsEventSeverity.Warning, alert.Severity);
Assert.Contains(GraphFeatureCatalog.FeatureIntuneDeviceBinding, alert.Details);
Assert.Contains(GraphAppPermissions.DeviceManagementManagedDevicesReadAll, alert.Details);
Assert.Contains("\"blocksAll\":false", alert.Details);
}
[Fact]
public async Task Each_addon_runs_its_own_episode()
{
SwitchOn(binding: true, cloudPc: true);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(10));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, new[] { AddOnValidation.CloudPc });
Advance(TimeSpan.FromMinutes(5));
await _sut.RecordAddOnPermissionMissingAsync(TenantId, new[] { AddOnValidation.CloudPc, AddOnValidation.IntuneDeviceBinding });
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.NotNull(_store.Row.CloudPcPermissionMissingSince);
Assert.Null(_store.Row.CloudPcPermissionMissingConfirmedAt);
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_switched_off_validation_is_never_stamped()
{
SwitchOn(binding: false);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Assert.Empty(_store.Writes);
}
[Fact]
public async Task The_only_validation_reports_that_it_blocks_all()
{
SwitchOn(binding: true, autopilot: false);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Assert.Contains("\"blocksAll\":true", Assert.Single(_events).Details);
Assert.True(AddOnValidation.BlocksAllValidation(_store.Row!));
}
[Fact]
public async Task A_fresh_token_with_the_permission_ends_a_suspicion_silently()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Empty(_events);
}
[Fact]
public async Task A_fresh_token_with_the_permission_ends_a_confirmed_episode_with_one_event()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
_events.Clear();
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
var resolved = Assert.Single(_events);
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
Assert.Equal(OpsEventSeverity.Info, resolved.Severity);
Assert.Contains(GraphAccessHealthRecorder.ResolvedByGrant, resolved.Details);
}
[Fact]
public async Task A_fresh_token_without_the_permission_confirms_only_after_the_window()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(TimeSpan.FromMinutes(5));
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Single(_store.Writes);
Advance(TimeSpan.FromMinutes(10));
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_fresh_token_without_an_addon_episode_writes_nothing()
{
SwitchOn();
await _sut.RecordMintedAsync(TenantId, CoreRoles);
Assert.Empty(_store.Writes);
}
[Fact]
public async Task Switching_the_validation_off_ends_a_confirmed_episode_once()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
Advance(GraphAccessHealthRecorder.ConfirmAfter);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
_events.Clear();
_store.Row!.ValidateIntuneDeviceBinding = false;
// The maintenance run reads fresh, like another instance.
var maintenance = NewInstance();
await maintenance.ReconcileSwitchedOffAddOnsAsync(TenantId);
await maintenance.ReconcileSwitchedOffAddOnsAsync(TenantId);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
var resolved = Assert.Single(_events);
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
Assert.Contains(GraphAccessHealthRecorder.ResolvedBySwitchOff, resolved.Details);
}
[Fact]
public async Task Reconciling_leaves_an_episode_alone_while_its_switch_is_on()
{
SwitchOn();
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
await NewInstance().ReconcileSwitchedOffAddOnsAsync(TenantId);
Assert.Single(_store.Writes);
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
}
// ── Fail-soft: the agent route must not notice ──────────────────────────
[Fact]
public async Task A_storage_failure_never_reaches_the_caller()
{
_store.ThrowOnRead = new InvalidOperationException("storage down");
await _sut.RecordRejectedAsync(TenantId, MissingApp);
await _sut.RecordMintedAsync(TenantId, CoreRoles);
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
await _sut.ReconcileSwitchedOffAddOnsAsync(TenantId);
Assert.Empty(_store.Writes);
}
[Fact]
public async Task A_failing_write_never_reaches_the_caller()
{
_store.ThrowOnWrite = new InvalidOperationException("storage down");
await _sut.RecordRejectedAsync(TenantId, MissingApp);
Assert.Empty(_store.Writes);
Assert.Empty(_events);
}
}
@@ -0,0 +1,310 @@
using System.Net;
using System.Net.Http;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Tests.GraphResolution;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The maintenance run asks Entra for every tenant with a suspicion, so the state does not depend on
/// device traffic. Runs the real token service and recorder; only Entra's answer is scripted.
/// </summary>
public class GraphAccessRecheckMaintenanceTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string OtherTenantId = "22222222-2222-2222-2222-222222222222";
private const string MissingAppBody = "{\"error\":\"invalid_client\",\"error_description\":\"AADSTS7000229: missing service principal\",\"error_codes\":[7000229]}";
private const string ConditionalAccessBody = "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: blocked\",\"error_codes\":[53003]}";
private static readonly DateTimeOffset Now = new(2026, 11, 2, 8, 0, 0, TimeSpan.Zero);
private static readonly string TokenBody = TestGraphToken.Body(TestGraphToken.ValidationRole);
private readonly ScriptedEntra _entra = new();
private readonly Mock<IConfigRepository> _repo = new();
private readonly StoredTenantConfig _store;
private readonly List<OpsEventEntry> _events = new();
private readonly GraphTokenService _tokens;
private readonly GraphAccessRecheckMaintenance _sut;
public GraphAccessRecheckMaintenanceTests()
{
_store = _repo.BackWithRow(ValidatedRow());
_repo.Setup(r => r.GetAllTenantConfigurationsAsync())
.ReturnsAsync(() => _store.Row == null
? new List<TenantConfiguration>()
: new List<TenantConfiguration> { ConfigRepoMockExtensions.Clone(_store.Row) });
var cache = new MemoryCache(new MemoryCacheOptions());
var tenantConfig = new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => _events.Add(e))
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
_repo.Object, NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
var opsEvents = new OpsEventService(
opsRepo.Object, NullLogger<OpsEventService>.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
var recorder = new GraphAccessHealthRecorder(
tenantConfig, new Lazy<OpsEventService>(() => opsEvents), NullLogger<GraphAccessHealthRecorder>.Instance, new TestTimeProvider(Now));
_tokens = NewTokenService(new SingleHandlerFactory(_entra), recorder, tenantConfig);
_sut = new GraphAccessRecheckMaintenance(_repo.Object, _tokens, recorder, NullLogger<GraphAccessRecheckMaintenance>.Instance);
}
private static GraphTokenService NewTokenService(
IHttpClientFactory factory, IGraphAccessHealthRecorder recorder, TenantConfigurationService tenantConfig)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
["EntraId:ClientSecret"] = "secret",
}).Build();
return new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
factory,
new MemoryCache(new MemoryCacheOptions()),
new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance),
tenantConfig,
recorder);
}
private static TenantConfiguration ValidatedRow()
{
var row = TenantConfiguration.CreateDefault(TenantId);
row.ValidateAutopilotDevice = true;
return row;
}
private void Suspect(TimeSpan age, string code = EntraTokenRejection.ServicePrincipalMissingCode)
{
_store.Row!.GraphAccessRejectedSince = Now.UtcDateTime - age;
_store.Row.GraphAccessRejectedCode = code;
}
[Fact]
public async Task Confirms_an_old_suspicion_without_device_traffic()
{
// A real 401 goes into the retry delay, which the budget cuts: the refusal must survive it.
Suspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.Unauthorized, MissingAppBody);
Assert.Equal(1, await _sut.RunAsync());
Assert.Equal(Now.UtcDateTime, _store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_young_suspicion_stays_a_suspicion()
{
Suspect(TimeSpan.FromMinutes(10));
_entra.Answer(HttpStatusCode.BadRequest, ConditionalAccessBody);
await _sut.RunAsync();
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task Clears_the_notice_after_a_repair_made_directly_in_Entra()
{
Suspect(TimeSpan.FromHours(5));
_store.Row!.GraphAccessRejectionConfirmedAt = Now.UtcDateTime - TimeSpan.FromHours(4);
_entra.Answer(HttpStatusCode.OK, TokenBody);
await _sut.RunAsync();
Assert.Null(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Equal(OpsEventTypes.GraphAccessRecovered, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_token_without_a_role_keeps_the_notice_up()
{
// A user's sign-in re-created the removed enterprise application without any permission.
Suspect(TimeSpan.FromHours(5));
_store.Row!.GraphAccessRejectionConfirmedAt = Now.UtcDateTime - TimeSpan.FromHours(4);
_entra.Answer(HttpStatusCode.OK, TestGraphToken.Body());
await _sut.RunAsync();
Assert.NotNull(_store.Row!.GraphAccessRejectedSince);
Assert.NotNull(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task A_token_without_a_role_confirms_an_old_suspicion()
{
Suspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.OK, TestGraphToken.Body());
await _sut.RunAsync();
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
Assert.Equal("NoApplicationRole", _store.Row.GraphAccessRejectedCode);
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
}
[Fact]
public async Task A_cached_token_cannot_answer_the_question()
{
// The token this instance still holds stays valid after the enterprise app is gone.
_entra.Answer(HttpStatusCode.OK, TokenBody);
await _tokens.GetAccessTokenAsync(TenantId);
Assert.Equal(1, _entra.Posts);
Suspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.BadRequest, ConditionalAccessBody);
await _sut.RunAsync();
Assert.Equal(2, _entra.Posts);
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
}
[Fact]
public async Task No_answer_from_Entra_decides_nothing()
{
Suspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.ServiceUnavailable, "{}");
await _sut.RunAsync();
Assert.NotNull(_store.Row!.GraphAccessRejectedSince);
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
Assert.Empty(_events);
}
[Fact]
public async Task A_tenant_without_a_suspicion_is_not_asked()
{
Assert.Equal(0, await _sut.RunAsync());
Assert.Equal(0, _entra.Posts);
}
[Fact]
public async Task A_tenant_that_switched_device_validation_off_is_not_asked()
{
Suspect(TimeSpan.FromMinutes(20));
_store.Row!.ValidateAutopilotDevice = false;
Assert.Equal(0, await _sut.RunAsync());
Assert.Equal(0, _entra.Posts);
}
[Fact]
public async Task A_failing_read_ends_the_run_quietly()
{
_repo.Setup(r => r.GetAllTenantConfigurationsAsync()).ThrowsAsync(new InvalidOperationException("storage down"));
Assert.Equal(0, await _sut.RunAsync());
}
[Fact]
public async Task One_failing_tenant_does_not_stop_the_next()
{
// The stub throws on the unscripted first tenant, which the token service does not catch.
var repo = new Mock<IConfigRepository>();
var first = ValidatedRow();
first.GraphAccessRejectedSince = Now.UtcDateTime;
var second = ValidatedRow();
second.TenantId = OtherTenantId;
second.GraphAccessRejectedSince = Now.UtcDateTime;
repo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(new List<TenantConfiguration> { first, second });
var recorder = new Mock<IGraphAccessHealthRecorder>();
var handler = new StubHttpMessageHandler().When($"/{OtherTenantId}/oauth2", HttpStatusCode.OK, TokenBody);
var tenantConfig = new TenantConfigurationService(
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
var sut = new GraphAccessRecheckMaintenance(
repo.Object, NewTokenService(new StubHttpClientFactory(handler), recorder.Object, tenantConfig), recorder.Object,
NullLogger<GraphAccessRecheckMaintenance>.Instance);
Assert.Equal(2, await sut.RunAsync());
recorder.Verify(r => r.RecordMintedAsync(OtherTenantId, It.IsAny<IReadOnlyCollection<string>>()), Times.Once);
}
// ── Add-on episodes ─────────────────────────────────────────────────────
private void AddOnSuspect(TimeSpan age, TimeSpan? confirmedAgo = null, bool switchedOn = true)
{
_store.Row!.ValidateIntuneDeviceBinding = switchedOn;
_store.Row.IntuneDeviceBindingPermissionMissingSince = Now.UtcDateTime - age;
if (confirmedAgo is { } ago)
_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt = Now.UtcDateTime - ago;
}
[Fact]
public async Task Confirms_an_old_addon_suspicion_with_a_token_that_lacks_the_permission()
{
AddOnSuspect(TimeSpan.FromMinutes(20));
_entra.Answer(HttpStatusCode.OK, TokenBody);
Assert.Equal(1, await _sut.RunAsync());
Assert.Equal(Now.UtcDateTime, _store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
}
[Fact]
public async Task Ends_an_addon_episode_once_the_grant_arrives()
{
AddOnSuspect(TimeSpan.FromHours(5), confirmedAgo: TimeSpan.FromHours(4));
_entra.Answer(HttpStatusCode.OK,
TestGraphToken.Body(TestGraphToken.ValidationRole, GraphAppPermissions.DeviceManagementManagedDevicesReadAll));
await _sut.RunAsync();
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
var resolved = Assert.Single(_events);
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
Assert.Contains(GraphAccessHealthRecorder.ResolvedByGrant, resolved.Details);
}
[Fact]
public async Task A_switched_off_addon_ends_without_asking_Entra()
{
AddOnSuspect(TimeSpan.FromHours(5), confirmedAgo: TimeSpan.FromHours(4), switchedOn: false);
Assert.Equal(1, await _sut.RunAsync());
Assert.Equal(0, _entra.Posts);
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
var resolved = Assert.Single(_events);
Assert.Contains(GraphAccessHealthRecorder.ResolvedBySwitchOff, resolved.Details);
}
/// <summary>Entra's token endpoint with an answer that can change between calls.</summary>
private sealed class ScriptedEntra : HttpMessageHandler
{
private HttpStatusCode _status = HttpStatusCode.OK;
private string _body = "{}";
public int Posts { get; private set; }
public void Answer(HttpStatusCode status, string body)
{
_status = status;
_body = body;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Posts++;
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
}
}
@@ -32,7 +32,7 @@ public class GraphAuthFailureTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
return new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
}
@@ -342,7 +342,8 @@ public class GraphFeatureDetectorTests
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())));
new MemoryCache(new MemoryCacheOptions())),
Moq.Mock.Of<IGraphAccessHealthRecorder>());
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
var detector = new GraphFeatureDetector(
tokenService, cache, NullLogger<GraphFeatureDetector>.Instance, telemetry);
@@ -497,7 +498,8 @@ public class GraphFeatureDetectorTests
new NoopHttpClientFactory(),
new MemoryCache(new MemoryCacheOptions()),
StubAppRegistry(),
StubTenantConfigService())
StubTenantConfigService(),
Moq.Mock.Of<IGraphAccessHealthRecorder>())
{
}
@@ -529,7 +531,8 @@ public class GraphFeatureDetectorTests
new NoopHttpClientFactory(),
new MemoryCache(new MemoryCacheOptions()),
StubAppRegistry(),
StubTenantConfigService())
StubTenantConfigService(),
Moq.Mock.Of<IGraphAccessHealthRecorder>())
{
_firstToken = firstToken;
_laterToken = laterToken;
@@ -567,7 +570,8 @@ public class GraphFeatureDetectorTests
factory,
cache,
StubAppRegistry(),
StubTenantConfigService())
StubTenantConfigService(),
Moq.Mock.Of<IGraphAccessHealthRecorder>())
{
_onInvalidate = onInvalidate;
}
@@ -0,0 +1,21 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
/// <summary>What Entra's token endpoint answers on success, with the application roles of the tenant's service principal.</summary>
internal static class TestGraphToken
{
public const string ValidationRole = "DeviceManagementServiceConfig.Read.All";
public static string Body(params string[] roles)
{
var jwt = new JwtSecurityToken(
issuer: "https://sts.windows.net/test",
audience: "https://graph.microsoft.com",
claims: roles.Select(r => new Claim("roles", r)),
notBefore: null,
expires: DateTime.UtcNow.AddHours(1));
return $"{{\"access_token\":\"{new JwtSecurityTokenHandler().WriteToken(jwt)}\",\"expires_in\":3599}}";
}
}
@@ -43,16 +43,21 @@ public class GraphTokenServiceTests
/// these tests ⇒ the registry always resolves the primary credentials and the tenant-config
/// lookup is never consulted for the decision — a null-returning repo is fine.
/// </summary>
private static GraphTokenService NewService(IConfiguration config, System.Net.Http.IHttpClientFactory factory) =>
private static GraphTokenService NewService(
IConfiguration config,
System.Net.Http.IHttpClientFactory factory,
IGraphAccessHealthRecorder? accessHealth = null,
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null) =>
new GraphTokenService(
NullLogger<GraphTokenService>.Instance,
logger ?? NullLogger<GraphTokenService>.Instance,
factory,
new MemoryCache(new MemoryCacheOptions()),
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())));
new MemoryCache(new MemoryCacheOptions())),
accessHealth ?? Moq.Mock.Of<IGraphAccessHealthRecorder>());
private static int TokenPosts(StubHttpMessageHandler handler, string tenantId)
=> handler.Requests.Count(u => u.Contains($"/{tenantId}/oauth2/v2.0/token"));
@@ -240,7 +245,8 @@ public class GraphTokenServiceTests
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())));
new MemoryCache(new MemoryCacheOptions())),
Moq.Mock.Of<IGraphAccessHealthRecorder>());
var probe1 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
var probe2 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
@@ -260,6 +266,196 @@ public class GraphTokenServiceTests
Assert.Equal(3, TokenPosts(handler, TenantA));
}
// ── Entra's refusal ─────────────────────────────────────────────────────
private const string MissingAppBody = "{\"error\":\"invalid_client\",\"error_description\":\"AADSTS7000229: missing service principal\",\"error_codes\":[7000229]}";
private const string ConditionalAccessBody = "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: blocked\",\"error_codes\":[53003]}";
private static GraphTokenService ServiceFor(
StubHttpMessageHandler handler,
Moq.Mock<IGraphAccessHealthRecorder> accessHealth,
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client",
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
return NewService(config, new StubHttpClientFactory(handler), accessHealth.Object, logger);
}
[Fact]
public async Task Rejection_survives_a_budget_that_ends_the_retries()
{
// The validators run on 8 s and the recheck on 3 s; the chain's first retry waits 5 s.
var handler = new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.Unauthorized, MissingAppBody);
var (svc, _) = Create(handler);
using var budget = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(500));
var result = await svc.GetAccessTokenAsync(TenantA, budget.Token);
Assert.Null(result.AccessToken);
Assert.True(result.IsTransient);
Assert.NotNull(result.Rejection);
Assert.Equal(401, result.Rejection!.StatusCode);
Assert.True(result.Rejection.IsServicePrincipalMissing);
Assert.Equal(1, TokenPosts(handler, TenantA));
}
[Theory]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.TooManyRequests)]
[InlineData(HttpStatusCode.RequestTimeout)]
public async Task A_server_error_or_throttle_is_no_rejection(HttpStatusCode status)
{
var handler = new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", status, MissingAppBody);
var (svc, _) = Create(handler);
using var budget = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(200));
var result = await svc.GetAccessTokenAsync(TenantA, budget.Token);
Assert.True(result.IsTransient);
Assert.Null(result.Rejection);
}
[Fact]
public async Task Agent_validation_reports_a_rejection()
{
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody), health);
var result = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
Assert.False(result.IsTransient);
health.Verify(h => h.RecordRejectedAsync(TenantA, Moq.It.Is<EntraTokenRejection>(r => r.ErrorCode == "AADSTS53003")), Moq.Times.Once);
}
[Fact]
public async Task The_portal_path_does_not_report_a_rejection()
{
// The consent flows see refusals as a matter of course while a consent propagates.
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody), health);
await svc.GetAccessTokenAsync(TenantA);
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
}
[Fact]
public async Task Only_a_fresh_token_counts_as_proof_of_access()
{
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body(TestGraphToken.ValidationRole)), health);
var minted = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
var cached = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
Assert.True(minted.IsFreshlyMinted);
Assert.False(cached.IsFreshlyMinted);
// The roles travel along: they decide the add-on episodes.
health.Verify(h => h.RecordMintedAsync(TenantA, Moq.It.Is<IReadOnlyCollection<string>>(r => r.Contains(TestGraphToken.ValidationRole))), Moq.Times.Once);
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
}
[Fact]
public async Task A_token_without_a_role_is_no_proof_of_access()
{
// A sign-in consent re-created the removed enterprise application without any permission.
// The notice must survive it: this is the moment an admin signs in to read it.
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
await svc.GetAccessTokenAsync(TenantA);
health.Verify(h => h.RecordMintedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<IReadOnlyCollection<string>>()), Moq.Times.Never);
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
}
[Fact]
public async Task Agent_validation_counts_a_token_without_a_role_as_a_refusal()
{
// Graph refuses every call made with it, so the agent is locked out all the same.
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
var result = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
Assert.NotNull(result.AccessToken);
health.Verify(h => h.RecordRejectedAsync(TenantA, EntraTokenRejection.NoApplicationRole), Moq.Times.Once);
health.Verify(h => h.RecordMintedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<IReadOnlyCollection<string>>()), Moq.Times.Never);
}
[Fact]
public async Task Agent_validation_does_not_judge_a_token_from_the_cache()
{
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
await svc.GetAccessTokenForAgentValidationAsync(TenantA);
await svc.GetAccessTokenForAgentValidationAsync(TenantA);
health.Verify(h => h.RecordRejectedAsync(TenantA, EntraTokenRejection.NoApplicationRole), Moq.Times.Once);
}
[Fact]
public async Task A_probe_under_explicit_credentials_reports_nothing()
{
// A token of the app the tenant is not homed on says nothing about the tenant's access.
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["EntraId:ClientId"] = "test-client",
["EntraId:ClientSecret"] = "test-secret",
})
.Build();
var registry = new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance);
foreach (var (status, body) in new[]
{
(HttpStatusCode.OK, TestGraphToken.Body(TestGraphToken.ValidationRole)),
(HttpStatusCode.OK, TestGraphToken.Body()),
(HttpStatusCode.BadRequest, ConditionalAccessBody),
})
{
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
var svc = NewService(config, new StubHttpClientFactory(new StubHttpMessageHandler().When($"/{TenantA}/oauth2", status, body)), health.Object);
await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
health.VerifyNoOtherCalls();
}
}
[Fact]
public async Task Entra_refusal_is_logged_once_per_window()
{
var logger = new Moq.Mock<Microsoft.Extensions.Logging.ILogger<GraphTokenService>>();
var svc = ServiceFor(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody),
new Moq.Mock<IGraphAccessHealthRecorder>(), logger.Object);
await svc.GetAccessTokenAsync(TenantA);
await svc.GetAccessTokenAsync(TenantA);
logger.Verify(l => l.Log(
Microsoft.Extensions.Logging.LogLevel.Warning,
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
Moq.It.IsAny<Exception?>(),
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -0,0 +1,74 @@
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Cover for the group-tag search filter and for the device-snapshot path's in-memory twin of the
/// OData push-down: a filter must answer the same on both search paths.
/// </summary>
public class GroupTagSearchFilterTests
{
private static SessionSummary Session(string? groupTag = null) => new()
{
SessionId = "s", TenantId = "t", AutopilotGroupTag = groupTag,
Manufacturer = "Contoso", Model = "EliteBook 840", EnrollmentType = "v1",
DeviceName = "LAB-01", OsBuild = "26100.1000", Status = SessionStatus.Failed,
};
[Theory]
[InlineData("TEST", true)]
[InlineData("test", true)] // Intune does not fix the casing of a tag
[InlineData(" Test ", true)]
[InlineData("TEST-LAB", false)] // exact, not prefix
[InlineData(null, false)] // session without a recorded tag
public void GroupTagFilter_IsCaseInsensitiveAndExact(string? sessionTag, bool expected)
{
var filter = new SessionSearchFilter { GroupTag = "Test" };
Assert.Equal(expected, TableStorageService.MatchesGroupTag(Session(sessionTag), filter));
}
[Fact]
public void NoGroupTagFilter_MatchesEverySession()
{
Assert.True(TableStorageService.MatchesGroupTag(Session(), new SessionSearchFilter()));
Assert.True(TableStorageService.MatchesGroupTag(Session(), new SessionSearchFilter { GroupTag = " " }));
}
[Theory]
[InlineData("Manufacturer", "Contoso", true)]
[InlineData("Manufacturer", "contoso", false)] // OData eq is ordinal: the twin is too
[InlineData("Model", "EliteBook 840", true)]
[InlineData("Model", "EliteBook", false)]
[InlineData("EnrollmentType", "v2", false)]
[InlineData("DeviceName", "LAB", true)] // prefix, like the ge/lt range
[InlineData("DeviceName", "PC", false)]
[InlineData("OsBuild", "26100", true)]
[InlineData("OsBuild", "22631", false)]
[InlineData("Status", "Failed", true)]
[InlineData("Status", "Succeeded", false)]
public void DeviceSnapshotPath_AppliesThePushedDownColumns(string column, string value, bool expected)
{
var filter = column switch
{
"Manufacturer" => new SessionSearchFilter { Manufacturer = value },
"Model" => new SessionSearchFilter { Model = value },
"EnrollmentType" => new SessionSearchFilter { EnrollmentType = value },
"DeviceName" => new SessionSearchFilter { DeviceName = value },
"OsBuild" => new SessionSearchFilter { OsBuild = value },
_ => new SessionSearchFilter { Status = value },
};
Assert.Equal(expected, TableStorageService.MatchesPushedDownColumns(Session(), filter));
}
[Fact]
public void Fingerprint_BindsTheGroupTag()
{
// A continuation token minted for one group tag must not be replayed against another.
var a = SearchSessionsPagination.Fingerprint("tenant", "t", null, new SessionSearchFilter { GroupTag = "TEST" });
var b = SearchSessionsPagination.Fingerprint("tenant", "t", null, new SessionSearchFilter { GroupTag = "PROD" });
Assert.NotEqual(a, b);
}
}
@@ -79,7 +79,7 @@ public class IntuneEnrollmentValidationTests
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
{ CallBase = false };
var tokens = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
{ CallBase = false };
tokens.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
.ReturnsAsync(GraphTokenResult.Success("token"));
@@ -290,7 +290,8 @@ public class IntuneEnrollmentValidationTests
// ------------------------------------------------------------------ SecurityValidator chain
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(TenantConfiguration config)
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(
TenantConfiguration config, IGraphAccessHealthRecorder? accessHealth = null, CloudPcDeviceValidator? cloudPc = null)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
@@ -318,7 +319,9 @@ public class IntuneEnrollmentValidationTests
new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!, corporateIdentifierValidator: null!,
NullLogger.Instance,
intuneDeviceBindingValidator: binding.Object);
cloudPcDeviceValidator: cloudPc,
intuneDeviceBindingValidator: binding.Object,
accessHealth: accessHealth);
return (validator, binding);
}
@@ -407,6 +410,118 @@ public class IntuneEnrollmentValidationTests
Assert.Equal(retryAfter, result.RetryAfterSeconds);
}
// ------------------------------------------------------------------ missing add-on permission
private static Mock<CloudPcDeviceValidator> CloudPcAnswering(CloudPcValidationResult result)
{
var cloudPc = new Mock<CloudPcDeviceValidator>(
NullLogger<CloudPcDeviceValidator>.Instance, Mock.Of<IHttpClientFactory>(),
new MemoryCache(new MemoryCacheOptions()), null!)
{ CallBase = false };
cloudPc.Setup(c => c.ValidateCloudPcAsync(CertTenant, DeviceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
return cloudPc;
}
private static readonly CloudPcValidationResult CloudPcPermissionMissing =
new() { IsValid = false, PermissionMissing = true, ErrorMessage = "not permitted" };
private static Task<SecurityValidationResult> Validate(SecurityValidator validator, bool selfReportedCloudPc = false)
=> validator.ValidateRequestAsync(CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant,
selfReportedCloudPc: selfReportedCloudPc);
[Fact]
public async Task Chain_MissingBindingPermission_ReportsTheAddOn_AndKeepsTheResponse()
{
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (withRecorder, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true }, recorder.Object);
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
var (withoutRecorder, bindingWithout) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
SetupAdmitting(bindingWithout, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
var reported = await Validate(withRecorder);
var silent = await Validate(withoutRecorder);
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
It.Is<IReadOnlyList<AddOnValidation>>(l => l.Count == 1 && l[0] == AddOnValidation.IntuneDeviceBinding)), Times.Once);
Assert.Equal(HttpStatusCode.ServiceUnavailable, reported.StatusCode);
Assert.Equal(silent.StatusCode, reported.StatusCode);
Assert.Equal(silent.RetryAfterSeconds, reported.RetryAfterSeconds);
Assert.Equal(silent.Details, reported.Details);
}
[Theory]
[InlineData(IntuneDeviceBindingOutcome.Match)]
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
[InlineData(IntuneDeviceBindingOutcome.Transient)]
public async Task Chain_OtherBindingOutcomes_ReportNothing(IntuneDeviceBindingOutcome outcome)
{
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true }, recorder.Object);
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "x" });
await Validate(validator);
recorder.VerifyNoOtherCalls();
}
[Theory]
[InlineData(true)]
[InlineData(false)] // a laptop not (yet) in Autopilot or a Hyper-V test VM: the grant would not admit it either
public async Task Chain_MissingCloudPcPermission_KeepsThe403_AndReportsOnlyASelfReportedCloudPc(bool selfReportedCloudPc)
{
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (validator, _) = BuildChain(new TenantConfiguration { ValidateCloudPcDevice = true }, recorder.Object,
CloudPcAnswering(CloudPcPermissionMissing).Object);
var result = await Validate(validator, selfReportedCloudPc);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
Assert.Contains("not permitted", result.Details);
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
It.Is<IReadOnlyList<AddOnValidation>>(l => l.Count == 1 && l[0] == AddOnValidation.CloudPc)),
selfReportedCloudPc ? Times.Once() : Times.Never());
recorder.VerifyNoOtherCalls();
}
[Fact]
public async Task Chain_MissingCloudPcPermission_ReportsNothing_WhenTheBindingAdmits()
{
// The device got in: the missing grant had no consequence.
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (validator, binding) = BuildChain(
new TenantConfiguration { ValidateCloudPcDevice = true, ValidateIntuneDeviceBinding = true }, recorder.Object,
CloudPcAnswering(CloudPcPermissionMissing).Object);
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.Match, IntuneDeviceId = DeviceId });
var result = await Validate(validator);
Assert.True(result.IsValid, result.ErrorMessage + " " + result.Details);
recorder.VerifyNoOtherCalls();
}
[Theory]
[InlineData(true)]
[InlineData(false)] // a Hyper-V VM outside Autopilot: only the binding's grant would admit it
public async Task Chain_BothPermissionsMissing_ReportsInChainOrder_CloudPcOnlyWhenSelfReported(bool selfReportedCloudPc)
{
var recorder = new Mock<IGraphAccessHealthRecorder>();
var (validator, binding) = BuildChain(
new TenantConfiguration { ValidateCloudPcDevice = true, ValidateIntuneDeviceBinding = true }, recorder.Object,
CloudPcAnswering(CloudPcPermissionMissing).Object);
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
AddOnValidation[] expected = selfReportedCloudPc
? [AddOnValidation.CloudPc, AddOnValidation.IntuneDeviceBinding]
: [AddOnValidation.IntuneDeviceBinding];
var result = await Validate(validator, selfReportedCloudPc);
// The binding's missing grant keeps the device waiting rather than rejected (D-282).
Assert.Equal(HttpStatusCode.ServiceUnavailable, result.StatusCode);
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
It.Is<IReadOnlyList<AddOnValidation>>(l => l.SequenceEqual(expected))), Times.Once);
}
[Fact]
public async Task Chain_ToggleOff_NoAdmittingLookup_OnlyABackgroundObservation()
{
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Maintenance;
using Xunit;
@@ -7,6 +8,18 @@ namespace AutopilotMonitor.Functions.Tests;
public class MaintenanceStepTimerTests
{
private const long Mb = 1024 * 1024;
private static MaintenanceStepTimer TimerOver(params (long PeakMb, long AllocatedMb)[] readings)
{
var queue = new Queue<(long PeakMb, long AllocatedMb)>(readings);
return new MaintenanceStepTimer(() =>
{
var (peakMb, allocatedMb) = queue.Dequeue();
return new MaintenanceMemoryReading(peakMb * Mb, allocatedMb * Mb, 1740 * Mb, 2088 * Mb);
});
}
[Fact]
public async Task Records_every_step_in_order_and_returns_the_body_result()
{
@@ -30,4 +43,49 @@ public class MaintenanceStepTimerTests
Assert.True(timer.Steps.ContainsKey("boom"));
}
// The memory columns have to name the step that set the peak: each step records the process
// peak after it ran and only the allocation of its own span.
[Fact]
public async Task Each_step_reports_the_process_peak_after_it_and_its_own_allocation()
{
var timer = TimerOver((400, 100), (400, 150), (400, 150), (1900, 1450), (1900, 1450));
await timer.RunAsync("light", () => Task.CompletedTask);
await timer.RunAsync<int>("heavy", () => Task.FromResult(7));
var report = timer.MemoryReport();
Assert.Equal(400, report.StepsPeakMb["light"]);
Assert.Equal(50, report.StepsAllocMb["light"]);
Assert.Equal(1900, report.StepsPeakMb["heavy"]);
Assert.Equal(1300, report.StepsAllocMb["heavy"]);
Assert.Equal(1740, report.GcHeapLimitMb);
Assert.Equal(2088, report.GcHighLoadMb);
}
[Fact]
public async Task A_throwing_step_still_reports_its_memory()
{
var timer = TimerOver((300, 10), (800, 60), (800, 60));
await Assert.ThrowsAsync<InvalidOperationException>(
() => timer.RunAsync("boom", () => throw new InvalidOperationException("x")));
var report = timer.MemoryReport();
Assert.Equal(800, report.StepsPeakMb["boom"]);
Assert.Equal(50, report.StepsAllocMb["boom"]);
}
[Fact]
public void A_real_reading_sees_the_process_peak_and_the_gc_limits()
{
GC.Collect();
var reading = MaintenanceMemoryReading.Capture();
Assert.True(reading.PeakWorkingSetBytes > 0);
Assert.True(reading.AllocatedBytes > 0);
Assert.True(reading.GcHeapLimitBytes > 0);
Assert.True(reading.GcHighLoadBytes > 0);
}
}
@@ -435,10 +435,10 @@ public class NotificationChannelHealthTests
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
.ReturnsAsync(webhookResult);
}
var telegram = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>())).ReturnsAsync(Ok());
return new NotificationChannelDispatcher(webhook.Object, telegram.Object, (push ?? new Mock<IPushChannelSender>()).Object,
Mock.Of<IEmailChannelSender>(), recorder.Object, NullLogger<NotificationChannelDispatcher>.Instance);
var email = new Mock<IEmailChannelSender>();
email.Setup(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>())).ReturnsAsync(Ok());
return new NotificationChannelDispatcher(webhook.Object, (push ?? new Mock<IPushChannelSender>()).Object,
email.Object, recorder.Object, NullLogger<NotificationChannelDispatcher>.Instance);
}
[Fact]
@@ -447,13 +447,13 @@ public class NotificationChannelHealthTests
var recorder = new Mock<INotificationChannelHealthRecorder>();
var push = new Mock<IPushChannelSender>();
var dispatcher = Dispatcher(recorder, push: push);
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
var mail = new NotificationChannel { Id = "mail", Name = "mail", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
var phones = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Push, Enabled = true };
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram, phones }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
await dispatcher.SendToChannelsAsync(new[] { Webhook(), mail, phones }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"), It.Is<NotificationSendResult>(x => !x.Success), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.Is<NotificationSendResult>(x => x.Success), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "mail"), It.Is<NotificationSendResult>(x => x.Success), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(It.IsAny<NotificationScope>(), It.Is<NotificationChannel>(c => c.Id == "push"), It.IsAny<NotificationSendResult>(), It.IsAny<ChannelSendKind>()), Times.Never);
push.Verify(p => p.SendAsync(Tenant, It.IsAny<NotificationAlert>()), Times.Once);
}
@@ -474,13 +474,13 @@ public class NotificationChannelHealthTests
{
var recorder = new Mock<INotificationChannelHealthRecorder>();
var dispatcher = Dispatcher(recorder, webhookResult: () => throw new InvalidOperationException("boom"));
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
var mail = new NotificationChannel { Id = "mail", Name = "mail", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
await dispatcher.SendToChannelsAsync(new[] { Webhook(), mail }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"),
It.Is<NotificationSendResult>(x => !x.Success && x.Message == "Unexpected error: boom"), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.IsAny<NotificationSendResult>(), ChannelSendKind.Delivery), Times.Once);
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "mail"), It.IsAny<NotificationSendResult>(), ChannelSendKind.Delivery), Times.Once);
}
// ── Read endpoint projection ──────────────────────────────────────────
@@ -36,6 +36,7 @@ public class NotificationChannelsTests
"{\"name\":\"no-id\",\"providerType\":20,\"url\":\"https://x.example\"}",
ChannelJson("none-provider", providerType: 0),
ChannelJson("retired-teams-connector", providerType: 1),
ChannelJson("retired-provider", providerType: 40),
ChannelJson("unknown-provider", providerType: 99)) + "]";
var parsed = NotificationChannel.ParseList(json);
@@ -153,6 +154,20 @@ public class NotificationChannelsTests
Assert.Null(redacted[1].SigningSecret);
}
[Fact]
public void RedactedCopy_MasksTheRecipientsOfAnEmailChannel()
{
var cfg = new TenantConfiguration
{
NotificationChannelsJson = "[" + ChannelJson("mail", providerType: 60, url: "ops@example.invalid") + "]",
};
var redacted = NotificationChannel.ParseList(cfg.RedactedCopyForReader().NotificationChannelsJson);
Assert.Equal("mail", Assert.Single(redacted).Id);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url);
}
[Fact]
public void RedactedCopy_UnparseableChannelsJson_FallsBackToWholeStringRedaction()
{
@@ -218,6 +233,7 @@ public class NotificationChannelsTests
[InlineData("[{\"name\":\"no-id\",\"providerType\":20}]", "needs an id")]
[InlineData("[{\"id\":\"a\",\"providerType\":99}]", "invalid provider type")]
[InlineData("[{\"id\":\"a\",\"providerType\":1}]", "invalid provider type")] // retired Teams Office 365 Connector
[InlineData("[{\"id\":\"a\",\"providerType\":40}]", "invalid provider type")] // retired, never reused
[InlineData("[{\"id\":\"a\",\"providerType\":20,\"url\":\"http://plain.example\"}]", "")] // non-https rejected by SsrfGuard format check
public void ValidateNotificationChannels_RejectsInvalidEntries(string json, string expectedFragment)
{
@@ -238,6 +254,7 @@ public class NotificationChannelsTests
[Theory]
[InlineData(1, false)] // retired Teams Office 365 Connector
[InlineData(40, false)] // retired, never reused
[InlineData(99, false)]
[InlineData(0, true)]
[InlineData(2, true)]
@@ -257,8 +274,6 @@ public class NotificationChannelsTests
}
[Theory]
[InlineData(40, false)] // Telegram
[InlineData(40, true)]
[InlineData(50, false)] // Push
[InlineData(50, true)]
[InlineData(60, false)] // E-mail
@@ -275,7 +290,6 @@ public class NotificationChannelsTests
}
[Theory]
[InlineData(40)]
[InlineData(50)]
[InlineData(60)]
public void ValidateModel_LegacyWebhook_KeepsAnUnchangedStoredNonWebhookProviderSaveable(int providerType)
@@ -9,7 +9,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ops-event payload delivery: the structured Details object reaches outbound channels, both as
/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers),
/// readable facts (card + e-mail formats) and verbatim under "data" (generic JSON consumers),
/// and only the channels whose rule opted in get it — on the platform scope, like every ops send.
/// Before this, an alert carried only category/event/severity and a tenant GUID.
/// </summary>
@@ -117,10 +117,10 @@ public class OpsAlertPayloadTests
=> Assert.Equal("1.0", JObject.Parse(new GenericJsonRenderer().RenderToJson(
new NotificationAlert { Title = "t", Summary = "s", DataJson = """{"a":1}""" }))["schemaVersion"]!.Value<string>());
// ── Telegram text carries the same information ────────────────────────
// ── E-mail text carries the same information ──────────────────────────
[Fact]
public void TelegramRendering_IncludesFlattenedFacts()
public void EmailRendering_IncludesFlattenedFacts()
{
var alert = new NotificationAlert
{
@@ -130,7 +130,7 @@ public class OpsAlertPayloadTests
Facts = OpsAlertDispatchService.FlattenDetails("""{"domainName":"contoso.example"}"""),
};
var text = TelegramNotificationService.RenderAlertText(alert);
var text = EmailAlertRenderer.Text(alert);
Assert.Contains("Domain Name: contoso.example", text);
Assert.Contains("Pro trial started", text);
@@ -0,0 +1,86 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The ops events behind a customer submission are the only signal an alert channel gets. They
/// name ids and the portal path, never the submitter or a comment; the submitter stays in the
/// row's user id, which the dispatch never forwards.
/// </summary>
public class OpsEventSubmissionSignalsTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string Submitter = "someone@contoso.invalid";
private readonly List<OpsEventEntry> _events = new();
private readonly OpsEventService _sut;
public OpsEventSubmissionSignalsTests()
{
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(_events.Add)
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
{ CallBase = false };
_sut = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
}
private OpsEventEntry AssertSingleSignal(string eventType, string portalPath)
{
var entry = Assert.Single(_events);
Assert.Equal(eventType, entry.EventType);
Assert.Equal(OpsEventSeverity.Info, entry.Severity);
Assert.Equal(TenantId, entry.TenantId);
Assert.Contains(portalPath, entry.Details!);
Assert.Equal(Submitter, entry.UserId);
Assert.DoesNotContain(Submitter, entry.Message);
Assert.DoesNotContain(Submitter, entry.Details!);
return entry;
}
[Fact]
public async Task TenantSignup_names_the_tenant_page()
{
await _sut.RecordTenantSignupAsync(TenantId, Submitter);
AssertSingleSignal(OpsEventTypes.TenantSignup, $"/admin/tenants/management?tenantId={TenantId}");
}
[Fact]
public async Task SessionReport_names_the_session_and_the_report_only()
{
await _sut.RecordSessionReportSubmittedAsync(TenantId, Submitter, "session-1", "report-1");
var entry = AssertSingleSignal(OpsEventTypes.SessionReportSubmitted, "/admin/reports/session-reports?reportId=report-1");
Assert.Contains("session-1", entry.Details!);
}
[Fact]
public async Task DiagFilesReport_names_the_report_only()
{
await _sut.RecordDiagFilesReportSubmittedAsync(TenantId, Submitter, "report-2");
AssertSingleSignal(OpsEventTypes.DiagFilesReportSubmitted, "/admin/reports/session-reports?reportId=report-2");
}
[Fact]
public async Task RuleSubmission_names_the_submission_and_the_rule_only()
{
await _sut.RecordRuleSubmissionReceivedAsync(TenantId, Submitter, "abc123def456", "analyze", "ANALYZE-X-1");
var entry = AssertSingleSignal(OpsEventTypes.RuleSubmissionReceived, "/admin/reports/rule-submissions?submissionId=abc123def456");
Assert.Contains("ANALYZE-X-1", entry.Details!);
}
}
@@ -12,28 +12,12 @@ using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Platform (ops) notification channels: legacy-slot synthesis so the pre-channels dispatch
/// behavior survives the migration untouched, per-rule channel routing, the platform dispatch
/// scope (an ops alert's tenant is its subject, never the channel owner), and redaction of the
/// new secret-bearing field.
/// Platform (ops) notification channels: the stored list, per-rule channel routing, the platform
/// dispatch scope (an ops alert's tenant is its subject, never the channel owner), and redaction of
/// the secret-bearing field.
/// </summary>
public class OpsNotificationChannelsTests
{
private static AdminConfiguration LegacyConfig(
bool telegramEnabled = true, string telegramChat = "-1003785642894",
bool teamsEnabled = true, string teamsUrl = "https://teams.example/hook",
bool slackEnabled = false, string slackUrl = "")
=> new()
{
UpdatedBy = "test",
OpsAlertTelegramEnabled = telegramEnabled,
OpsAlertTelegramChatId = telegramChat,
OpsAlertTeamsEnabled = teamsEnabled,
OpsAlertTeamsWebhookUrl = teamsUrl,
OpsAlertSlackEnabled = slackEnabled,
OpsAlertSlackWebhookUrl = slackUrl,
};
private static NotificationChannel Channel(string id, bool enabled = true, int providerType = 20)
=> new() { Id = id, Name = id, ProviderType = providerType, Url = "https://x.example/" + id, Enabled = enabled };
@@ -61,44 +45,20 @@ public class OpsNotificationChannelsTests
private static IEnumerable<string> WithPayload(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
=> OpsAlertDispatchService.ResolveTargets(rules, channels).WithPayload.Select(c => c.Id);
// ── Legacy synthesis ──────────────────────────────────────────────────
[Fact]
public void GetOpsNotificationChannels_SynthesizesConfiguredLegacySlots()
{
var channels = LegacyConfig().GetOpsNotificationChannels();
Assert.Equal(2, channels.Count); // Slack slot is empty → not synthesized
var telegram = channels[0];
Assert.Equal(AdminConfiguration.LegacyTelegramChannelId, telegram.Id);
Assert.Equal(AdminConfiguration.LegacyTelegramChannelName, telegram.Name);
Assert.Equal((int)WebhookProviderType.Telegram, telegram.ProviderType);
Assert.Equal("-1003785642894", telegram.Url);
Assert.True(telegram.Enabled);
Assert.Equal(AdminConfiguration.LegacyTeamsChannelId, channels[1].Id);
Assert.Equal((int)WebhookProviderType.TeamsWorkflowWebhook, channels[1].ProviderType);
}
[Fact]
public void GetOpsNotificationChannels_CarriesLegacyEnabledFlags()
{
// A configured-but-disabled provider must stay disabled, or the migration would start
// sending to a destination the operator had deliberately switched off.
var channels = LegacyConfig(telegramEnabled: false).GetOpsNotificationChannels();
Assert.False(channels.Single(c => c.Id == AdminConfiguration.LegacyTelegramChannelId).Enabled);
}
// ── Stored list ───────────────────────────────────────────────────────
[Fact]
public void GetOpsNotificationChannels_EmptyWhenNothingConfigured()
=> Assert.Empty(new AdminConfiguration { UpdatedBy = "t" }.GetOpsNotificationChannels());
[Fact]
public void GetOpsNotificationChannels_StoredListWinsOverLegacySlots()
public void GetOpsNotificationChannels_ReturnsTheStoredList()
{
var config = LegacyConfig();
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") });
var config = new AdminConfiguration
{
UpdatedBy = "t",
OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") }),
};
var channels = config.GetOpsNotificationChannels();
@@ -46,6 +46,25 @@ public class OrphanSweepOpsEventTests
Assert.Contains("\"aggregateMetrics\":90000", evt.Details);
}
[Fact]
public async Task MaintenanceCompleted_carries_the_step_memory_and_the_gc_limits()
{
var (service, saved) = Rig();
var memory = new MaintenanceMemoryReport(
new Dictionary<string, long> { ["appVersionRadar"] = 1900 },
new Dictionary<string, long> { ["appVersionRadar"] = 1300 },
GcHeapLimitMb: 1740,
GcHighLoadMb: 2088);
await service.RecordMaintenanceCompletedAsync(123456, "Timer", memory: memory);
var evt = Assert.Single(saved);
Assert.Contains("\"stepsPeakMb\":{\"appVersionRadar\":1900}", evt.Details);
Assert.Contains("\"stepsAllocMb\":{\"appVersionRadar\":1300}", evt.Details);
Assert.Contains("\"gcHeapLimitMb\":1740", evt.Details);
Assert.Contains("\"gcHighLoadMb\":2088", evt.Details);
}
[Fact]
public async Task OrphanEventsCleaned_is_a_Warning_with_per_table_totals_and_the_legacy_count()
{
@@ -0,0 +1,56 @@
using System;
using AutopilotMonitor.Functions.Telemetry;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The memory metric is a percentage of the limits the GC works against, so a reading keeps its
/// meaning when the instance size changes, and an alert can name one number for every size.
/// </summary>
public class ProcessMemoryPressureTests
{
[Fact]
public void Heap_is_committed_memory_against_the_heap_limit()
{
var snapshot = default(ProcessSnapshot) with { CommittedBytes = 1305, TotalAvailableMemoryBytes = 1740 };
Assert.Equal(75.0, ProcessMemoryPressure.HeapPercent(snapshot));
}
[Fact]
public void Load_is_memory_load_against_the_high_load_threshold()
{
var snapshot = default(ProcessSnapshot) with { MemoryLoadBytes = 2088, HighMemoryLoadThresholdBytes = 2088 };
Assert.Equal(100.0, ProcessMemoryPressure.LoadPercent(snapshot));
}
[Fact]
public void A_value_past_the_limit_is_not_capped()
{
var snapshot = default(ProcessSnapshot) with { MemoryLoadBytes = 2320, HighMemoryLoadThresholdBytes = 2088 };
Assert.Equal(111.1, ProcessMemoryPressure.LoadPercent(snapshot));
}
[Fact]
public void Before_the_first_gc_there_is_no_reading()
{
var snapshot = default(ProcessSnapshot) with { WorkingSetBytes = 500 };
Assert.Null(ProcessMemoryPressure.HeapPercent(snapshot));
Assert.Null(ProcessMemoryPressure.LoadPercent(snapshot));
}
[Fact]
public void A_captured_snapshot_after_a_gc_has_both_readings()
{
GC.Collect();
var snapshot = ProcessSnapshot.Capture();
Assert.True(ProcessMemoryPressure.HeapPercent(snapshot) > 0);
Assert.True(ProcessMemoryPressure.LoadPercent(snapshot) > 0);
}
}
@@ -20,6 +20,7 @@ public class ProcessStallClassifierTests
PendingWorkItems: pending,
CompletedWorkItems: completed,
WorkingSetBytes: 0, HeapSizeBytes: 0, CommittedBytes: 0,
TotalAvailableMemoryBytes: 0, MemoryLoadBytes: 0, HighMemoryLoadThresholdBytes: 0,
LastGcGeneration: 0, LastGcPause: TimeSpan.Zero, LastGcCompacted: false);
[Fact]
@@ -17,7 +17,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
/// <summary>
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
/// webhook path, one fan-out per send), the validation rules (no destination, open to tenant
/// admins — only Telegram and E-mail stay behind the GA gate — and one Push channel per scope),
/// admins — only E-mail stays behind the GA gate — and one Push channel per scope),
/// the raw-table deny-list and the dependency redaction.
/// </summary>
public class PushChannelRoutingTests
@@ -35,15 +35,6 @@ public class PushChannelRoutingTests
return mock;
}
private static Mock<TelegramNotificationService> TelegramMock()
{
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
{ CallBase = false };
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true });
return mock;
}
private static NotificationChannel PushChannel(string id = "push", bool enabled = true) =>
new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = enabled };
@@ -56,9 +47,8 @@ public class PushChannelRoutingTests
public async Task Push_channel_reaches_the_push_sender_with_the_callers_scope_and_never_the_webhook_path()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var push = new Mock<IPushChannelSender>();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, push.Object);
var dispatcher = new NotificationChannelDispatcher(webhook.Object, push.Object);
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(new[] { PushChannel() }, alert, Tenant);
@@ -66,7 +56,6 @@ public class PushChannelRoutingTests
push.Verify(p => p.SendAsync(Tenant, alert), Times.Once);
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
}
[Fact]
@@ -75,7 +64,7 @@ public class PushChannelRoutingTests
// A pair stored before validation allowed only one: each would reach every paired device.
var webhook = WebhookMock();
var push = new Mock<IPushChannelSender>();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, TelegramMock().Object, push.Object);
var dispatcher = new NotificationChannelDispatcher(webhook.Object, push.Object);
var alert = new NotificationAlert { Title = "t", Summary = "s" };
var slack = new NotificationChannel { Id = "slack", Name = "slack", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x", Enabled = true };
@@ -94,7 +83,7 @@ public class PushChannelRoutingTests
var push = new Mock<IPushChannelSender>();
push.Setup(p => p.SendWithResultAsync(It.IsAny<NotificationScope>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, push.Object);
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, push.Object);
var result = await dispatcher.SendTestAsync(PushChannel(), new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
@@ -109,7 +98,6 @@ public class PushChannelRoutingTests
Assert.False(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "" }.HasDestination());
Assert.True(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x" }.HasDestination());
Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Push));
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Telegram));
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Email));
Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Slack));
}
@@ -127,23 +115,23 @@ public class PushChannelRoutingTests
}
[Fact]
public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_telegram()
public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_email()
{
var json = Json(PushChannel());
// Add, disable, rename and toggle: all the tenant admin's own.
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(PushChannel(enabled: false)), json, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(json, existingJson: null, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(PushChannel(enabled: false)), json, isGlobalAdmin: false));
var renamed = PushChannel();
renamed.Name = "Phones";
renamed.NotifyOnFailure = true;
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(renamed), json, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(renamed), json, isGlobalAdmin: false));
// The same channel id turned into Telegram still needs a Global Administrator.
var retyped = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
Assert.Equal("Telegram channels can only be configured by a Global Administrator.",
TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: true));
// The same channel id turned into E-mail still needs a Global Administrator.
var retyped = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
Assert.Equal("E-mail channels can only be configured by a Global Administrator.",
TenantConfigValidation.ValidatePlatformProviderGate(Json(retyped), json, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(retyped), json, isGlobalAdmin: true));
}
[Fact]
@@ -5,9 +5,9 @@ using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Cover for the RebootCount range predicate used on every search path that does NOT push the
/// bound to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
/// unpaged scan. Without it a <c>deviceProperties + rebootCountMin=5</c> query leaks sessions
/// Cover for the RebootCount range predicate used on the search path that does NOT push the
/// bound to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>). Without it a
/// <c>deviceProperties + rebootCountMin=5</c> query leaks sessions
/// with RebootCount &lt; 5 (the medium finding). Min is inclusive (">= many reboots").
/// </summary>
public class RebootCountFilterBoundsTests
@@ -255,7 +255,7 @@ public class ReportsWireParityTests
}
// =========================================================================
// GetDistressReportsFunction — { success, count, reports }
// GetDistressReportsFunction — { success, count, reports, truncated }
// =========================================================================
[Fact]
@@ -287,13 +287,15 @@ public class ReportsWireParityTests
{
success = true,
count = reports.Count,
reports
reports,
truncated = false
},
new DistressReportListResponse
{
Success = true,
Count = reports.Count,
Reports = reports
Reports = reports,
Truncated = false
});
}
@@ -72,7 +72,7 @@ public class SessionDeletionWorkerTests
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.AtLeastOnce);
// PR-B audit consolidation: deletion_poisoned audit moved to SessionDeletionPoisoned
// OpsEvent (operator-bound, Telegram-routable). Tenant-audit row is no longer written.
// OpsEvent (operator-bound, routable through alert rules). Tenant-audit row is no longer written.
var poisoned = Assert.Single(harness.OpsEventCalls, e => e.EventType == "SessionDeletionPoisoned");
Assert.Equal(TenantId, poisoned.TenantId);
Assert.Equal(SessionId, poisoned.SessionId);
@@ -29,6 +29,7 @@ public class SessionFreeTextSearchTests
DeviceName = "DESKTOP-A1B2C3",
SerialNumber = "5CG1234XYZ",
Manufacturer = "Contoso",
AutopilotGroupTag = "LAB-WEST",
Model = "EliteBook 840",
Status = SessionStatus.Failed,
GeoCountry = "DE",
@@ -178,6 +179,7 @@ public class SessionFreeTextSearchTests
DeviceName = F("deviceName"),
SerialNumber = F("serialNumber"),
Manufacturer = F("manufacturer"),
AutopilotGroupTag = F("autopilotGroupTag"),
Model = F("model"),
Status = Enum.Parse<SessionStatus>(F("status")),
SessionId = F("sessionId"),
@@ -83,6 +83,11 @@ public class SessionIndexFieldManifestTests
["EspSoftFailure"] = true,
["CompletionSource"] = "esp_exit",
["ValidatedBy"] = "AutopilotV1",
["AutopilotDeviceId"] = "ap-1",
["AutopilotGroupTag"] = "TEST",
["AutopilotPurchaseOrderId"] = "PO-7",
["AutopilotDisplayName"] = "LAB-01",
["AutopilotUserAssigned"] = true,
["FailureSnapshotJson"] = "{\"x\":1}",
["AdminMarkedAction"] = "failed",
["VerdictPath"] = "sweep:r5_incomplete",
@@ -1013,7 +1013,7 @@ public class SlaTenantStatusLifecycleTests
.Returns(Task.CompletedTask);
var webhook = new WebhookNotificationService(new HttpClient());
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance), Mock.Of<IPushChannelSender>());
var channelDispatcher = new NotificationChannelDispatcher(webhook, Mock.Of<IPushChannelSender>());
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
@@ -60,7 +60,7 @@ public class StoreSessionReregistrationPreserveTests
DeviceName = "PC-1",
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.NotNull(harness.Written);
@@ -95,7 +95,7 @@ public class StoreSessionReregistrationPreserveTests
DeviceName = "PC-1",
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.NotNull(harness.Written);
@@ -134,7 +134,7 @@ public class StoreSessionReregistrationPreserveTests
IsSelfDeployingProfile = false, // fresh read failed / degraded
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
@@ -166,7 +166,7 @@ public class StoreSessionReregistrationPreserveTests
IsSelfDeployingProfile = true,
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
@@ -199,7 +199,7 @@ public class StoreSessionReregistrationPreserveTests
IsCloudPc = false, // fresh marker read degraded
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
@@ -233,7 +233,7 @@ public class StoreSessionReregistrationPreserveTests
EnrollmentType = "v1", // degraded registry read on restart
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
@@ -265,7 +265,7 @@ public class StoreSessionReregistrationPreserveTests
EnrollmentType = "v2",
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
@@ -295,7 +295,7 @@ public class StoreSessionReregistrationPreserveTests
IsCloudPc = true,
};
var ok = await harness.Sut.StoreSessionAsync(registration);
var ok = await harness.Store(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
@@ -327,7 +327,7 @@ public class StoreSessionReregistrationPreserveTests
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
@@ -358,7 +358,7 @@ public class StoreSessionReregistrationPreserveTests
var harness = new Harness(existing);
var boundAt = new DateTime(2026, 1, 1, 9, 0, 1, DateTimeKind.Utc);
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
@@ -401,7 +401,7 @@ public class StoreSessionReregistrationPreserveTests
.ReturnsAsync(Response.FromValue(initial, new Mock<Response>().Object))
.ReturnsAsync(Response.FromValue(fresh, new Mock<Response>().Object));
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 3, 8, 0, 0, DateTimeKind.Utc),
@@ -419,7 +419,7 @@ public class StoreSessionReregistrationPreserveTests
public async Task StoreSessionAsync_fresh_row_carries_the_stamped_owner()
{
var harness = new Harness(existing: null);
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
@@ -431,6 +431,214 @@ public class StoreSessionReregistrationPreserveTests
Assert.False(harness.Written.ContainsKey("OwnerThumbprint"));
}
// ── Carry-over of the columns the registration does not own ─────────────
[Fact]
public async Task StoreSessionAsync_carries_every_declared_column_it_does_not_own()
{
// Driven by the manifest: a column added to SessionIndexFieldManifest later is carried
// through an agent restart without anyone touching StoreSessionAsync. Merge-written columns
// such as ImeAgentVersion, LastIngestAt, ExcessiveEventsAlerted, Block* and the pending
// server actions used to be dropped by the Replace.
var existing = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "InProgress",
};
foreach (var column in TableStorageService.CarriedColumns)
existing[column] = $"carried:{column}";
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
});
Assert.True(ok);
Assert.Contains("ImeAgentVersion", TableStorageService.CarriedColumns);
Assert.Contains("PendingActionsJson", TableStorageService.CarriedColumns);
foreach (var column in TableStorageService.CarriedColumns)
Assert.Equal($"carried:{column}", harness.Written![column]);
}
[Fact]
public void RegistrationOwnedColumns_name_only_declared_columns()
{
// A typo in the owned list would silently turn a column into a carried one.
var declared = SessionIndexFieldManifest.All
.Concat(SessionIndexFieldManifest.PrimaryOnly)
.Append("IndexRowKey") // Sessions-only bookkeeping, deliberately outside the index manifest
.ToHashSet(StringComparer.Ordinal);
Assert.All(TableStorageService.RegistrationOwnedColumns, column => Assert.Contains(column, declared));
Assert.Equal(
TableStorageService.RegistrationOwnedColumns.Length,
TableStorageService.RegistrationOwnedColumns.Distinct(StringComparer.Ordinal).Count());
}
[Fact]
public async Task StoreSessionAsync_cas_retry_carries_the_values_of_its_own_fresh_read()
{
var startedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc));
var initial = new TableEntity(TenantId, SessionId) { ["StartedAt"] = startedAt, ["Status"] = "InProgress" };
initial.ETag = new ETag("0xINITIAL");
var firstFresh = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = startedAt, ["Status"] = "InProgress",
["ImeAgentVersion"] = "1.0", ["LastIngestAt"] = startedAt.AddMinutes(5),
};
firstFresh.ETag = new ETag("0xFRESH1");
var secondFresh = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = startedAt, ["Status"] = "InProgress",
["ImeAgentVersion"] = "2.0",
};
secondFresh.ETag = new ETag("0xFRESH2");
var harness = new Harness(initial);
harness.Sessions.SetupSequence(t => t.GetEntityAsync<TableEntity>(
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(initial, new Mock<Response>().Object))
.ReturnsAsync(Response.FromValue(firstFresh, new Mock<Response>().Object))
.ReturnsAsync(Response.FromValue(secondFresh, new Mock<Response>().Object));
var attempts = 0;
TableEntity? written = null;
harness.Sessions.Setup(t => t.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
{
if (++attempts == 1)
return Task.FromException<Response>(new RequestFailedException(412, "ConditionNotMet"));
written = e;
return Task.FromResult(new Mock<Response>().Object);
});
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
});
Assert.True(ok);
Assert.Equal(2, attempts);
Assert.Equal("2.0", written!.GetString("ImeAgentVersion"));
// Carried by the failed attempt, gone from the second fresh read: not carried again.
Assert.False(written.ContainsKey("LastIngestAt"));
}
[Fact]
public async Task StoreSessionAsync_does_not_carry_the_terminal_tuple_of_a_non_terminal_row()
{
// The terminal tuple is owned: it is re-stamped only while the fresh status is terminal.
// A WhiteGlove Part 1 row resuming into Part 2 keeps the old behaviour of dropping it.
var existing = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "Pending",
["CompletionSource"] = "whiteglove_part1",
["FailureSnapshotJson"] = "{}",
};
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var ok = await harness.Store(new SessionRegistration
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 2, 9, 0, 0, DateTimeKind.Utc),
});
Assert.True(ok);
Assert.Equal("InProgress", harness.Written!.GetString("Status"));
Assert.False(harness.Written.ContainsKey("CompletionSource"));
Assert.False(harness.Written.ContainsKey("FailureSnapshotJson"));
}
// ── Server facts: validator and Autopilot registration ──────────────────
private static SessionRegistration Reregistration() => new()
{
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
};
private static TableEntity ExistingRow(params (string Column, object Value)[] columns)
{
var row = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "InProgress",
};
foreach (var (column, value) in columns)
row[column] = value;
row.ETag = new ETag("0xEXISTING");
return row;
}
[Fact]
public async Task StoreSessionAsync_writes_the_autopilot_registration_of_a_new_session()
{
var harness = new Harness(existing: null);
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts
{
ValidatedBy = ValidatorType.AutopilotV1,
AutopilotRegistration = new AutopilotRegistration
{
DeviceId = "ap-1", GroupTag = "TEST", PurchaseOrderId = "PO-7", DisplayName = "LAB-01", UserAssigned = true,
},
});
Assert.True(ok);
Assert.Equal("AutopilotV1", harness.Written!.GetString("ValidatedBy"));
Assert.Equal("ap-1", harness.Written.GetString("AutopilotDeviceId"));
Assert.Equal("TEST", harness.Written.GetString("AutopilotGroupTag"));
Assert.Equal("PO-7", harness.Written.GetString("AutopilotPurchaseOrderId"));
Assert.Equal("LAB-01", harness.Written.GetString("AutopilotDisplayName"));
Assert.True(harness.Written.GetBoolean("AutopilotUserAssigned"));
}
[Fact]
public async Task StoreSessionAsync_a_fresh_autopilot_registration_replaces_the_stored_one_whole()
{
// The group tag was removed in Intune between two agent starts: the column clears.
var harness = new Harness(ExistingRow(
("AutopilotDeviceId", "ap-1"), ("AutopilotGroupTag", "OLD"), ("AutopilotPurchaseOrderId", "PO-1"),
("AutopilotDisplayName", "LAB-01"), ("AutopilotUserAssigned", true)));
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts
{
ValidatedBy = ValidatorType.AutopilotV1,
AutopilotRegistration = new AutopilotRegistration { DeviceId = "ap-1", PurchaseOrderId = "PO-2" },
});
Assert.True(ok);
Assert.False(harness.Written!.ContainsKey("AutopilotGroupTag"));
Assert.False(harness.Written.ContainsKey("AutopilotDisplayName"));
Assert.Equal("PO-2", harness.Written.GetString("AutopilotPurchaseOrderId"));
Assert.False(harness.Written.GetBoolean("AutopilotUserAssigned"));
}
[Fact]
public async Task StoreSessionAsync_without_an_autopilot_registration_keeps_the_stored_one_and_the_validator()
{
// A re-registration another path admitted (or with validation switched off) proves nothing
// about the Autopilot registration: the stored values and the recorded validator stay.
var harness = new Harness(ExistingRow(
("ValidatedBy", "AutopilotV1"), ("AutopilotDeviceId", "ap-1"), ("AutopilotGroupTag", "TEST"),
("AutopilotUserAssigned", false)));
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts());
Assert.True(ok);
Assert.Equal("AutopilotV1", harness.Written!.GetString("ValidatedBy"));
Assert.Equal("ap-1", harness.Written.GetString("AutopilotDeviceId"));
Assert.Equal("TEST", harness.Written.GetString("AutopilotGroupTag"));
Assert.False(harness.Written.GetBoolean("AutopilotUserAssigned"));
}
private sealed class Harness
{
public Mock<TableClient> Sessions { get; }
@@ -483,5 +691,9 @@ public class StoreSessionReregistrationPreserveTests
Sut = new TableStorageService(mockServiceClient.Object, NullLogger<TableStorageService>.Instance);
}
/// <summary>Stores with no server facts unless a test hands them in.</summary>
public Task<bool> Store(SessionRegistration registration, SessionOwner? owner = null, SessionServerFacts? facts = null)
=> Sut.StoreSessionAsync(registration, facts ?? new SessionServerFacts(), owner);
}
}
@@ -40,7 +40,7 @@ public class StoreSessionTenantLookupClaimTests
{
var harness = new Harness(existingOwner: null);
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
Assert.True(ok);
Assert.Equal(new[] { "lookup-add", "session-write" }, harness.Order);
@@ -54,7 +54,7 @@ public class StoreSessionTenantLookupClaimTests
{
var harness = new Harness(existingOwner: TenantA.ToUpperInvariant());
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
Assert.True(ok);
Assert.Contains("session-write", harness.Order);
@@ -67,7 +67,7 @@ public class StoreSessionTenantLookupClaimTests
var harness = new Harness(existingOwner: TenantA);
var ex = await Assert.ThrowsAsync<SessionTenantConflictException>(
() => harness.Sut.StoreSessionAsync(Registration(TenantB)));
() => harness.Sut.StoreSessionAsync(Registration(TenantB), new SessionServerFacts()));
Assert.Equal(SessionId, ex.SessionId);
Assert.Equal(TenantB, ex.RequestedTenantId);
@@ -87,7 +87,7 @@ public class StoreSessionTenantLookupClaimTests
harness.Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(503, "ServerBusy"));
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
Assert.False(ok);
Assert.DoesNotContain("session-write", harness.Order);
@@ -66,7 +66,7 @@ public sealed class SubmitGeneralFeedbackFunctionTests
[Fact]
public async Task TheAlert_CarriesNeitherTextNorSenderNorContactAddress()
{
// Channels bound to FeedbackReceived (Telegram, Slack, webhooks) receive message + details;
// Channels bound to FeedbackReceived (Slack, webhooks, e-mail) receive message + details;
// the feedback itself is read in the portal. Personal data must never ride along.
var h = new Harness();
@@ -52,7 +52,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
Assert.Equal("Pricing was too high for our use case.", h.Repo.PersistedOffboardingFeedback.Comment);
Assert.NotNull(h.Repo.PersistedOffboardingFeedback.InteractedAt);
// OpsEvent emitted with the canonical type catalog-registered for Telegram-routing.
// OpsEvent emitted with the canonical type catalog-registered for alert routing.
var ops = Assert.Single(h.OpsEvents);
Assert.Equal("OffboardingFeedbackReceived", ops.EventType);
Assert.Equal(TenantId, ops.TenantId);
@@ -283,7 +283,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
// Alert-dispatch dependencies are inert: empty AdminConfig service + bare HTTP
// clients. The feedback path emits Info-severity so even if alerts ARE evaluated,
// no Telegram/webhook fires for an unconfigured rule set.
// no channel fires for an unconfigured rule set.
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(),
@@ -0,0 +1,66 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ranking of <see cref="TableDistressReportRepository.KeepNewestAsync"/>: the table yields rows in
/// tenant order, so a cap must keep the newest rows across tenants, never the first rows read.
/// </summary>
public class TableDistressReportRepositoryNewestTests
{
private static readonly DateTime T0 = new(2026, 10, 20, 12, 0, 0, DateTimeKind.Utc);
private static DistressReportEntry Entry(string tenantId, int minute) => new()
{
TenantId = tenantId,
ErrorType = "DeviceNotRegistered",
SerialNumber = $"SN-{tenantId[0]}-{minute}",
IngestedAt = T0.AddMinutes(minute),
};
private static async IAsyncEnumerable<DistressReportEntry> Source(params DistressReportEntry[] entries)
{
foreach (var entry in entries)
{
await Task.Yield();
yield return entry;
}
}
// Tenant order as the table returns it: all rows of tenant "a", then tenant "b", each newest first.
private static DistressReportEntry[] TwoTenantsInterleavedInTime() =>
[
Entry("aaaaaaaa", 50), Entry("aaaaaaaa", 30), Entry("aaaaaaaa", 10),
Entry("bbbbbbbb", 60), Entry("bbbbbbbb", 40), Entry("bbbbbbbb", 20),
];
[Fact]
public async Task OverTheCap_KeepsNewestAcrossTenants_NewestFirst()
{
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(
Source(TwoTenantsInterleavedInTime()), maxResults: 4);
Assert.True(truncated);
Assert.Equal(new[] { 60, 50, 40, 30 }, reports.Select(r => (int)(r.IngestedAt - T0).TotalMinutes));
}
[Fact]
public async Task AtTheCap_ReturnsAll_NotTruncated()
{
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(
Source(TwoTenantsInterleavedInTime()), maxResults: 6);
Assert.False(truncated);
Assert.Equal(new[] { 60, 50, 40, 30, 20, 10 }, reports.Select(r => (int)(r.IngestedAt - T0).TotalMinutes));
}
[Fact]
public async Task EmptyTable_ReturnsEmpty_NotTruncated()
{
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(Source(), maxResults: 4);
Assert.Empty(reports);
Assert.False(truncated);
}
}
@@ -45,7 +45,7 @@ public class TableLifecycleBucketTests
[Constants.TableNames.GlobalAdmins] = "platform operator identities",
[Constants.TableNames.McpUsers] = "platform MCP identities (PK='McpUsers', RK=UPN)",
[Constants.TableNames.TenantGroupAssignments] = "per-UPN rows without tenant anchor; offboarding never touches it (see Constants doc)",
[Constants.TableNames.PreviewConfig] = "global TelegramBot config row",
[Constants.TableNames.PreviewConfig] = "global e-mail template overrides",
[Constants.TableNames.Feedback] = "product feedback deliberately survives offboarding (TableFeedbackRepositoryTests contract)",
[Constants.TableNames.BlockedVersions] = "platform-wide agent version blocks",
[Constants.TableNames.GlobalNotifications] = "GA notifications; time-retention only",
@@ -1,236 +0,0 @@
using System.Net.Http;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Telegram as a notification-channel provider: dispatch routing (it must never reach the
/// SSRF-guarded webhook path), chat-ID validation, and the Global-Admin gate that keeps a
/// tenant admin from sending through the platform-owned bot.
/// </summary>
public class TelegramChannelProviderTests
{
private static Mock<WebhookNotificationService> WebhookMock()
{
var mock = new Mock<WebhookNotificationService>(new HttpClient()) { CallBase = false };
mock.Setup(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
.ReturnsAsync(new NotificationSendResult { Success = true });
return mock;
}
private static Mock<TelegramNotificationService> TelegramMock()
{
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
{ CallBase = false };
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true });
return mock;
}
private static NotificationChannel Channel(int providerType, string? url, string id = "c1", bool enabled = true)
=> new() { Id = id, Name = id, ProviderType = providerType, Url = url, Enabled = enabled };
private static string ChannelJson(int providerType, string url, string id = "c1", bool enabled = true)
=> $"[{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":{(enabled ? "true" : "false")}}}]";
// ── Dispatch routing ──────────────────────────────────────────────────
[Fact]
public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(
new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert, NotificationScope.Platform);
telegram.Verify(t => t.SendAlertAsync("-1003785642894", alert), Times.Once);
webhook.Verify(w => w.SendAsync(
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SendToChannels_RoutesWebhookProvidersToWebhookService()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(
new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, NotificationScope.Platform);
webhook.Verify(w => w.SendAsync(
"https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
}
[Fact]
public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
var alert = new NotificationAlert { Title = "t", Summary = "s" };
await dispatcher.SendToChannelsAsync(new[]
{
Channel((int)WebhookProviderType.Telegram, "-100123", "tg"),
Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"),
}, alert, NotificationScope.Platform);
telegram.Verify(t => t.SendAlertAsync("-100123", alert), Times.Once);
webhook.Verify(w => w.SendAsync(
"https://sales.example/hook", WebhookProviderType.GenericJson, alert,
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task SendToChannels_SkipsChannelsWithoutDestination()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
await dispatcher.SendToChannelsAsync(new[]
{
Channel((int)WebhookProviderType.Telegram, null, "tg"),
Channel((int)WebhookProviderType.Slack, "", "slack"),
}, new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
webhook.Verify(w => w.SendAsync(
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SendTest_RoutesTelegramToBot()
{
var webhook = WebhookMock();
var telegram = TelegramMock();
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
var result = await dispatcher.SendTestAsync(
Channel((int)WebhookProviderType.Telegram, "@salesdesk"),
new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
Assert.True(result.Success);
telegram.Verify(t => t.SendAlertAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
}
// ── Chat-ID validation ────────────────────────────────────────────────
[Theory]
[InlineData("-1003785642894")]
[InlineData("123456789")]
[InlineData("@salesdesk")]
[InlineData("@Sales_Desk_01")]
public void ValidateTelegramChatId_AcceptsNumericAndUsernameForms(string chatId)
=> Assert.Null(TenantConfigValidation.ValidateTelegramChatId(chatId));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("https://api.telegram.org/bot123/sendMessage")]
[InlineData("-100abc")]
[InlineData("@abc")] // too short
[InlineData("@1sales")] // must start with a letter
[InlineData("@sales-desk")] // hyphen not allowed
[InlineData("123456789012345678901")] // 21 digits
public void ValidateTelegramChatId_RejectsEverythingElse(string? chatId)
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChatId(chatId));
[Fact]
public void ValidateNotificationChannels_TelegramChatIdBypassesSsrfUrlGate()
{
// A chat ID is not a URL — the webhook SSRF gate would reject it. Proves the
// provider-specific destination branch is wired, not just the format helper.
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
ChannelJson((int)WebhookProviderType.Telegram, "-1003785642894")));
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
ChannelJson((int)WebhookProviderType.Slack, "-1003785642894")));
}
[Fact]
public void ValidateNotificationChannels_RejectsTelegramChannelWithUrlDestination()
=> Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
ChannelJson((int)WebhookProviderType.Telegram, "https://hooks.example/x")));
// ── Global-Admin gate ─────────────────────────────────────────────────
[Fact]
public void TelegramGate_GlobalAdminMayAddChannel()
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: true));
[Fact]
public void TelegramGate_TenantAdminMayNotAddChannel()
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: false));
[Fact]
public void TelegramGate_TenantAdminMayNotRetargetExistingChannel()
{
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
var retargeted = ChannelJson((int)WebhookProviderType.Telegram, "-100999");
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, existing, isGlobalAdmin: false));
}
[Fact]
public void TelegramGate_TenantAdminMayNotFlipEnabledState()
{
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: true);
var disabled = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: false);
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(disabled, existing, isGlobalAdmin: false));
}
[Fact]
public void TelegramGate_TenantAdminMaySaveWithUnchangedGaCreatedChannel()
{
// The gate must not turn a GA-created Telegram channel into a permanent save block
// for the tenant admin's unrelated configuration changes.
var json = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
}
[Fact]
public void TelegramGate_IgnoresNonTelegramChannels()
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
ChannelJson((int)WebhookProviderType.GenericJson, "https://sales.example/hook"), null, isGlobalAdmin: false));
// ── Both write paths (PUT + field patch) go through ValidateModel ─────
[Fact]
public void ValidateModel_EnforcesTelegramGateForNonGlobalAdmin()
{
var candidate = new TenantConfiguration
{
TenantId = "t1",
NotificationChannelsJson = ChannelJson((int)WebhookProviderType.Telegram, "-100123"),
};
var existing = new TenantConfiguration { TenantId = "t1" };
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
}
}
@@ -310,6 +310,13 @@ public class TenantConfigPatchServiceTests
[InlineData("domainName")]
[InlineData("lastUpdated")]
[InlineData("updatedBy")]
[InlineData("graphAccessRejectedSince")]
[InlineData("graphAccessRejectedCode")]
[InlineData("graphAccessRejectionConfirmedAt")]
[InlineData("intuneDeviceBindingPermissionMissingSince")]
[InlineData("intuneDeviceBindingPermissionMissingConfirmedAt")]
[InlineData("cloudPcPermissionMissingSince")]
[InlineData("cloudPcPermissionMissingConfirmedAt")]
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
{
var harness = new Harness(Stored());
@@ -33,6 +33,9 @@ public class TenantConfigProjectionTests
"onboardedAt", "onboardedBy", "dpaVersion", "dpaAcceptancePending", "dpaAcceptedBy", "dpaAcceptedAt",
"lastUpdated", "dataRetentionDays",
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
"graphAccessRejectedSince", "graphAccessRejectedCode", "graphAccessRejectionConfirmedAt",
"intuneDeviceBindingPermissionMissingSince", "intuneDeviceBindingPermissionMissingConfirmedAt",
"cloudPcPermissionMissingSince", "cloudPcPermissionMissingConfirmedAt",
};
[Fact]
@@ -213,6 +213,66 @@ public class TenantConfigTableSerializationTests
Assert.Null(mapped.LastAuthClientIdSince);
}
[Fact]
public void Roundtrip_GraphAccessFields_SurviveStoreAndMap()
{
var since = new DateTime(2026, 11, 2, 8, 0, 0, DateTimeKind.Utc);
var config = new TenantConfiguration
{
TenantId = TenantId,
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
GraphAccessRejectedSince = since,
GraphAccessRejectedCode = "AADSTS7000229",
GraphAccessRejectionConfirmedAt = since.AddMinutes(20),
};
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.Equal(since, mapped.GraphAccessRejectedSince);
Assert.Equal("AADSTS7000229", mapped.GraphAccessRejectedCode);
Assert.Equal(since.AddMinutes(20), mapped.GraphAccessRejectionConfirmedAt);
}
[Fact]
public void Map_LegacyRow_WithoutGraphAccessColumns_ReadsNull()
{
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
Assert.Null(mapped.GraphAccessRejectedSince);
Assert.Null(mapped.GraphAccessRejectedCode);
Assert.Null(mapped.GraphAccessRejectionConfirmedAt);
Assert.Null(mapped.IntuneDeviceBindingPermissionMissingSince);
Assert.Null(mapped.CloudPcPermissionMissingConfirmedAt);
}
[Fact]
public void Roundtrip_AddOnPermissionFields_SurviveStoreAndMap()
{
var since = new DateTime(2026, 11, 4, 8, 0, 0, DateTimeKind.Utc);
var config = new TenantConfiguration
{
TenantId = TenantId,
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
IntuneDeviceBindingPermissionMissingSince = since,
IntuneDeviceBindingPermissionMissingConfirmedAt = since.AddMinutes(15),
CloudPcPermissionMissingSince = since.AddHours(1),
CloudPcPermissionMissingConfirmedAt = since.AddHours(2),
};
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.Equal(since, mapped.IntuneDeviceBindingPermissionMissingSince);
Assert.Equal(since.AddMinutes(15), mapped.IntuneDeviceBindingPermissionMissingConfirmedAt);
Assert.Equal(since.AddHours(1), mapped.CloudPcPermissionMissingSince);
Assert.Equal(since.AddHours(2), mapped.CloudPcPermissionMissingConfirmedAt);
}
[Theory]
[InlineData(null)]
[InlineData(0)]
@@ -1,7 +1,6 @@
using System.Net.Http;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -10,11 +9,10 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Inert notification plumbing for tests that need an <see cref="OpsEventService"/> but do not
/// assert on anything being sent. Nothing leaves the process: the webhook transport gets a bare
/// HttpClient it never uses (no channel is configured in these stubs), and the Telegram transport
/// resolves its bot URL from a mocked repository that returns nothing.
/// HttpClient it never uses (no channel is configured in these stubs).
/// <para>
/// Tests that DO assert on dispatch should mock the transports directly — see
/// <c>TelegramChannelProviderTests</c> — rather than extending this helper.
/// <c>EmailChannelProviderTests</c> — rather than extending this helper.
/// </para>
/// </summary>
internal static class TestNotifications
@@ -22,8 +20,6 @@ internal static class TestNotifications
internal static NotificationChannelDispatcher InertDispatcher()
=> new(
new WebhookNotificationService(new HttpClient()),
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
Mock.Of<IPushChannelSender>());
internal static OpsAlertDispatchService InertOpsAlertDispatch(AdminConfigurationService adminConfigService)
@@ -376,6 +376,11 @@ public class TypedContractCompletionWireParityTests
bootstrapTokenEnabled = true,
diagnosticsUploadConfigured = false,
deviceValidationEnabled = true,
graphAccessRejected = true,
graphAccessAppMissing = false,
intuneDeviceBindingPermissionMissing = true,
cloudPcPermissionMissing = false,
addOnPermissionMissingBlocksAll = false,
appHomingFunnelActive = false,
showScriptOutput = true,
enableSoftwareInventoryAnalyzer = false,
@@ -402,6 +407,11 @@ public class TypedContractCompletionWireParityTests
BootstrapTokenEnabled = true,
DiagnosticsUploadConfigured = false,
DeviceValidationEnabled = true,
GraphAccessRejected = true,
GraphAccessAppMissing = false,
IntuneDeviceBindingPermissionMissing = true,
CloudPcPermissionMissing = false,
AddOnPermissionMissingBlocksAll = false,
AppHomingFunnelActive = false,
ShowScriptOutput = true,
EnableSoftwareInventoryAnalyzer = false,
@@ -88,8 +88,7 @@ public class WhatsNewNotificationServiceTests
public Harness()
{
var webhook = new WebhookNotificationService(new HttpClient());
var telegram = new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance);
Dispatcher = new Mock<NotificationChannelDispatcher>(webhook, telegram, Mock.Of<IPushChannelSender>());
Dispatcher = new Mock<NotificationChannelDispatcher>(webhook, Mock.Of<IPushChannelSender>());
Dispatcher
.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>(), It.IsAny<NotificationScope>()))
.Returns<IEnumerable<NotificationChannel>, NotificationAlert, NotificationScope>((channels, alert, scope) =>
@@ -434,14 +433,14 @@ public class WhatsNewNotificationServiceTests
}
[Fact]
public void BuildWhatsNewAlert_TelegramText_CarriesSectionsAndLinks()
public void BuildWhatsNewAlert_EmailText_CarriesSectionsAndLinks()
{
var alert = NotificationAlertBuilder.BuildWhatsNewAlert(
new List<WhatsNewFeedEntry> { Entry("platform", "p", title: "**Lead**", body: "Body.") },
null,
WhatsNewNotificationService.PortalWhatsNewUrl("platform"));
var text = TelegramNotificationService.RenderAlertText(alert);
var text = EmailAlertRenderer.Text(alert);
Assert.Contains("What's new in Autopilot Monitor", text);
Assert.Contains("Platform \u00b7 Sep 8, 2026 \u2014 Lead", text);
@@ -32,12 +32,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
/// <summary>
/// Changes to ONLY these properties do not snapshot the tenant-config row: the
/// LastAuthClientId pair flips as an auth-flow side effect and would flood the
/// two backup slots with states nobody ever wants to revert to.
/// LastAuthClientId pair flips as an auth-flow side effect and the Graph access state as
/// a token side effect; both would flood the two backup slots with states nobody ever
/// wants to revert to.
/// </summary>
private static readonly HashSet<string> TenantBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
{
"LastUpdated", "UpdatedBy", "LastAuthClientId", "LastAuthClientIdSince",
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
};
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
@@ -541,62 +545,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
// --- Preview Config ---
public async Task<Dictionary<string, string>> GetPreviewConfigAsync()
{
try
{
var config = new Dictionary<string, string>();
var entity = await _previewConfigTableClient.GetEntityAsync<TableEntity>("TelegramBot", "config");
foreach (var kvp in entity.Value)
{
if (kvp.Key == "odata.etag" || kvp.Key == "PartitionKey" || kvp.Key == "RowKey" || kvp.Key == "Timestamp")
continue;
config[kvp.Key] = kvp.Value?.ToString() ?? string.Empty;
}
return config;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return new Dictionary<string, string>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading preview config");
throw;
}
}
public async Task<bool> SavePreviewConfigAsync(string key, string value)
{
try
{
// Get existing entity or create new one
TableEntity entity;
try
{
var existing = await _previewConfigTableClient.GetEntityAsync<TableEntity>("TelegramBot", "config");
entity = existing.Value;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
entity = new TableEntity("TelegramBot", "config");
}
entity[key] = value;
await _previewConfigTableClient.UpsertEntityAsync(entity);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving preview config key {Key}", key);
return false;
}
}
// --- Email Template Overrides ---
private const string EmailTemplatesPartition = "EmailTemplates";
@@ -871,6 +819,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "HomedAppClientId", config.HomedAppClientId },
{ "LastAuthClientId", config.LastAuthClientId },
{ "LastAuthClientIdSince", config.LastAuthClientIdSince },
{ "GraphAccessRejectedSince", config.GraphAccessRejectedSince },
{ "GraphAccessRejectedCode", config.GraphAccessRejectedCode },
{ "GraphAccessRejectionConfirmedAt", config.GraphAccessRejectionConfirmedAt },
{ "IntuneDeviceBindingPermissionMissingSince", config.IntuneDeviceBindingPermissionMissingSince },
{ "IntuneDeviceBindingPermissionMissingConfirmedAt", config.IntuneDeviceBindingPermissionMissingConfirmedAt },
{ "CloudPcPermissionMissingSince", config.CloudPcPermissionMissingSince },
{ "CloudPcPermissionMissingConfirmedAt", config.CloudPcPermissionMissingConfirmedAt },
{ "PlanTier", config.PlanTier },
{ "TrialExpiresUtc", config.TrialExpiresUtc },
{ "TrialStartedUtc", config.TrialStartedUtc },
@@ -994,6 +949,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
HomedAppClientId = entity.GetString("HomedAppClientId"),
LastAuthClientId = entity.GetString("LastAuthClientId"),
LastAuthClientIdSince = entity.GetDateTime("LastAuthClientIdSince"),
GraphAccessRejectedSince = entity.GetDateTime("GraphAccessRejectedSince"),
GraphAccessRejectedCode = entity.GetString("GraphAccessRejectedCode"),
GraphAccessRejectionConfirmedAt = entity.GetDateTime("GraphAccessRejectionConfirmedAt"),
IntuneDeviceBindingPermissionMissingSince = entity.GetDateTime("IntuneDeviceBindingPermissionMissingSince"),
IntuneDeviceBindingPermissionMissingConfirmedAt = entity.GetDateTime("IntuneDeviceBindingPermissionMissingConfirmedAt"),
CloudPcPermissionMissingSince = entity.GetDateTime("CloudPcPermissionMissingSince"),
CloudPcPermissionMissingConfirmedAt = entity.GetDateTime("CloudPcPermissionMissingConfirmedAt"),
PlanTier = entity.GetString("PlanTier") ?? "free",
TrialExpiresUtc = entity.GetDateTime("TrialExpiresUtc"),
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
@@ -1054,12 +1016,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "SlaNotificationCooldownHours", config.SlaNotificationCooldownHours },
// Ops Alert settings
{ "OpsAlertRulesJson", config.OpsAlertRulesJson ?? string.Empty },
{ "OpsAlertTelegramEnabled", config.OpsAlertTelegramEnabled },
{ "OpsAlertTelegramChatId", config.OpsAlertTelegramChatId ?? string.Empty },
{ "OpsAlertTeamsEnabled", config.OpsAlertTeamsEnabled },
{ "OpsAlertTeamsWebhookUrl", config.OpsAlertTeamsWebhookUrl ?? string.Empty },
{ "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled },
{ "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty },
{ "OpsNotificationChannelsJson", config.OpsNotificationChannelsJson ?? string.Empty },
// Per-line agent binary integrity: written by the release pipeline via Merge.
// The backend never changes them (the PATCH gate rejects them), so the
@@ -1130,12 +1086,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
SlaNotificationCooldownHours = entity.GetInt32("SlaNotificationCooldownHours") ?? 24,
// Ops Alert settings
OpsAlertRulesJson = entity.GetString("OpsAlertRulesJson"),
OpsAlertTelegramEnabled = entity.GetBoolean("OpsAlertTelegramEnabled") ?? false,
OpsAlertTelegramChatId = entity.GetString("OpsAlertTelegramChatId"),
OpsAlertTeamsEnabled = entity.GetBoolean("OpsAlertTeamsEnabled") ?? false,
OpsAlertTeamsWebhookUrl = entity.GetString("OpsAlertTeamsWebhookUrl"),
OpsAlertSlackEnabled = entity.GetBoolean("OpsAlertSlackEnabled") ?? false,
OpsAlertSlackWebhookUrl = entity.GetString("OpsAlertSlackWebhookUrl"),
OpsNotificationChannelsJson = entity.GetString("OpsNotificationChannelsJson"),
// Per-line agent binary integrity. V2 is the only wired line (V1 retired).
AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false,
@@ -78,18 +78,44 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return result;
}
public async Task<List<DistressReportEntry>> GetAllDistressReportsAsync(int maxResults = 500)
public Task<(List<DistressReportEntry> Reports, bool Truncated)> GetNewestDistressReportsAsync(int maxResults)
{
var result = new List<DistressReportEntry>();
// A cross-partition query returns rows in tenant order, so the first N rows are not the
// newest N: the whole table is read (bounded by the 14-day cleanup) and ranked here.
return KeepNewestAsync(MapAll(_table.QueryAsync<TableEntity>(maxPerPage: 1000)), maxResults);
}
await foreach (var entity in _table.QueryAsync<TableEntity>(maxPerPage: maxResults))
private static async IAsyncEnumerable<DistressReportEntry> MapAll(IAsyncEnumerable<TableEntity> entities)
{
await foreach (var entity in entities)
yield return MapToEntry(entity);
}
/// <summary>
/// The newest <paramref name="maxResults"/> entries by IngestedAt, newest first. A min-heap keeps
/// memory at <paramref name="maxResults"/> however many rows the source yields.
/// </summary>
internal static async Task<(List<DistressReportEntry> Reports, bool Truncated)> KeepNewestAsync(
IAsyncEnumerable<DistressReportEntry> entries, int maxResults)
{
var newest = new PriorityQueue<DistressReportEntry, DateTime>();
var seen = 0;
await foreach (var entry in entries)
{
result.Add(MapToEntry(entity));
if (result.Count >= maxResults)
break;
seen++;
if (newest.Count < maxResults)
newest.Enqueue(entry, entry.IngestedAt);
else
newest.EnqueueDequeue(entry, entry.IngestedAt);
}
return result;
var reports = new List<DistressReportEntry>(newest.Count);
while (newest.TryDequeue(out var entry, out _))
reports.Add(entry);
reports.Reverse();
return (reports, seen > maxResults);
}
public async Task<int> DeleteDistressReportsOlderThanAsync(string tenantId, DateTime cutoff)
@@ -22,9 +22,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
_publisher = publisher;
}
public async Task<bool> StoreSessionAsync(SessionRegistration registration, SessionOwner? ownerToStamp = null)
public async Task<bool> StoreSessionAsync(SessionRegistration registration, SessionServerFacts serverFacts, SessionOwner? ownerToStamp = null)
{
var result = await _storage.StoreSessionAsync(registration, ownerToStamp);
var result = await _storage.StoreSessionAsync(registration, serverFacts, ownerToStamp);
if (result)
await _publisher.PublishAsync("session.created", registration, registration.TenantId);
return result;
@@ -266,9 +266,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10)
=> _storage.QuickSearchSessionsAsync(tenantId, query, limit);
public Task<List<SessionSummary>> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter)
=> _storage.SearchSessionsAsync(tenantId, filter);
public Task<RawPage<SessionSummary>> SearchSessionsPageAsync(string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
=> _storage.SearchSessionsPageAsync(tenantId, filter, pageSize, continuation);
@@ -302,13 +302,15 @@ public class AutopilotDeviceValidationConsentFunction
/// <summary>
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog:
/// one per serial-based validation (Autopilot, corporate identifier, and
/// "device-preparation" for Device Association Validation).
/// "device-preparation" for Device Association Validation), plus "restore" for repairing the
/// Graph access of validations that are already on.
/// </summary>
internal static readonly HashSet<string> KnownConsentTriggers = new(StringComparer.OrdinalIgnoreCase)
{
"autopilot",
"corporate",
"device-preparation",
"restore",
};
/// <summary>
@@ -14,17 +14,20 @@ namespace AutopilotMonitor.Functions.Functions.Admin
private readonly MaintenanceService _maintenanceService;
private readonly Services.Push.PushMaintenanceService _pushMaintenance;
private readonly Services.Notifications.NotificationChannelHealthMaintenance _channelHealthMaintenance;
private readonly GraphAccessRecheckMaintenance _graphAccessRecheck;
private readonly ILogger<MaintenanceFunction> _logger;
public MaintenanceFunction(
MaintenanceService maintenanceService,
Services.Push.PushMaintenanceService pushMaintenance,
Services.Notifications.NotificationChannelHealthMaintenance channelHealthMaintenance,
GraphAccessRecheckMaintenance graphAccessRecheck,
ILogger<MaintenanceFunction> logger)
{
_maintenanceService = maintenanceService;
_pushMaintenance = pushMaintenance;
_channelHealthMaintenance = channelHealthMaintenance;
_graphAccessRecheck = graphAccessRecheck;
_logger = logger;
}
@@ -45,6 +48,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin
await _pushMaintenance.RunAsync();
// Health rows of deleted notification channels (D-342); fail-soft inside.
await _channelHealthMaintenance.RunAsync();
// Confirms or clears Graph access suspicions without waiting for device traffic; fail-soft inside.
await _graphAccessRecheck.RunAsync();
}
}
}
@@ -10,7 +10,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
/// Timer-triggered watcher for the Azure SignalR plan limits (Standard S1
/// x1 unit: 1,000 concurrent connections / 1M messages per UTC day). Runs
/// every full hour and emits OpsEvents when the configured thresholds are
/// crossed so operators get a Telegram ping before clients start getting
/// crossed so operators get an alert before clients start getting
/// 429'd or message overage is billed.
///
/// Separate from the 2h MaintenanceFunction: the SignalR limits can be
@@ -178,8 +178,7 @@ public class SubmitOffboardingFeedbackFunction
}
// Best-effort OpsEvent so operators see "feedback received" on the Ops dashboard.
// Telegram-wiring done by the admin via OpsAlertRules UI (memory:
// feedback_ops_event_types_dual_register).
// Alert channels are bound by the admin in the Ops Alert Rules UI.
try
{
await _opsEvents.RecordOffboardingFeedbackReceivedAsync(
@@ -3,7 +3,6 @@ using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -37,24 +36,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
var config = await _adminConfigService.GetConfigurationAsync();
// Ops channels: a tenant that predates named channels stores an empty list, and the
// dispatcher synthesizes one channel per configured legacy provider slot. Project the
// SAME synthesis into the read model so the editor shows what actually receives
// alerts today — the alternative is a second copy of the synthesis rules in the web
// app, drifting from the one the dispatcher uses. The first save then persists
// exactly the list the operator was shown. Never mutates the cached instance.
if (string.IsNullOrWhiteSpace(config.OpsNotificationChannelsJson))
{
var synthesized = config.GetOpsNotificationChannels();
if (synthesized.Count > 0)
{
config = config.ShallowCopy();
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(synthesized);
}
}
// A read-only GlobalReader gets secrets (NvdApiKey, SAS, ops webhook URLs and the
// per-channel destinations) redacted; a Global Admin gets the full config. Redaction
// A read-only GlobalReader gets secrets (NvdApiKey, SAS and the per-channel
// destinations) redacted; a Global Admin gets the full config. Redaction
// returns a copy — the cached instance is never mutated.
if (requestCtx.IsGlobalReader)
config = config.RedactedCopyForReader();
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly KillSwitchEvaluator _killSwitchEvaluator;
private readonly IGraphAccessHealthRecorder _accessHealth;
public GetAgentConfigFunction(
ILogger<GetAgentConfigFunction> logger,
@@ -42,8 +43,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
KillSwitchEvaluator killSwitchEvaluator)
KillSwitchEvaluator killSwitchEvaluator,
IGraphAccessHealthRecorder accessHealth)
{
_accessHealth = accessHealth;
_logger = logger;
_configService = configService;
_adminConfigService = adminConfigService;
@@ -85,7 +88,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
accessHealth: _accessHealth
);
if (errorResponse != null)
@@ -74,6 +74,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var resolution = FeatureEntitlementCatalog.Resolve(config, nowUtc);
var entitlements = FeatureEntitlementCatalog.Get(resolution);
var isTrial = resolution.IsTrial;
var graphAccessRejected = config.HasAnyDeviceValidation() && config.GraphAccessRejectionConfirmedAt != null;
return new TenantFeatureFlagsResponse
{
@@ -87,6 +88,17 @@ namespace AutopilotMonitor.Functions.Functions.Config
DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
// Drives the "no device validation enabled" dashboard banner (useTenantSecurityConfig).
DeviceValidationEnabled = config.HasAnyDeviceValidation(),
// Drives the "enrollment monitoring is interrupted" notice. A suspicion alone shows
// nothing; the error code itself stays in the admin-gated full config.
GraphAccessRejected = graphAccessRejected,
GraphAccessAppMissing = graphAccessRejected
&& string.Equals(config.GraphAccessRejectedCode, EntraTokenRejection.ServicePrincipalMissingCode, StringComparison.OrdinalIgnoreCase),
// Drive the "devices aren't being monitored" notice: a switched-on validation kept
// devices out and still lacks its optional permission. Switching it off hides the
// notice at once; the state itself ends in the next maintenance run.
IntuneDeviceBindingPermissionMissing = AddOnValidation.IntuneDeviceBinding.IsConfirmedMissing(config),
CloudPcPermissionMissing = AddOnValidation.CloudPc.IsConfirmedMissing(config),
AddOnPermissionMissingBlocksAll = AddOnValidation.BlocksAllValidation(config),
// Dual app-reg self-service migration: when true, running the consent flow (or
// "Detect existing access") targets the NEW app registration and auto-flips this
// tenant's homing after verification — drives the explanatory banner in the
@@ -207,12 +207,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
nameof(AdminConfiguration.OpsNotificationChannelsJson), StringComparison.OrdinalIgnoreCase)))
{
// A GlobalReader is served destinations as the ***REDACTED*** sentinel; persisting it as a
// chat ID or webhook URL would silently break every alert, so restore per channel id.
// recipient or webhook URL would silently break every alert, so restore per channel id.
draft.OpsNotificationChannelsJson = NotificationChannel.RestoreRedactedList(
draft.OpsNotificationChannelsJson, storedChannels);
// Same structural gate and one-Push-channel rule the tenant channel list passes. No GA
// gate on Telegram here: this endpoint is GlobalAdminOnly by policy.
// gate on platform providers here: this endpoint is GlobalAdminOnly by policy.
var channelsError = TenantConfigValidation.ValidateNotificationChannels(draft.OpsNotificationChannelsJson)
?? TenantConfigValidation.ValidateSinglePushChannel(draft.OpsNotificationChannelsJson, storedChannels);
if (channelsError != null)
@@ -58,6 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly IGraphAccessHealthRecorder _accessHealth;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
private readonly ISessionDeletionInventoryReader _sessionRowReader;
@@ -73,6 +74,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
IGraphAccessHealthRecorder accessHealth,
BootstrapSessionService bootstrapSessionService,
HostedDiagnosticsBlobService hostedDiagnostics,
ISessionDeletionInventoryReader sessionRowReader,
@@ -89,6 +91,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_accessHealth = accessHealth;
_bootstrapSessionService = bootstrapSessionService;
_hostedDiagnostics = hostedDiagnostics;
}
@@ -141,7 +144,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
accessHealth: _accessHealth
);
if (errorResponse != null)
@@ -21,7 +21,6 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
private readonly TenantConfigurationService _tenantConfigService;
private readonly AdminConfigurationService _adminConfigService;
private readonly TenantAdminsService _tenantAdminsService;
private readonly TelegramNotificationService _telegramNotificationService;
private readonly ISessionRepository _sessionRepo;
private readonly IFeedbackRepository _feedbackRepo;
private readonly OpsEventService _opsEvents;
@@ -32,7 +31,6 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
TenantConfigurationService tenantConfigService,
AdminConfigurationService adminConfigService,
TenantAdminsService tenantAdminsService,
TelegramNotificationService telegramNotificationService,
ISessionRepository sessionRepo,
IFeedbackRepository feedbackRepo,
OpsEventService opsEvents,
@@ -42,7 +40,6 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
_tenantConfigService = tenantConfigService;
_adminConfigService = adminConfigService;
_tenantAdminsService = tenantAdminsService;
_telegramNotificationService = telegramNotificationService;
_sessionRepo = sessionRepo;
_feedbackRepo = feedbackRepo;
_opsEvents = opsEvents;
@@ -185,7 +182,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
// Only a rating that was actually recorded is announced — at most once per person.
if (stored is { Submitted: true } && stored.Rating is int rating)
await AnnounceRatingAsync(tenantId, upn, displayName, stored.FeedbackId!, rating, comment);
await AnnounceRatingAsync(tenantId, upn, stored.FeedbackId!, rating, comment);
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
stored == null ? "unchanged" : body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
@@ -196,13 +193,11 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
/// <summary>
/// The signals of a recorded rating, all best effort: a stored rating never fails because of
/// them. The ops event carries the stars and the comment length only, so an alert channel
/// receives no personal data; the Telegram ping keeps sender and comment until it is retired.
/// receives no personal data.
/// </summary>
private async Task AnnounceRatingAsync(
string tenantId, string upn, string displayName, string feedbackId, int rating, string? comment)
string tenantId, string upn, string feedbackId, int rating, string? comment)
{
_ = _telegramNotificationService.SendFeedbackAsync(tenantId, upn, displayName, rating, comment);
var normalizedTenantId = tenantId.ToLowerInvariant();
// The domain makes the alert and the bell readable. The lookup never creates a
// configuration row; an unknown tenant simply keeps its GUID label.
@@ -31,7 +31,6 @@ public class AuthFunction
private readonly TenantAdminsService _tenantAdminsService;
private readonly IMetricsRepository _metricsRepo;
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TelegramNotificationService _telegramNotificationService;
private readonly GlobalNotificationService _globalNotificationService;
private readonly McpUserService _mcpUserService;
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
@@ -41,7 +40,7 @@ public class AuthFunction
private readonly AdminConfigurationService _adminConfigService;
private readonly IOffboardingAuditRepository _offboardingRepo;
private readonly Services.Push.PushOwnerSignInService? _pushSignIn;
private readonly OpsEventService? _opsEvents;
private readonly OpsEventService _opsEvents;
public AuthFunction(
ILogger<AuthFunction> logger,
@@ -51,7 +50,6 @@ public class AuthFunction
TenantAdminsService tenantAdminsService,
IMetricsRepository metricsRepo,
PreviewWhitelistService previewWhitelistService,
TelegramNotificationService telegramNotificationService,
GlobalNotificationService globalNotificationService,
McpUserService mcpUserService,
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
@@ -60,8 +58,8 @@ public class AuthFunction
LiveStreamRevoker streams,
AdminConfigurationService adminConfigService,
IOffboardingAuditRepository offboardingRepo,
Services.Push.PushOwnerSignInService? pushSignIn = null,
OpsEventService? opsEvents = null)
OpsEventService opsEvents,
Services.Push.PushOwnerSignInService? pushSignIn = null)
{
_logger = logger;
_pushSignIn = pushSignIn;
@@ -76,7 +74,6 @@ public class AuthFunction
_tenantAdminsService = tenantAdminsService;
_metricsRepo = metricsRepo;
_previewWhitelistService = previewWhitelistService;
_telegramNotificationService = telegramNotificationService;
_globalNotificationService = globalNotificationService;
_mcpUserService = mcpUserService;
_tenantAutoApproveEnqueuer = tenantAutoApproveEnqueuer;
@@ -444,7 +441,7 @@ public class AuthFunction
/// <summary>
/// If the tenant has no domain name yet, extracts it from the UPN, persists it,
/// and fires best-effort notifications (Telegram + global notification). The write lands
/// and fires the signup signals (ops event + global notification). The write lands
/// only while the STORED row still has no domain (conditional create or update), and only
/// the login that wrote it fires the signup side effects.
/// </summary>
@@ -559,15 +556,8 @@ public class AuthFunction
Seed(tenantConfig);
// The ops event is the routable record (alert rules → any channel, Push included); it is
// written before the response and never throws. The Telegram ping below stays until retired.
if (_opsEvents != null)
await _opsEvents.RecordTenantSignupAsync(tenantId, upn);
// Fire-and-forget: Telegram
_ = _telegramNotificationService.SendNewTenantSignupAsync(tenantId, upn)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget Telegram notification failed for tenant {TenantId}", tenantId),
TaskContinuationOptions.OnlyOnFaulted);
// written before the response and never throws.
await _opsEvents.RecordTenantSignupAsync(tenantId, upn);
// Fire-and-forget: Global notification
_ = _globalNotificationService.CreateNotificationAsync(
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
/// <summary>
/// Receives Azure Monitor alert notifications (Common Alert Schema) from an Action Group
/// webhook receiver and re-emits them as ops events, so platform alerts flow through the
/// same OpsEventService → OpsAlertDispatchService fan-out (Telegram/Teams/Slack providers +
/// same OpsEventService → OpsAlertDispatchService fan-out (the notification channels +
/// the portal ops-events list) as every internally raised operational event.
///
/// The delivery path is deliberately independent of the portal: Azure Monitor → this
@@ -53,6 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly IGraphAccessHealthRecorder _accessHealth;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly KillSwitchEvaluator _killSwitchEvaluator;
private readonly SessionDeletionGuard _deletionGuard;
@@ -73,6 +74,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
IGraphAccessHealthRecorder accessHealth,
BootstrapSessionService bootstrapSessionService,
KillSwitchEvaluator killSwitchEvaluator,
SessionDeletionGuard deletionGuard,
@@ -92,6 +94,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_accessHealth = accessHealth;
_bootstrapSessionService = bootstrapSessionService;
_killSwitchEvaluator = killSwitchEvaluator;
_deletionGuard = deletionGuard;
@@ -125,7 +128,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator);
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
accessHealth: _accessHealth);
if (errorResponse != null) return AsOutput(errorResponse);
@@ -40,6 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly IGraphAccessHealthRecorder _accessHealth;
private readonly TelemetryClient _telemetryClient;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly ISessionRepository _sessionRepo;
@@ -58,6 +59,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
IGraphAccessHealthRecorder accessHealth,
TelemetryClient telemetryClient,
BootstrapSessionService bootstrapSessionService,
ISessionRepository sessionRepo,
@@ -75,6 +77,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_accessHealth = accessHealth;
_telemetryClient = telemetryClient;
_bootstrapSessionService = bootstrapSessionService;
_sessionRepo = sessionRepo;
@@ -115,7 +118,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
accessHealth: _accessHealth
);
if (errorResponse != null)
@@ -29,12 +29,12 @@ namespace AutopilotMonitor.Functions.Functions.Maintenance
/// Independent cadence from the 2h <c>Maintenance</c> function so cascade-lifecycle
/// work cannot starve rule-stats aggregation / cert-expiry checks, and operators get a
/// dedicated OpsEvent stream (<see cref="OpsEventService.RecordSessionDeletionMaintenanceLongRunningAsync"/>
/// + siblings) wired to Telegram via the standard alert-rules UI.
/// + siblings) wired to channels via the standard alert-rules UI.
/// </para>
/// <para>
/// 30-minute watchdog emits a Warning OpsEvent; 60-minute watchdog escalates to Error. The
/// 60-minute mark matches the Flex Consumption hard <c>functionTimeout</c> (host.json) so the
/// operator gets a Telegram alert before the Azure runtime abort. The retention fanout stops
/// operator gets an alert before the Azure runtime abort. The retention fanout stops
/// itself at the <see cref="RunBudget"/> (50min) deadline so a growing backlog degrades into
/// a clean <c>BudgetExceeded</c> + partial sweep instead of a host abort. Cron
/// <c>0 0 */12 * * *</c> (every 12h at 00:00 / 12:00 UTC) — const per repo convention;
@@ -36,13 +36,15 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var query = req.Query;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
if (!FleetGroupTagFilter.TryParse(query["excludeGroupTags"], query["excludeUntagged"], out var groupTagFilter, out var filterError))
return await req.BadRequestAsync(filterError!);
_logger.LogInformation("Fetching fleet health for tenant {TenantId} (days={Days})", tenantId, days);
var sessions = await _sessionRepo.GetSessionsAsync(tenantId, days);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days));
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days, groupTagFilter));
return response;
}
catch (Exception ex)
@@ -36,6 +36,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var query = req.Query;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
if (!FleetGroupTagFilter.TryParse(query["excludeGroupTags"], query["excludeUntagged"], out var groupTagFilter, out var filterError))
return await req.BadRequestAsync(filterError!);
var tenantIdFilter = query["tenantId"];
@@ -47,7 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
string.IsNullOrWhiteSpace(tenantIdFilter) ? null : tenantIdFilter, days);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days));
await response.WriteAsJsonAsync(MetricsMath.BuildFleetHealthPayload(sessions, days, groupTagFilter));
return response;
}
catch (Exception ex)
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
return await req.BadRequestAsync(pagination.Error);
}
// Build search filter — Limit is ignored, pagination owns size.
// Build search filter — pagination owns size.
var filter = new SessionSearchFilter
{
Status = string.IsNullOrEmpty(status) ? null : status,
@@ -128,6 +128,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]);
var connectionType = query["connectionType"];
if (!string.IsNullOrEmpty(connectionType)) filter.ConnectionType = connectionType;
var groupTag = query["groupTag"];
if (!string.IsNullOrWhiteSpace(groupTag)) filter.GroupTag = groupTag.Trim();
var callerTenantId = TenantHelper.GetTenantId(req);
@@ -9,11 +9,15 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// Global Admin endpoint: returns all distress reports from the DistressReports table.
/// Global Admin endpoint: returns the newest distress reports from the DistressReports table.
/// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
/// </summary>
public class GetDistressReportsFunction
{
// About twice the rows a 14-day retention window holds at today's peak rate; the portal
// searches this list client-side, so it should normally hold the whole table.
private const int MaxReports = 2000;
private readonly ILogger<GetDistressReportsFunction> _logger;
private readonly IDistressReportRepository _repository;
@@ -31,14 +35,15 @@ namespace AutopilotMonitor.Functions.Functions.Reports
{
try
{
var reports = await _repository.GetAllDistressReportsAsync(maxResults: 500);
var (reports, truncated) = await _repository.GetNewestDistressReportsAsync(MaxReports);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new DistressReportListResponse
{
Success = true,
Count = reports.Count,
Reports = reports
Reports = reports,
Truncated = truncated
});
return response;
}
@@ -22,24 +22,21 @@ namespace AutopilotMonitor.Functions.Functions.Reports
private readonly ILogger<SubmitDiagFilesReportFunction> _logger;
private readonly SessionReportService _sessionReportService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly TelegramNotificationService _telegramNotificationService;
private readonly GlobalNotificationService _globalNotificationService;
private readonly ReportUploadGate _uploadGate;
private readonly OpsEventService? _opsEvents;
private readonly OpsEventService _opsEvents;
public SubmitDiagFilesReportFunction(
ILogger<SubmitDiagFilesReportFunction> logger,
SessionReportService sessionReportService,
IMaintenanceRepository maintenanceRepo,
TelegramNotificationService telegramNotificationService,
GlobalNotificationService globalNotificationService,
ReportUploadGate uploadGate,
OpsEventService? opsEvents = null)
OpsEventService opsEvents)
{
_logger = logger;
_sessionReportService = sessionReportService;
_maintenanceRepo = maintenanceRepo;
_telegramNotificationService = telegramNotificationService;
_globalNotificationService = globalNotificationService;
_uploadGate = uploadGate;
_opsEvents = opsEvents;
@@ -122,12 +119,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
}
// Ops event (routable through alert rules, Push included) — written before the response, never throws
if (_opsEvents != null)
await _opsEvents.RecordDiagFilesReportSubmittedAsync(request.TenantId, userIdentifier, metadata.ReportId);
// Telegram notification — best effort
_ = _telegramNotificationService.SendDiagFilesReportAsync(
request.TenantId, userIdentifier, metadata.ReportId, request.Comment ?? string.Empty);
await _opsEvents.RecordDiagFilesReportSubmittedAsync(request.TenantId, userIdentifier, metadata.ReportId);
// Persistent in-app notification for Global Admins — best effort
_ = _globalNotificationService.CreateNotificationAsync(
@@ -16,24 +16,21 @@ namespace AutopilotMonitor.Functions.Functions.Reports
private readonly ILogger<SubmitSessionReportFunction> _logger;
private readonly SessionReportService _sessionReportService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly TelegramNotificationService _telegramNotificationService;
private readonly GlobalNotificationService _globalNotificationService;
private readonly ReportUploadGate _uploadGate;
private readonly OpsEventService? _opsEvents;
private readonly OpsEventService _opsEvents;
public SubmitSessionReportFunction(
ILogger<SubmitSessionReportFunction> logger,
SessionReportService sessionReportService,
IMaintenanceRepository maintenanceRepo,
TelegramNotificationService telegramNotificationService,
GlobalNotificationService globalNotificationService,
ReportUploadGate uploadGate,
OpsEventService? opsEvents = null)
OpsEventService opsEvents)
{
_logger = logger;
_sessionReportService = sessionReportService;
_maintenanceRepo = maintenanceRepo;
_telegramNotificationService = telegramNotificationService;
_globalNotificationService = globalNotificationService;
_uploadGate = uploadGate;
_opsEvents = opsEvents;
@@ -131,12 +128,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
}
// Ops event (routable through alert rules, Push included) — written before the response, never throws
if (_opsEvents != null)
await _opsEvents.RecordSessionReportSubmittedAsync(request.TenantId, userIdentifier, sessionId, metadata.ReportId);
// Telegram notification — best effort
_ = _telegramNotificationService.SendSessionReportAsync(
request.TenantId, userIdentifier, sessionId, metadata.ReportId, request.Comment ?? string.Empty);
await _opsEvents.RecordSessionReportSubmittedAsync(request.TenantId, userIdentifier, sessionId, metadata.ReportId);
// Persistent in-app notification for Global Admins — best effort
_ = _globalNotificationService.CreateNotificationAsync(
@@ -18,22 +18,19 @@ namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
private readonly ILogger<SubmitRuleSubmissionsFunction> _logger;
private readonly RuleSubmissionService _service;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly TelegramNotificationService _telegram;
private readonly GlobalNotificationService _globalNotifications;
private readonly OpsEventService? _opsEvents;
private readonly OpsEventService _opsEvents;
public SubmitRuleSubmissionsFunction(
ILogger<SubmitRuleSubmissionsFunction> logger,
RuleSubmissionService service,
IMaintenanceRepository maintenanceRepo,
TelegramNotificationService telegram,
GlobalNotificationService globalNotifications,
OpsEventService? opsEvents = null)
OpsEventService opsEvents)
{
_logger = logger;
_service = service;
_maintenanceRepo = maintenanceRepo;
_telegram = telegram;
_globalNotifications = globalNotifications;
_opsEvents = opsEvents;
}
@@ -112,9 +109,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules.Submissions
foreach (var s in created)
{
// Ops event (routable through alert rules, Push included) — written before the response, never throws
if (_opsEvents != null)
await _opsEvents.RecordRuleSubmissionReceivedAsync(request.TenantId, userIdentifier, s.SubmissionId, s.RuleKind, s.SourceRuleId);
_ = _telegram.SendRuleSubmissionAsync(s.SubmissionId, request.TenantId, userIdentifier, s.RuleKind, s.SourceRuleId, s.Title, s.Comment ?? string.Empty);
await _opsEvents.RecordRuleSubmissionReceivedAsync(request.TenantId, userIdentifier, s.SubmissionId, s.RuleKind, s.SourceRuleId);
_ = _globalNotifications.CreateNotificationAsync(
"rule_submission",
$"Rule submission {s.SubmissionId}",
@@ -33,6 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly IGraphAccessHealthRecorder _accessHealth;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly NotificationChannelDispatcher _channelDispatcher;
private readonly SessionDeletionGuard _deletionGuard;
@@ -52,6 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
IGraphAccessHealthRecorder accessHealth,
BootstrapSessionService bootstrapSessionService,
NotificationChannelDispatcher channelDispatcher,
SessionDeletionGuard deletionGuard,
@@ -69,6 +71,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_accessHealth = accessHealth;
_bootstrapSessionService = bootstrapSessionService;
_channelDispatcher = channelDispatcher;
_deletionGuard = deletionGuard;
@@ -115,7 +118,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
intuneDeviceBindingValidator: _intuneDeviceBindingValidator,
accessHealth: _accessHealth,
selfReportedCloudPc: registration.IsCloudPc
);
if (errorResponse2 != null)
@@ -141,12 +146,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
_logger.LogInformation($"Registering session {registration.SessionId} for tenant {registration.TenantId} (Device: {validation.CertificateThumbprint})");
// Server-populated: record which device-validation path accepted this request
// (AutopilotV1 / CorporateIdentifier / DeviceAssociation / Bootstrap / CloudPc /
// IntuneEnrollment) so the
// session row shows HOW the backend admitted the device. Overwrites any
// agent-sent value unconditionally — this is the server's verdict, not input.
registration.ValidatedBy = validation.ValidatedBy;
// What the backend established about the device — which validator admitted it and the
// Autopilot registration that validator read — travels apart from the agent's body, so
// a device can never report its own group tag or validator.
var serverFacts = new SessionServerFacts
{
ValidatedBy = validation.ValidatedBy,
AutopilotRegistration = validation.AutopilotRegistration,
};
// Device-supplied session anchor: clamp before ANY consumer sees it. StartedAt drives the
// SessionsIndex RowKey (list order), the retention filter ("StartedAt lt cutoff"), the
@@ -205,7 +212,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
bool stored;
try
{
stored = await _sessionRepo.StoreSessionAsync(registration, ownerDecision.OwnerToStamp);
stored = await _sessionRepo.StoreSessionAsync(registration, serverFacts, ownerDecision.OwnerToStamp);
}
catch (SessionTenantConflictException conflict)
{
@@ -148,9 +148,9 @@ public class SearchSessionsFunction
AgentVersionPrefix = query["agentVersionPrefix"],
ImeAgentVersionPrefix = query["imeAgentVersionPrefix"],
ConnectionType = query["connectionType"],
GroupTag = string.IsNullOrWhiteSpace(query["groupTag"]) ? null : query["groupTag"]!.Trim(),
// Free-text search (dashboard search box); trimmed, empty → null.
Q = string.IsNullOrWhiteSpace(query["q"]) ? null : query["q"]!.Trim(),
// Limit field is a no-op in the paged path — pagination drives count.
};
if (bool.TryParse(query["isPreProvisioned"], out var ipp)) filter.IsPreProvisioned = ipp;
@@ -225,6 +225,11 @@ public class SearchSessionsFunction
if (fields.Contains("avgApiLatencyMs")) dict["avgApiLatencyMs"] = s.AvgApiLatencyMs;
if (fields.Contains("apiRequestCount")) dict["apiRequestCount"] = s.ApiRequestCount;
if (fields.Contains("connectionType")) dict["connectionType"] = s.ConnectionType;
if (fields.Contains("autopilotGroupTag")) dict["autopilotGroupTag"] = s.AutopilotGroupTag;
if (fields.Contains("autopilotPurchaseOrderId")) dict["autopilotPurchaseOrderId"] = s.AutopilotPurchaseOrderId;
if (fields.Contains("autopilotDisplayName")) dict["autopilotDisplayName"] = s.AutopilotDisplayName;
if (fields.Contains("autopilotDeviceId")) dict["autopilotDeviceId"] = s.AutopilotDeviceId;
if (fields.Contains("autopilotUserAssigned")) dict["autopilotUserAssigned"] = s.AutopilotUserAssigned;
if (dict.Count == 0)
{
@@ -0,0 +1,71 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Helpers
{
/// <summary>
/// The Fleet Health view filter by Autopilot group tag: sessions whose group tag is listed, or that
/// carry none with <see cref="ExcludeUntagged"/>, leave every figure of the view. Nothing is stored —
/// the filter lives in the request, and alerts never see it. Tags compare trimmed and without case.
/// </summary>
public sealed class FleetGroupTagFilter
{
/// <summary>Most group tags one request may exclude.</summary>
internal const int MaxExcludedTags = 20;
public static readonly FleetGroupTagFilter None = new(Array.Empty<string>(), excludeUntagged: false);
private readonly HashSet<string> _excluded;
private FleetGroupTagFilter(IReadOnlyList<string> excludedTags, bool excludeUntagged)
{
ExcludedTags = excludedTags;
ExcludeUntagged = excludeUntagged;
_excluded = new HashSet<string>(excludedTags, StringComparer.OrdinalIgnoreCase);
}
/// <summary>The excluded group tags as requested: trimmed, de-duplicated without case.</summary>
public IReadOnlyList<string> ExcludedTags { get; }
/// <summary>Whether the sessions without a group tag leave the view.</summary>
public bool ExcludeUntagged { get; }
public bool IsActive => ExcludedTags.Count > 0 || ExcludeUntagged;
/// <summary>The bucket key of a session: its trimmed group tag, empty when it has none.</summary>
internal static string KeyOf(SessionSummary session) => session.AutopilotGroupTag?.Trim() ?? string.Empty;
public bool Excludes(SessionSummary session) => ExcludesKey(KeyOf(session));
internal bool ExcludesKey(string key) => key.Length == 0 ? ExcludeUntagged : _excluded.Contains(key);
/// <summary>
/// Parses <c>excludeGroupTags</c> (comma-separated, at most <see cref="MaxExcludedTags"/>) and
/// <c>excludeUntagged</c> ("true"). False with an error message for too many or overlong tags.
/// </summary>
public static bool TryParse(string? excludeGroupTags, string? excludeUntagged, out FleetGroupTagFilter filter, out string? error)
{
filter = None;
error = null;
var tags = (excludeGroupTags ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (tags.Count > MaxExcludedTags)
{
error = $"excludeGroupTags takes at most {MaxExcludedTags} comma-separated group tags";
return false;
}
if (tags.Any(t => t.Length > AutopilotDeviceValidator.MaxRegistrationFieldLength))
{
error = $"a group tag in excludeGroupTags is longer than {AutopilotDeviceValidator.MaxRegistrationFieldLength} characters";
return false;
}
var untagged = string.Equals(excludeUntagged?.Trim(), "true", StringComparison.OrdinalIgnoreCase);
if (tags.Count > 0 || untagged)
filter = new FleetGroupTagFilter(tags, untagged);
return true;
}
}
}
@@ -192,10 +192,19 @@ public static class MetricsMath
/// it. Duration stats (average, median, P90) count every non-in-progress session that carries a
/// positive duration (failures included); the cards lead with the median because enrollment
/// durations are heavily right-skewed and a handful of multi-hour outliers make the mean
/// meaningless as a "typical enrollment" answer.
/// meaningless as a "typical enrollment" answer. The group-tag view filter takes its sessions out
/// of every figure except the group tag breakdown, which keeps them flagged so they can be shown
/// again; <see cref="FleetHealthMetrics.ExcludedSessions"/> says how many left.
/// </summary>
public static FleetHealthMetrics BuildFleetHealthPayload(IReadOnlyList<SessionSummary> sessions, int days)
public static FleetHealthMetrics BuildFleetHealthPayload(
IReadOnlyList<SessionSummary> allSessions, int days, FleetGroupTagFilter? groupTagFilter = null)
{
var filter = groupTagFilter ?? FleetGroupTagFilter.None;
var groupTagHealth = BuildFleetGroupTagHealth(allSessions, filter);
IReadOnlyList<SessionSummary> sessions = filter.IsActive
? allSessions.Where(s => !filter.Excludes(s)).ToList()
: allSessions;
int succeeded = 0, failed = 0, inProgress = 0, incomplete = 0;
var completedDurationSeconds = new List<double>();
@@ -241,10 +250,60 @@ public static class MetricsMath
ModelHealth = BuildFleetModelHealth(sessions),
SlowestModels = BuildFleetSlowestModels(sessions),
TopFailingModels = BuildFleetTopFailingModels(sessions),
GroupTagHealth = groupTagHealth,
ExcludedSessions = allSessions.Count - sessions.Count,
ExcludedGroupTags = filter.ExcludedTags.ToList(),
ExcludeUntagged = filter.ExcludeUntagged,
ComputedAt = DateTime.UtcNow,
};
}
/// <summary>Upper bound of the group tag breakdown; the global view spans every tenant's tags.</summary>
internal const int MaxGroupTagBuckets = 50;
/// <summary>
/// Sessions per group tag (trimmed, compared without case, shown in the most frequent spelling)
/// over the unfiltered window, so a bucket the view filter excluded stays visible and can be
/// shown again. Empty when no session carries a tag — the breakdown would be one "No group tag" row.
/// </summary>
private static List<FleetGroupTagHealth> BuildFleetGroupTagHealth(IReadOnlyList<SessionSummary> sessions, FleetGroupTagFilter filter)
{
if (!sessions.Any(s => FleetGroupTagFilter.KeyOf(s).Length > 0))
return new List<FleetGroupTagHealth>();
var buckets = new Dictionary<string, (FleetGroupTagHealth Row, Dictionary<string, int> Spellings)>(StringComparer.OrdinalIgnoreCase);
foreach (var s in sessions)
{
var key = FleetGroupTagFilter.KeyOf(s);
if (!buckets.TryGetValue(key, out var bucket))
{
bucket = (new FleetGroupTagHealth { Excluded = filter.ExcludesKey(key) }, new Dictionary<string, int>(StringComparer.Ordinal));
buckets[key] = bucket;
}
bucket.Row.Total++;
if (s.Status == SessionStatus.Succeeded) bucket.Row.Succeeded++;
else if (s.Status == SessionStatus.Failed) bucket.Row.Failed++;
bucket.Spellings[key] = bucket.Spellings.TryGetValue(key, out var seen) ? seen + 1 : 1;
}
foreach (var (_, bucket) in buckets)
{
var spelling = bucket.Spellings
.OrderByDescending(kv => kv.Value)
.ThenBy(kv => kv.Key, StringComparer.Ordinal)
.First().Key;
bucket.Row.GroupTag = spelling;
bucket.Row.Label = spelling.Length == 0 ? "No group tag" : spelling;
}
return buckets.Values
.Select(b => b.Row)
.OrderByDescending(r => r.Total)
.ThenBy(r => r.Label, StringComparer.OrdinalIgnoreCase)
.Take(MaxGroupTagBuckets)
.ToList();
}
/// <summary>
/// One point per day in the window, oldest-first, so every day renders even with zero
/// enrollments. Sessions are bucketed by their StartedAt calendar day, treated as UTC to
@@ -29,7 +29,7 @@ public sealed class ResiliencePolicies
public IAsyncPolicy<HttpResponseMessage> ExternalDataApi { get; }
/// <summary>
/// Policy for notification clients (webhook, Telegram):
/// Policy for notification clients (webhook):
/// 2 retries with 2s fixed delay. No circuit breaker — a single failing endpoint
/// should not block all notifications across tenants.
/// </summary>
@@ -32,6 +32,7 @@ public sealed class SessionSearchQuery
new("serial", s => s.SerialNumber),
new("model", s => s.Model),
new("manufacturer", s => s.Manufacturer),
new("grouptag", s => s.AutopilotGroupTag),
new("status", s => s.Status.ToString()),
new("session", s => s.SessionId),
new("country", s => s.GeoCountry),
@@ -52,6 +52,15 @@ namespace AutopilotMonitor.Functions.Helpers
("homedAppClientId", c => c.HomedAppClientId),
("lastAuthClientId", c => c.LastAuthClientId),
("lastAuthClientIdSince", c => c.LastAuthClientIdSince),
// Graph access state: two timestamps and a Microsoft error code, not secrets.
("graphAccessRejectedSince", c => c.GraphAccessRejectedSince),
("graphAccessRejectedCode", c => c.GraphAccessRejectedCode),
("graphAccessRejectionConfirmedAt", c => c.GraphAccessRejectionConfirmedAt),
// Missing add-on permission behind a switched-on validation: four timestamps.
("intuneDeviceBindingPermissionMissingSince", c => c.IntuneDeviceBindingPermissionMissingSince),
("intuneDeviceBindingPermissionMissingConfirmedAt", c => c.IntuneDeviceBindingPermissionMissingConfirmedAt),
("cloudPcPermissionMissingSince", c => c.CloudPcPermissionMissingSince),
("cloudPcPermissionMissingConfirmedAt", c => c.CloudPcPermissionMissingConfirmedAt),
};
/// <summary>
@@ -187,8 +187,8 @@ namespace AutopilotMonitor.Functions.Helpers
return "Invalid webhook provider type.";
// ...and only a webhook provider: its destination passed the webhook URL gate above, never
// the channel gates of Telegram, Push or E-mail. Only a CHANGED value is checked, so a
// stored one stays saveable.
// the channel gates of Push or E-mail. Only a CHANGED value is checked, so a stored one
// stays saveable.
if (candidate.WebhookProviderType != existing.WebhookProviderType
&& candidate.WebhookProviderType != 0
&& !Shared.Models.Notifications.NotificationChannel.IsWebhookUrlProvider(candidate.WebhookProviderType))
@@ -202,10 +202,10 @@ namespace AutopilotMonitor.Functions.Helpers
if (channelsError != null)
return $"Invalid notification channels: {channelsError}";
var telegramGateError = ValidateTelegramChannelGate(
var platformProviderError = ValidatePlatformProviderGate(
candidate.NotificationChannelsJson, existing.NotificationChannelsJson, isGlobalAdmin);
if (telegramGateError != null)
return telegramGateError;
if (platformProviderError != null)
return platformProviderError;
var pushCountError = ValidateSinglePushChannel(candidate.NotificationChannelsJson, existing.NotificationChannelsJson);
if (pushCountError != null)
@@ -365,15 +365,11 @@ namespace AutopilotMonitor.Functions.Helpers
if (!Shared.Models.Notifications.NotificationChannel.IsSupportedProviderType(channel.ProviderType))
return $"channel \"{label}\" has an invalid provider type.";
// Telegram's destination is a chat ID, not a URL — the SSRF gate does not apply
// (there is no caller-supplied endpoint; the bot URL is platform-owned) and would
// reject every valid value. Format-check the chat ID instead. A Push channel has
// no destination at all: a URL on it is refused so it can never fall through to
// the webhook path should a future branch forget the provider.
// A Push channel has no destination at all: a URL on it is refused so it can never
// fall through to the webhook path should a future branch forget the provider. An
// e-mail channel names recipients, not a URL, so the SSRF gate does not apply.
var destinationError =
channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Telegram
? ValidateTelegramChatId(channel.Url)
: channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push
channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push
? ValidatePushDestination(channel.Url)
: channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Email
? ValidateEmailDestination(channel.Url)
@@ -393,45 +389,6 @@ namespace AutopilotMonitor.Functions.Helpers
return null;
}
/// <summary>
/// Validates a Telegram destination chat ID. Returns an error message, or null when valid.
/// Accepts a numeric id (negative for groups/channels, e.g. -1003785642894) or an @username.
/// Empty is rejected: unlike a webhook URL, a Telegram channel with no chat ID cannot be
/// saved as "configured but idle" — it would silently drop every message.
/// </summary>
internal static string? ValidateTelegramChatId(string? chatId)
{
if (string.IsNullOrWhiteSpace(chatId))
return "a Telegram chat ID is required.";
var trimmed = chatId!.Trim();
if (trimmed.StartsWith("@", StringComparison.Ordinal))
{
// Telegram public usernames: 5-32 chars, letters/digits/underscore, letter first.
var name = trimmed.Substring(1);
if (name.Length < 5 || name.Length > 32 || !char.IsLetter(name[0]))
return "the @username must be 5-32 characters and start with a letter.";
foreach (var ch in name)
{
if (!char.IsLetterOrDigit(ch) && ch != '_')
return "the @username may only contain letters, digits and underscores.";
}
return null;
}
var digits = trimmed.StartsWith("-", StringComparison.Ordinal) ? trimmed.Substring(1) : trimmed;
if (digits.Length == 0 || digits.Length > 20)
return "must be a numeric chat ID (up to 20 digits) or an @username.";
foreach (var ch in digits)
{
if (!char.IsDigit(ch))
return "must be a numeric chat ID (up to 20 digits) or an @username.";
}
return null;
}
/// <summary>
/// A Push channel carries no destination (its recipients are the scope's paired devices,
/// resolved at send time); a URL on it is refused rather than ignored.
@@ -467,9 +424,8 @@ namespace AutopilotMonitor.Functions.Helpers
/// <summary>
/// Global-Admin gate for the providers that send through PLATFORM-owned infrastructure
/// (<see cref="Shared.Models.Notifications.NotificationChannel.IsGlobalAdminOnlyProvider"/>):
/// Telegram through the platform bot (the token lives in PreviewConfig, not in tenant
/// config), E-mail through the platform's sender identity and provider key. A tenant admin
/// must not be able to create or retarget one — hiding the provider in the UI is not a control.
/// E-mail through the platform's sender identity and provider key. A tenant admin must not be
/// able to create or retarget one — hiding the provider in the UI is not a control.
/// <para>
/// Compares against the stored config so a non-GA caller can still save unrelated changes
/// while a GA-created gated channel is present: only ADDING one, changing an existing
@@ -477,7 +433,7 @@ namespace AutopilotMonitor.Functions.Helpers
/// deletion stay with the tenant admin. Same shape as the retention-cap check.
/// </para>
/// </summary>
internal static string? ValidateTelegramChannelGate(string? candidateJson, string? existingJson, bool isGlobalAdmin)
internal static string? ValidatePlatformProviderGate(string? candidateJson, string? existingJson, bool isGlobalAdmin)
{
if (isGlobalAdmin)
return null;
@@ -501,12 +457,7 @@ namespace AutopilotMonitor.Functions.Helpers
|| !string.Equals(stored.Url ?? string.Empty, channel.Url ?? string.Empty, StringComparison.Ordinal)
|| stored.Enabled != channel.Enabled)
{
var provider = channel.ProviderType switch
{
(int)Shared.Models.Notifications.WebhookProviderType.Email => "E-mail",
_ => "Telegram",
};
return $"{provider} channels can only be configured by a Global Administrator.";
return "E-mail channels can only be configured by a Global Administrator.";
}
}
@@ -55,6 +55,7 @@ namespace AutopilotMonitor.Functions.Pagination
new KeyValuePair<string, string?>("rebootCountMin", filter.RebootCountMin?.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("rebootCountMax", filter.RebootCountMax?.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("connectionType", filter.ConnectionType),
new KeyValuePair<string, string?>("groupTag", filter.GroupTag),
new KeyValuePair<string, string?>("q", filter.Q),
};
if (filter.DeviceProperties != null)
@@ -458,6 +458,10 @@ builder.Services.AddSingleton<RegisterSessionFunction>();
builder.Services.AddSingleton<GetAgentConfigFunction>();
builder.Services.AddSingleton<ReportAgentErrorFunction>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.EntraAppRegistry>();
builder.Services.AddSingleton<
AutopilotMonitor.Functions.Security.IGraphAccessHealthRecorder,
AutopilotMonitor.Functions.Security.GraphAccessHealthRecorder>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphAccessRecheckMaintenance>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
@@ -473,10 +477,8 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolutio
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.GraphResolution.IScriptDisplayNameResolver,
AutopilotMonitor.Functions.Services.GraphResolution.ScriptDisplayNameResolver>();
builder.Services.AddWebhookNotificationHttpClient();
builder.Services.AddHttpClient<TelegramNotificationService>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
// Channel-level send API — routes each NotificationChannel to its transport (webhook renderer
// vs. the platform Telegram bot). Transient: both transports are typed HttpClients.
// Channel-level send API — routes each NotificationChannel to its transport. Transient: the
// webhook transport is a typed HttpClient.
builder.Services.AddTransient<AutopilotMonitor.Functions.Services.Notifications.NotificationChannelDispatcher>();
// Every channel send lands in the channel's health row (status dot in the portal, failing bell).
builder.Services.AddSingleton<
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// A device validation that needs an optional Graph permission (an add-on grant) instead of the
/// default consent: its switch, its feature in <see cref="GraphFeatureCatalog"/>, and the two
/// tenant fields of its missing-permission episode. <see cref="GraphAccessHealthRecorder"/> runs the
/// episodes in the same two stages as the token state.
/// </summary>
public sealed class AddOnValidation
{
public static readonly AddOnValidation CloudPc = new(
GraphFeatureCatalog.FeatureW365CloudPcValidation,
c => c.ValidateCloudPcDevice,
c => c.CloudPcPermissionMissingSince, (c, v) => c.CloudPcPermissionMissingSince = v,
c => c.CloudPcPermissionMissingConfirmedAt, (c, v) => c.CloudPcPermissionMissingConfirmedAt = v);
public static readonly AddOnValidation IntuneDeviceBinding = new(
GraphFeatureCatalog.FeatureIntuneDeviceBinding,
c => c.ValidateIntuneDeviceBinding,
c => c.IntuneDeviceBindingPermissionMissingSince, (c, v) => c.IntuneDeviceBindingPermissionMissingSince = v,
c => c.IntuneDeviceBindingPermissionMissingConfirmedAt, (c, v) => c.IntuneDeviceBindingPermissionMissingConfirmedAt = v);
/// <summary>Both, in chain order.</summary>
public static readonly IReadOnlyList<AddOnValidation> All = new[] { CloudPc, IntuneDeviceBinding };
private readonly Func<TenantConfiguration, bool> _isEnabled;
private readonly Func<TenantConfiguration, DateTime?> _since;
private readonly Action<TenantConfiguration, DateTime?> _setSince;
private readonly Func<TenantConfiguration, DateTime?> _confirmedAt;
private readonly Action<TenantConfiguration, DateTime?> _setConfirmedAt;
private AddOnValidation(
string feature,
Func<TenantConfiguration, bool> isEnabled,
Func<TenantConfiguration, DateTime?> since, Action<TenantConfiguration, DateTime?> setSince,
Func<TenantConfiguration, DateTime?> confirmedAt, Action<TenantConfiguration, DateTime?> setConfirmedAt)
{
Feature = feature;
Permission = string.Join(", ", GraphFeatureCatalog.RequiredPermissions(feature));
_isEnabled = isEnabled;
_since = since;
_setSince = setSince;
_confirmedAt = confirmedAt;
_setConfirmedAt = setConfirmedAt;
}
/// <summary>Feature id of the grant script and of the permission-status endpoint.</summary>
public string Feature { get; }
/// <summary>The Graph application permission the add-on grant adds.</summary>
public string Permission { get; }
public bool IsEnabled(TenantConfiguration config) => _isEnabled(config);
public bool IsGranted(IEnumerable<string> roles) => GraphFeatureCatalog.IsFeatureGranted(Feature, roles);
public DateTime? MissingSince(TenantConfiguration config) => _since(config);
public DateTime? MissingConfirmedAt(TenantConfiguration config) => _confirmedAt(config);
internal void StampSince(TenantConfiguration config, DateTime at) => _setSince(config, at);
internal void StampConfirmed(TenantConfiguration config, DateTime at) => _setConfirmedAt(config, at);
internal void ClearEpisode(TenantConfiguration config)
{
_setSince(config, null);
_setConfirmedAt(config, null);
}
/// <summary>Switched on and its missing permission confirmed: the portal shows the notice.</summary>
public bool IsConfirmedMissing(TenantConfiguration config) => IsEnabled(config) && MissingConfirmedAt(config) != null;
/// <summary>
/// True when every switched-on validation is an add-on whose missing permission is confirmed:
/// then no device can be validated at all.
/// </summary>
public static bool BlocksAllValidation(TenantConfiguration config) =>
config.HasAnyDeviceValidation()
&& !config.ValidateAutopilotDevice
&& !config.ValidateCorporateIdentifier
&& !config.ValidateDeviceAssociation
&& !All.Any(a => a.IsEnabled(config) && !a.IsConfirmedMissing(config));
}
}
@@ -1,4 +1,5 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using System;
using System.Linq;
using System.Net.Http.Headers;
@@ -114,7 +115,7 @@ namespace AutopilotMonitor.Functions.Security
var ct = attemptCts.Token;
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new AutopilotDeviceValidationResult
@@ -219,7 +220,7 @@ namespace AutopilotMonitor.Functions.Security
{
IsValid = true,
SerialNumber = normalizedSerial,
AutopilotDeviceId = exactDevice["id"]?.ToString()
Registration = ReadRegistration(exactDevice)
};
_logger.LogInformation(
@@ -227,7 +228,7 @@ namespace AutopilotMonitor.Functions.Security
tenantId,
sessionId ?? "<none>",
normalizedSerial,
result.AutopilotDeviceId ?? "<none>");
result.Registration?.DeviceId ?? "<none>");
return CacheAndReturn(cacheKey, result, isPositive: true);
}
@@ -271,6 +272,31 @@ namespace AutopilotMonitor.Functions.Security
return $"autopilot-device-validation:{tenantId}:{serialNumber}";
}
/// <summary>Longest registration text a session keeps; anything beyond is cut.</summary>
internal const int MaxRegistrationFieldLength = 256;
/// <summary>
/// The registration fields a session keeps, read from the object this lookup already holds. The
/// assigned user's UPN is only tested for presence and never leaves this method; productKey is
/// never read.
/// </summary>
internal static AutopilotRegistration ReadRegistration(JToken device) => new AutopilotRegistration
{
DeviceId = RegistrationText(device["id"]),
GroupTag = RegistrationText(device["groupTag"]),
PurchaseOrderId = RegistrationText(device["purchaseOrderIdentifier"]),
DisplayName = RegistrationText(device["displayName"]),
UserAssigned = RegistrationText(device["userPrincipalName"]) != null,
};
private static string? RegistrationText(JToken? token)
{
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
if (string.IsNullOrEmpty(value))
return null;
return value!.Length > MaxRegistrationFieldLength ? value.Substring(0, MaxRegistrationFieldLength) : value;
}
private AutopilotDeviceValidationResult CacheAndReturn(
string cacheKey,
AutopilotDeviceValidationResult result,
@@ -303,7 +329,10 @@ namespace AutopilotMonitor.Functions.Security
public int? RetryAfterSeconds { get; set; }
public string? SerialNumber { get; set; }
public string? AutopilotDeviceId { get; set; }
/// <summary>The device's Autopilot registration; set exactly when <see cref="IsValid"/>.</summary>
public AutopilotRegistration? Registration { get; set; }
public string? ErrorMessage { get; set; }
}
}
@@ -51,7 +51,7 @@ namespace AutopilotMonitor.Functions.Security
_graphTokenService = graphTokenService;
}
public async Task<CloudPcValidationResult> ValidateCloudPcAsync(
public virtual async Task<CloudPcValidationResult> ValidateCloudPcAsync(
string tenantId,
string? intuneDeviceId,
string? sessionId = null,
@@ -119,7 +119,7 @@ namespace AutopilotMonitor.Functions.Security
var ct = attemptCts.Token;
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new CloudPcValidationResult
@@ -174,6 +174,7 @@ namespace AutopilotMonitor.Functions.Security
return CacheAndReturn(cacheKey, new CloudPcValidationResult
{
IsValid = false,
PermissionMissing = true,
IntuneDeviceId = normalizedId,
ErrorMessage = "Cloud PC lookup not permitted — grant the optional 'W365CloudPcValidation' Graph feature (CloudPC.Read.All)"
}, isPositive: false);
@@ -303,6 +304,12 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public bool IsTransient { get; set; }
/// <summary>
/// True when Graph refused the lookup after a fresh token because CloudPC.Read.All is not
/// granted: a definitive negative about the tenant's configuration, not about the device.
/// </summary>
public bool PermissionMissing { get; set; }
/// <summary>Intune device id extracted from the MDM client certificate's Subject CN.</summary>
public string? IntuneDeviceId { get; set; }
@@ -121,7 +121,7 @@ namespace AutopilotMonitor.Functions.Security
var ct = attemptCts.Token;
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new CorporateIdentifierValidationResult
@@ -111,7 +111,7 @@ namespace AutopilotMonitor.Functions.Security
var ct = attemptCts.Token;
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new DeviceAssociationResult
@@ -0,0 +1,78 @@
using System;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Microsoft Entra's answer when it refuses an app token: the HTTP status plus the AADSTS code
/// from the response body. Only a client error is a rejection; a 5xx, a throttle or a network
/// failure says nothing about the tenant and never becomes one.
/// </summary>
public sealed record EntraTokenRejection(int StatusCode, string? ErrorCode, string? Description)
{
/// <summary>The app has no service principal in the tenant: its enterprise application was removed.</summary>
public const string ServicePrincipalMissingCode = "AADSTS7000229";
/// <summary>
/// Not a refusal in Entra's words but the same lockout: a token without any application role,
/// which Graph refuses on every call. Typically a removed enterprise application that a user's
/// sign-in consent re-created. The repair is the same admin consent.
/// </summary>
public static readonly EntraTokenRejection NoApplicationRole =
new(200, "NoApplicationRole", "The app token carries no application permission");
private const string CodePrefix = "AADSTS";
private const int MaxDescriptionLength = 300;
// Wrong, expired or missing client secret: our configuration, never the customer's tenant.
private static readonly string[] OurCredentialCodes = { "AADSTS7000215", "AADSTS7000222", "AADSTS7000218" };
public bool IsServicePrincipalMissing =>
string.Equals(ErrorCode, ServicePrincipalMissingCode, StringComparison.OrdinalIgnoreCase);
public bool IsOurCredential =>
ErrorCode != null && OurCredentialCodes.Contains(ErrorCode, StringComparer.OrdinalIgnoreCase);
/// <summary>Reads the code and the description from a token error body; a body that is not JSON keeps only the status.</summary>
public static EntraTokenRejection Parse(int statusCode, string? body)
{
var json = TryParseObject(body);
var description = (json?["error_description"] as JValue)?.Value as string;
return new EntraTokenRejection(statusCode, ReadCode(json, description), Shorten(description));
}
private static JObject? TryParseObject(string? body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
try
{
return JsonConvert.DeserializeObject<JToken>(body) as JObject;
}
catch (JsonException)
{
return null;
}
}
private static string? ReadCode(JObject? json, string? description)
{
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
if (first != null) return CodePrefix + first.Value<long>();
// Fallback for a body without error_codes: the description starts with the code.
var start = description?.IndexOf(CodePrefix, StringComparison.Ordinal) ?? -1;
if (start < 0) return null;
var digits = new string(description!.Skip(start + CodePrefix.Length).TakeWhile(char.IsDigit).ToArray());
return digits.Length == 0 ? null : CodePrefix + digits;
}
private static string? Shorten(string? description)
{
if (string.IsNullOrWhiteSpace(description)) return null;
var line = description.Split('\n')[0].Trim();
return line.Length <= MaxDescriptionLength ? line : line.Substring(0, MaxDescriptionLength);
}
}
}
@@ -0,0 +1,363 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>Folds what Entra and Graph answer for a tenant's app token into the tenant's Graph access state.</summary>
public interface IGraphAccessHealthRecorder
{
/// <summary>Entra refused the token on an agent request. Never throws.</summary>
Task RecordRejectedAsync(string tenantId, EntraTokenRejection rejection);
/// <summary>
/// A token with an application role was freshly minted for the app acting on this tenant;
/// <paramref name="roles"/> are its application roles. Never throws.
/// </summary>
Task RecordMintedAsync(string tenantId, IReadOnlyCollection<string> roles);
/// <summary>
/// An agent request was not admitted while these switched-on validations lacked their optional
/// Graph permission. Never throws.
/// </summary>
Task RecordAddOnPermissionMissingAsync(string tenantId, IReadOnlyList<AddOnValidation> addOns);
/// <summary>Ends the missing-permission episodes of validations that are switched off. Never throws.</summary>
Task ReconcileSwitchedOffAddOnsAsync(string tenantId);
}
/// <summary>
/// Two stages, so one failed request never reaches a customer. The first rejection only stamps
/// the suspicion. A rejection at least <see cref="ConfirmAfter"/> later confirms it: only then
/// does the portal show its notice and the operator get an alert. A freshly minted token with an
/// application role ends the episode. A token from the cache proves nothing and never reaches
/// this class: it stays valid for up to 55 minutes after the enterprise application is gone.
/// Neither does a token without a role, or the notice would vanish the moment an admin signs in
/// to read it: that sign-in re-creates the application without any permission.
/// <para>
/// The add-on validations (<see cref="AddOnValidation"/>) run the same two stages per validation:
/// a device that was not admitted because the switched-on validation lacks its optional
/// permission stamps the suspicion; a further one, or a fresh token still without the permission,
/// at least <see cref="ConfirmAfter"/> later confirms it. A fresh token that carries the
/// permission, or the switch turned off, ends the episode.
/// </para>
/// <para>
/// Every decision runs on the fresh row, so across instances only one write wins and the ops
/// event fires once. Codes that blame our own credentials never touch the tenant: an expired
/// secret would otherwise tell every customer to repair something on their side.
/// </para>
/// </summary>
public sealed class GraphAccessHealthRecorder : IGraphAccessHealthRecorder
{
public static readonly TimeSpan ConfirmAfter = TimeSpan.FromMinutes(15);
// Resolutions of an add-on episode, as AddOnPermissionResolved reports them.
internal const string ResolvedByGrant = "granted";
internal const string ResolvedBySwitchOff = "switched_off";
internal const string ResolvedByAppHomingFlip = "app_homing_flip";
private const string BackupSource = "graph-access";
private static readonly TimeSpan CredentialAlertInterval = TimeSpan.FromHours(1);
private readonly TenantConfigurationService _tenantConfig;
private readonly Lazy<OpsEventService> _opsEvents;
private readonly ILogger<GraphAccessHealthRecorder> _logger;
private readonly TimeProvider _time;
private long _lastCredentialAlertTicks;
public GraphAccessHealthRecorder(
TenantConfigurationService tenantConfig,
Lazy<OpsEventService> opsEvents,
ILogger<GraphAccessHealthRecorder> logger,
TimeProvider? time = null)
{
_tenantConfig = tenantConfig;
_opsEvents = opsEvents;
_logger = logger;
_time = time ?? TimeProvider.System;
}
private enum Stage { Unchanged, Suspect, Confirmed }
private enum AddOnChange { None, Confirm, ResolveGranted, ResolveSwitchedOff }
private sealed record AddOnOutcome(AddOnValidation AddOn, AddOnChange Change, DateTime Since, bool WasConfirmed);
/// <summary>
/// One sighting of an episode: the first stamps the suspicion, one at least
/// <see cref="ConfirmAfter"/> later confirms it, anything else changes nothing.
/// </summary>
private static Stage Advance(DateTime? since, DateTime? confirmedAt, DateTime now)
{
if (confirmedAt != null)
return Stage.Unchanged;
if (since is not { } s)
return Stage.Suspect;
return now - s < ConfirmAfter ? Stage.Unchanged : Stage.Confirmed;
}
/// <summary>
/// What a fresh token (<paramref name="roles"/>), or a switch check without one (null),
/// means for an open add-on episode.
/// </summary>
private static AddOnChange PlanAddOnChange(AddOnValidation addOn, TenantConfiguration row, IReadOnlyCollection<string>? roles, DateTime now)
{
if (addOn.MissingSince(row) is not { } since)
return AddOnChange.None;
if (!addOn.IsEnabled(row))
return AddOnChange.ResolveSwitchedOff;
if (roles == null)
return AddOnChange.None;
if (addOn.IsGranted(roles))
return AddOnChange.ResolveGranted;
return Advance(since, addOn.MissingConfirmedAt(row), now) == Stage.Confirmed ? AddOnChange.Confirm : AddOnChange.None;
}
private static void ApplyAddOnChanges(TenantConfiguration row, IReadOnlyCollection<string>? roles, DateTime now, List<AddOnOutcome> outcomes)
{
foreach (var addOn in AddOnValidation.All)
{
var change = PlanAddOnChange(addOn, row, roles, now);
if (change == AddOnChange.None)
continue;
outcomes.Add(new AddOnOutcome(addOn, change, addOn.MissingSince(row)!.Value, addOn.MissingConfirmedAt(row) != null));
if (change == AddOnChange.Confirm)
addOn.StampConfirmed(row, now);
else
addOn.ClearEpisode(row);
}
}
public async Task RecordRejectedAsync(string tenantId, EntraTokenRejection rejection)
{
try
{
var now = _time.GetUtcNow().UtcDateTime;
if (rejection.IsOurCredential)
{
await AlertOurCredentialAsync(tenantId, rejection, now).ConfigureAwait(false);
return;
}
// Cheap pre-filter on the cached view; the write decides again on the fresh row.
var cached = await _tenantConfig.GetConfigurationIfExistsAsync(tenantId).ConfigureAwait(false);
if (cached == null || !cached.HasAnyDeviceValidation()
|| Advance(cached.GraphAccessRejectedSince, cached.GraphAccessRejectionConfirmedAt, now) == Stage.Unchanged)
return;
var stage = Stage.Unchanged;
var update = await _tenantConfig.UpdateAsync(tenantId, row =>
{
stage = Advance(row.GraphAccessRejectedSince, row.GraphAccessRejectionConfirmedAt, now);
if (stage == Stage.Unchanged)
return false;
if (stage == Stage.Suspect)
row.GraphAccessRejectedSince = now;
else
row.GraphAccessRejectionConfirmedAt = now;
row.GraphAccessRejectedCode = rejection.ErrorCode;
return true;
}, BackupSource, "Entra rejected the app token").ConfigureAwait(false);
if (update.Status != TenantConfigUpdateStatus.Updated)
return;
if (stage == Stage.Suspect)
{
_logger.LogWarning(
"Graph access for tenant {TenantId} is suspect: Entra rejected the app token ({ErrorCode}, status {StatusCode})",
tenantId, rejection.ErrorCode, rejection.StatusCode);
return;
}
_logger.LogWarning(
"Graph access for tenant {TenantId} is confirmed lost: Entra still rejects the app token ({ErrorCode}, status {StatusCode})",
tenantId, rejection.ErrorCode, rejection.StatusCode);
await _opsEvents.Value.RecordGraphAccessRejectedAsync(tenantId, rejection.ErrorCode, rejection.StatusCode).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Recording the Graph token rejection of tenant {TenantId} failed", tenantId);
}
}
public async Task RecordMintedAsync(string tenantId, IReadOnlyCollection<string> roles)
{
try
{
var now = _time.GetUtcNow().UtcDateTime;
var cached = await _tenantConfig.GetConfigurationIfExistsAsync(tenantId).ConfigureAwait(false);
if (cached == null
|| (cached.GraphAccessRejectedSince == null
&& AddOnValidation.All.All(a => PlanAddOnChange(a, cached, roles, now) == AddOnChange.None)))
return;
var tokenCleared = false;
var tokenWasConfirmed = false;
string? code = null;
var outcomes = new List<AddOnOutcome>();
var blocksAll = false;
var update = await _tenantConfig.UpdateAsync(tenantId, row =>
{
tokenCleared = false;
outcomes.Clear();
if (row.GraphAccessRejectedSince != null)
{
tokenCleared = true;
tokenWasConfirmed = row.GraphAccessRejectionConfirmedAt != null;
code = row.GraphAccessRejectedCode;
row.GraphAccessRejectedSince = null;
row.GraphAccessRejectedCode = null;
row.GraphAccessRejectionConfirmedAt = null;
}
ApplyAddOnChanges(row, roles, now, outcomes);
blocksAll = AddOnValidation.BlocksAllValidation(row);
return tokenCleared || outcomes.Count > 0;
}, BackupSource, "A fresh app token arrived").ConfigureAwait(false);
if (update.Status != TenantConfigUpdateStatus.Updated)
return;
if (tokenCleared && tokenWasConfirmed)
{
_logger.LogWarning("Graph access for tenant {TenantId} is back: Entra issued an app token again (was {ErrorCode})", tenantId, code);
await _opsEvents.Value.RecordGraphAccessRecoveredAsync(tenantId, code).ConfigureAwait(false);
}
await ReportAddOnOutcomesAsync(tenantId, outcomes, blocksAll).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Recording the fresh app token of tenant {TenantId} failed", tenantId);
}
}
public async Task RecordAddOnPermissionMissingAsync(string tenantId, IReadOnlyList<AddOnValidation> addOns)
{
if (addOns.Count == 0)
return;
try
{
var now = _time.GetUtcNow().UtcDateTime;
var cached = await _tenantConfig.GetConfigurationIfExistsAsync(tenantId).ConfigureAwait(false);
if (cached == null
|| !addOns.Any(a => a.IsEnabled(cached) && Advance(a.MissingSince(cached), a.MissingConfirmedAt(cached), now) != Stage.Unchanged))
return;
var stages = new List<(AddOnValidation AddOn, Stage Stage, DateTime Since)>();
var blocksAll = false;
var update = await _tenantConfig.UpdateAsync(tenantId, row =>
{
stages.Clear();
foreach (var addOn in addOns)
{
if (!addOn.IsEnabled(row))
continue;
var stage = Advance(addOn.MissingSince(row), addOn.MissingConfirmedAt(row), now);
if (stage == Stage.Suspect)
addOn.StampSince(row, now);
else if (stage == Stage.Confirmed)
addOn.StampConfirmed(row, now);
else
continue;
stages.Add((addOn, stage, addOn.MissingSince(row)!.Value));
}
blocksAll = AddOnValidation.BlocksAllValidation(row);
return stages.Count > 0;
}, BackupSource, "An optional Graph permission is missing").ConfigureAwait(false);
if (update.Status != TenantConfigUpdateStatus.Updated)
return;
foreach (var (addOn, stage, since) in stages)
{
if (stage == Stage.Suspect)
{
_logger.LogWarning(
"Add-on permission for tenant {TenantId} is suspect: {Feature} is switched on, but the app token lacks {Permission} and a device was not admitted",
tenantId, addOn.Feature, addOn.Permission);
continue;
}
await ReportConfirmedAsync(tenantId, addOn, since, blocksAll).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Recording the missing add-on permission of tenant {TenantId} failed", tenantId);
}
}
public async Task ReconcileSwitchedOffAddOnsAsync(string tenantId)
{
try
{
var now = _time.GetUtcNow().UtcDateTime;
var cached = await _tenantConfig.GetConfigurationIfExistsAsync(tenantId).ConfigureAwait(false);
if (cached == null || AddOnValidation.All.All(a => PlanAddOnChange(a, cached, null, now) == AddOnChange.None))
return;
var outcomes = new List<AddOnOutcome>();
var update = await _tenantConfig.UpdateAsync(tenantId, row =>
{
outcomes.Clear();
ApplyAddOnChanges(row, null, now, outcomes);
return outcomes.Count > 0;
}, BackupSource, "A validation that lacked its optional Graph permission was switched off").ConfigureAwait(false);
if (update.Status == TenantConfigUpdateStatus.Updated)
await ReportAddOnOutcomesAsync(tenantId, outcomes, blocksAll: false).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Reconciling the add-on permission state of tenant {TenantId} failed", tenantId);
}
}
private async Task ReportAddOnOutcomesAsync(string tenantId, List<AddOnOutcome> outcomes, bool blocksAll)
{
foreach (var outcome in outcomes)
{
if (outcome.Change == AddOnChange.Confirm)
{
await ReportConfirmedAsync(tenantId, outcome.AddOn, outcome.Since, blocksAll).ConfigureAwait(false);
continue;
}
// An episode that was never confirmed was never shown: it ends without a trace.
if (!outcome.WasConfirmed)
continue;
var resolution = outcome.Change == AddOnChange.ResolveGranted ? ResolvedByGrant : ResolvedBySwitchOff;
_logger.LogWarning(
"Add-on permission for tenant {TenantId} is resolved: {Feature} ({Resolution})",
tenantId, outcome.AddOn.Feature, resolution);
await _opsEvents.Value.RecordAddOnPermissionResolvedAsync(tenantId, outcome.AddOn.Feature, resolution).ConfigureAwait(false);
}
}
private async Task ReportConfirmedAsync(string tenantId, AddOnValidation addOn, DateTime since, bool blocksAll)
{
_logger.LogWarning(
"Add-on permission for tenant {TenantId} is confirmed missing: {Feature} still lacks {Permission} (since {Since:o}, blocks all validation: {BlocksAll})",
tenantId, addOn.Feature, addOn.Permission, since, blocksAll);
await _opsEvents.Value.RecordAddOnPermissionMissingAsync(tenantId, addOn.Feature, addOn.Permission, since, blocksAll).ConfigureAwait(false);
}
private async Task AlertOurCredentialAsync(string tenantId, EntraTokenRejection rejection, DateTime now)
{
// Hits every tenant at once: one alert per instance and hour says everything.
var last = Interlocked.Read(ref _lastCredentialAlertTicks);
if (now.Ticks - last < CredentialAlertInterval.Ticks)
return;
if (Interlocked.CompareExchange(ref _lastCredentialAlertTicks, now.Ticks, last) != last)
return;
_logger.LogError(
"Entra rejects our app credentials ({ErrorCode}, status {StatusCode}, seen on tenant {TenantId}); device validation fails for every tenant on that app",
rejection.ErrorCode, rejection.StatusCode, tenantId);
await _opsEvents.Value.RecordGraphAppCredentialRejectedAsync(tenantId, rejection.ErrorCode, rejection.StatusCode).ConfigureAwait(false);
}
}
}
@@ -1,3 +1,4 @@
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared;
using System;
using System.Collections.Concurrent;
@@ -30,6 +31,7 @@ namespace AutopilotMonitor.Functions.Security
private static readonly TimeSpan MaxTokenTtl = TimeSpan.FromMinutes(55);
private static readonly TimeSpan DefaultTokenLifetime = TimeSpan.FromMinutes(35); // when AAD omits expires_in
private static readonly TimeSpan TokenGateWait = TimeSpan.FromSeconds(10);
private static readonly TimeSpan RejectionLogWindow = TimeSpan.FromMinutes(15);
// One coalescing gate per tenant (see GetAccessTokenAsync). A SemaphoreSlim is tiny and
// tenant cardinality is bounded + validated upstream, so these are never evicted.
@@ -52,19 +54,22 @@ namespace AutopilotMonitor.Functions.Security
private readonly IMemoryCache _cache;
private readonly EntraAppRegistry _appRegistry;
private readonly Services.TenantConfigurationService _tenantConfigService;
private readonly IGraphAccessHealthRecorder _accessHealth;
public GraphTokenService(
ILogger<GraphTokenService> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
EntraAppRegistry appRegistry,
Services.TenantConfigurationService tenantConfigService)
Services.TenantConfigurationService tenantConfigService,
IGraphAccessHealthRecorder accessHealth)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_appRegistry = appRegistry;
_tenantConfigService = tenantConfigService;
_accessHealth = accessHealth;
}
/// <summary>
@@ -138,7 +143,41 @@ namespace AutopilotMonitor.Functions.Security
// the resolved client id, so a tenant flipped between apps can never be served a token
// minted under the other app.
var credentials = await ResolveCredentialsAsync(tenantId);
return await GetAccessTokenWithCredentialsAsync(tenantId, credentials, ct);
var result = await GetAccessTokenWithCredentialsAsync(tenantId, credentials, ct);
if (result.IsFreshlyMinted && TryGetApplicationRoles(result.AccessToken, out var roles))
await _accessHealth.RecordMintedAsync(tenantId, roles);
return result;
}
/// <summary>
/// <see cref="GetAccessTokenAsync"/> for the device validators of the agent routes and the
/// maintenance recheck, the only callers whose rejection counts as evidence of lost access.
/// The consent flows in the portal see 401s as a matter of course while a consent propagates.
/// </summary>
public async Task<GraphTokenResult> GetAccessTokenForAgentValidationAsync(string tenantId, CancellationToken ct = default)
{
var result = await GetAccessTokenAsync(tenantId, ct);
var refusal = result.Rejection
?? (result.IsFreshlyMinted && !TryGetApplicationRoles(result.AccessToken, out _) ? EntraTokenRejection.NoApplicationRole : null);
if (refusal != null)
await _accessHealth.RecordRejectedAsync(tenantId, refusal);
return result;
}
/// <summary>
/// Whether a token can do anything at Graph, and with which application roles. A user's
/// sign-in consent re-creates a removed enterprise application without any application
/// permission: Entra issues tokens again, and Graph refuses every call made with them. Only a
/// role proves access.
/// </summary>
private static bool TryGetApplicationRoles(string? accessToken, out IReadOnlySet<string> roles)
{
if (!string.IsNullOrEmpty(accessToken)
&& GraphFeatureDetector.TryParseToken(accessToken, out roles, out _)
&& roles.Count > 0)
return true;
roles = new HashSet<string>();
return false;
}
/// <summary>
@@ -238,6 +277,8 @@ namespace AutopilotMonitor.Functions.Security
string? responseBody = null;
int attempt = 0;
bool lastAttemptWasTransient = false;
// Survives the budget: a caller cut off between retries still learns what Entra said.
EntraTokenRejection? rejection = null;
while (true)
{
@@ -245,7 +286,7 @@ namespace AutopilotMonitor.Functions.Security
{
// Caller's budget exhausted — treat as transient so we never cache "no consent"
// for a tenant just because the optional path timed out.
return GraphTokenResult.TransientFailure();
return GraphTokenResult.TransientFailure(rejection);
}
try
{
@@ -271,7 +312,7 @@ namespace AutopilotMonitor.Functions.Security
// the token's own expires_in, minus a skew margin, clamped to a safe band.
CacheToken(tenantId, clientId, accessToken!, genAtStart, (long?)tokenJson?["expires_in"]);
}
return GraphTokenResult.Success(accessToken);
return GraphTokenResult.Minted(accessToken);
}
// Classify the error: consent-propagation errors are retryable but ultimately permanent (no consent).
@@ -287,6 +328,9 @@ namespace AutopilotMonitor.Functions.Security
|| isConsentError || isServerError;
lastAttemptWasTransient = isServerError;
rejection = isServerError ? null : EntraTokenRejection.Parse(statusCode, responseBody);
if (rejection != null)
LogRejectionOncePerWindow(tenantId, clientId, rejection);
if (!isRetryable || attempt >= retryDelays.Length)
{
@@ -300,7 +344,7 @@ namespace AutopilotMonitor.Functions.Security
// Server errors are transient (infrastructure issue), consent errors are permanent (no consent granted)
return lastAttemptWasTransient
? GraphTokenResult.TransientFailure()
: GraphTokenResult.PermanentFailure();
: GraphTokenResult.PermanentFailure(rejection);
}
var delay = retryDelays[attempt];
@@ -328,7 +372,7 @@ namespace AutopilotMonitor.Functions.Security
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Caller budget exhausted between retries — transient by definition.
return GraphTokenResult.TransientFailure();
return GraphTokenResult.TransientFailure(rejection);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
@@ -338,7 +382,7 @@ namespace AutopilotMonitor.Functions.Security
_logger.LogWarning(ex,
"Graph token acquisition for tenant {TenantId} failed with network error after {Attempts} attempt(s)",
tenantId, attempt + 1);
return GraphTokenResult.TransientFailure();
return GraphTokenResult.TransientFailure(rejection);
}
var delay = retryDelays[attempt];
@@ -349,13 +393,29 @@ namespace AutopilotMonitor.Functions.Security
try { await Task.Delay(delay, ct); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return GraphTokenResult.TransientFailure();
return GraphTokenResult.TransientFailure(rejection);
}
attempt++;
}
}
}
/// <summary>
/// The retry chain's own failure line only comes after its 50 s, which the validation budget
/// never reaches, so without this line Entra's reason would not reach App Insights at all.
/// </summary>
private void LogRejectionOncePerWindow(string tenantId, string clientId, EntraTokenRejection rejection)
{
var key = $"graph-token-rejection-logged:{tenantId}:{clientId.ToLowerInvariant()}";
if (_cache.TryGetValue(key, out _))
return;
_cache.Set(key, true, RejectionLogWindow);
_logger.LogWarning(
"Graph token rejected by Entra for tenant {TenantId}, app {ClientId}: status {StatusCode}, {ErrorCode}. {Description}",
tenantId, clientId, rejection.StatusCode, rejection.ErrorCode, rejection.Description);
}
// Keys include the resolved client id: during the dual app-reg window a tenant flip
// (HomedAppClientId change) must never be served a token/verdict minted under the other
// app. Normalized to lowercase so casing differences in config can't split the cache.
@@ -440,9 +500,16 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public bool IsTransient { get; private set; }
/// <summary>True when Entra issued this token just now; false for one from the cache.</summary>
public bool IsFreshlyMinted { get; private set; }
/// <summary>What Entra last refused with, also when the caller's budget ended the retries; null without a refusal.</summary>
public EntraTokenRejection? Rejection { get; private set; }
public static GraphTokenResult Success(string? token) => new() { AccessToken = token };
public static GraphTokenResult PermanentFailure() => new() { AccessToken = null, IsTransient = false };
public static GraphTokenResult TransientFailure() => new() { AccessToken = null, IsTransient = true };
public static GraphTokenResult Minted(string? token) => new() { AccessToken = token, IsFreshlyMinted = !string.IsNullOrEmpty(token) };
public static GraphTokenResult PermanentFailure(EntraTokenRejection? rejection = null) => new() { AccessToken = null, IsTransient = false, Rejection = rejection };
public static GraphTokenResult TransientFailure(EntraTokenRejection? rejection = null) => new() { AccessToken = null, IsTransient = true, Rejection = rejection };
}
public class GraphConsentStatusResult
@@ -247,7 +247,7 @@ namespace AutopilotMonitor.Functions.Security
var ct = attemptCts.Token;
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
var tokenResult = await _graphTokenService.GetAccessTokenForAgentValidationAsync(tenantId, ct);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new IntuneDeviceBindingResult
@@ -36,10 +36,12 @@ namespace AutopilotMonitor.Functions.Security
BootstrapSessionService? bootstrapSessionService = null,
DeviceAssociationValidator? deviceAssociationValidator = null,
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null,
IGraphAccessHealthRecorder? accessHealth = null,
bool selfReportedCloudPc = false)
{
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator, intuneDeviceBindingValidator);
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId);
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator, intuneDeviceBindingValidator, accessHealth);
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId, selfReportedCloudPc);
if (!validation.IsValid)
{
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
@@ -76,6 +77,7 @@ namespace AutopilotMonitor.Functions.Security
private readonly CloudPcDeviceValidator? _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator? _intuneDeviceBindingValidator;
private readonly BootstrapSessionService? _bootstrapSessionService;
private readonly IGraphAccessHealthRecorder? _accessHealth;
private readonly ILogger _logger;
public SecurityValidator(
@@ -88,7 +90,8 @@ namespace AutopilotMonitor.Functions.Security
BootstrapSessionService? bootstrapSessionService = null,
DeviceAssociationValidator? deviceAssociationValidator = null,
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null,
IGraphAccessHealthRecorder? accessHealth = null)
{
_configService = configService;
_adminConfigService = adminConfigService;
@@ -99,6 +102,7 @@ namespace AutopilotMonitor.Functions.Security
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_accessHealth = accessHealth;
_logger = logger;
}
@@ -129,8 +133,13 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
/// <param name="req">HTTP request</param>
/// <param name="tenantId">Tenant ID for configuration lookup</param>
/// <param name="selfReportedCloudPc">
/// The agent's own Cloud PC verdict (<c>SessionRegistration.IsCloudPc</c>, two local W365 markers).
/// Never an admission input: it only decides whether a Cloud PC lookup blocked by the missing
/// add-on permission counts toward the add-on notice.
/// </param>
/// <returns>Security validation result</returns>
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null)
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null, bool selfReportedCloudPc = false)
{
// 0. Verify tenant is known and not suspended (cheapest gate — runs before cert/rate/hardware)
var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId);
@@ -415,7 +424,7 @@ namespace AutopilotMonitor.Functions.Security
: null;
// TLS-proven device identity: the Intune managedDevice id in the certificate CN.
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
string? autopilotDeviceId = null;
AutopilotRegistration? autopilotRegistration = null;
bool deviceValidated = false;
bool deviceValidationTransient = false;
// Largest Retry-After any transient validator asked for (GraphAuthFailure: a fresh
@@ -423,6 +432,9 @@ namespace AutopilotMonitor.Functions.Security
int? deviceValidationRetryAfter = null;
string? deviceValidationError = null;
ValidatorType validatedBy = ValidatorType.Unknown;
// Switched-on validations that could not decide because their optional Graph
// permission is missing (GraphAccessHealthRecorder turns them into tenant state).
List<AddOnValidation>? addOnsMissingPermission = null;
// One time budget for the whole (sequential) validator chain — see
// DeviceValidationBudget: a stuck Graph call must not park the request past the
@@ -436,7 +448,7 @@ namespace AutopilotMonitor.Functions.Security
if (autopilotResult.IsValid)
{
deviceValidated = true;
autopilotDeviceId = autopilotResult.AutopilotDeviceId;
autopilotRegistration = autopilotResult.Registration;
validatedBy = ValidatorType.AutopilotV1;
}
else
@@ -506,6 +518,13 @@ namespace AutopilotMonitor.Functions.Security
{
deviceValidationError = CombineValidationErrors(deviceValidationError, cloudPcResult.ErrorMessage);
deviceValidationTransient |= cloudPcResult.IsTransient;
// Every device that missed the serial lookups lands here (laptops registered in
// Autopilot only during enrollment, Hyper-V test VMs), and the grant admits none of
// them. Only a device reporting itself as a Cloud PC was kept out by the missing grant.
// A Cloud PC is never admitted without it, so each of its runs registers: the register
// request is the only one carrying the flag, and it is enough.
if (cloudPcResult.PermissionMissing && selfReportedCloudPc)
(addOnsMissingPermission ??= new List<AddOnValidation>()).Add(AddOnValidation.CloudPc);
}
}
@@ -542,12 +561,18 @@ namespace AutopilotMonitor.Functions.Security
{
deviceValidationTransient = true;
deviceValidationRetryAfter = MaxRetryAfter(deviceValidationRetryAfter, GraphAuthFailure.PermissionMissingRetryAfterSeconds);
(addOnsMissingPermission ??= new List<AddOnValidation>()).Add(AddOnValidation.IntuneDeviceBinding);
}
}
}
if (config.HasAnyDeviceValidation() && !deviceValidated)
{
// Only a device that stayed out counts as a consequence of the missing grant: the
// notice must not appear while another validation admits every device.
if (addOnsMissingPermission != null && _accessHealth != null)
await _accessHealth.RecordAddOnPermissionMissingAsync(tenantId, addOnsMissingPermission);
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
// Definitive failures (device not registered) → 403 Forbidden
if (deviceValidationTransient)
@@ -605,7 +630,7 @@ namespace AutopilotMonitor.Functions.Security
Manufacturer = manufacturer,
Model = model,
SerialNumber = serialNumber,
AutopilotDeviceId = autopilotDeviceId,
AutopilotRegistration = autopilotRegistration,
RateLimitResult = rateLimitResult,
ValidatedBy = validatedBy
};
@@ -809,9 +834,10 @@ namespace AutopilotMonitor.Functions.Security
public string? SerialNumber { get; set; }
/// <summary>
/// Autopilot device ID from Intune (if Autopilot device validation is enabled and succeeded)
/// The device's Autopilot registration as the Autopilot validator read it — set only when that
/// validator admitted the device. Stored with the session as a server fact.
/// </summary>
public string? AutopilotDeviceId { get; set; }
public AutopilotRegistration? AutopilotRegistration { get; set; }
/// <summary>
/// Rate limit result (if validation succeeded)
@@ -5,7 +5,7 @@ namespace AutopilotMonitor.Functions.Services.Activation
/// <summary>
/// Queue message contract for <c>tenant-auto-approve</c>. One envelope per new tenant
/// signup, enqueued with <see cref="ActivationDelay"/> visibility delay so the operator's
/// Telegram / portal notification lands before the activation happens.
/// alert and portal notification land before the activation happens.
/// </summary>
public sealed class TenantAutoApproveEnvelope
{
@@ -144,7 +144,10 @@ namespace AutopilotMonitor.Functions.Services
}
var legacy = await _graphTokenService.GetAccessTokenForAppAsync(tenantId, legacyApp, ct);
if (string.IsNullOrWhiteSpace(legacy.AccessToken) && legacy.IsTransient)
// A missing service principal is a fact even when the caller's budget cut the retries
// short: the access check runs on 4 s, its first retry waits 5 s.
if (string.IsNullOrWhiteSpace(legacy.AccessToken) && legacy.IsTransient
&& legacy.Rejection?.IsServicePrincipalMissing != true)
{
return new AppHomingProbeResult(Succeeded: false, IsTransient: true);
}
@@ -231,6 +234,9 @@ namespace AutopilotMonitor.Functions.Services
{
var normalizedTarget = EntraAppRegistry.NormalizeClientId(targetClientId);
string? oldValue = null;
string? closedRejectionCode = null;
var closedRejection = false;
var closedAddOns = new List<AddOnValidation>();
var update = await _tenantConfigService.UpdateAsync(tenantId, row =>
{
if (string.Equals(row.HomedAppClientId, normalizedTarget, StringComparison.OrdinalIgnoreCase))
@@ -238,6 +244,21 @@ namespace AutopilotMonitor.Functions.Services
oldValue = row.HomedAppClientId;
row.HomedAppClientId = normalizedTarget;
row.UpdatedBy = actorUpn;
// A Graph access rejection and the add-on grants belong to the app the tenant just
// left; the new app's next fresh token decides again. A confirmed episode ends
// with its closing event, so the ops trail never shows an open one.
closedRejection = row.GraphAccessRejectionConfirmedAt != null;
closedRejectionCode = row.GraphAccessRejectedCode;
row.GraphAccessRejectedSince = null;
row.GraphAccessRejectedCode = null;
row.GraphAccessRejectionConfirmedAt = null;
closedAddOns.Clear();
foreach (var addOn in AddOnValidation.All)
{
if (addOn.MissingConfirmedAt(row) != null)
closedAddOns.Add(addOn);
addOn.ClearEpisode(row);
}
return true;
}, "app-homing", reason);
@@ -297,6 +318,10 @@ namespace AutopilotMonitor.Functions.Services
});
await _opsEventService.RecordAppHomingFlippedAsync(tenantId, actorUpn, oldLabel, newLabel, reason, forced);
if (closedRejection)
await _opsEventService.RecordGraphAccessClosedByAppHomingFlipAsync(tenantId, closedRejectionCode);
foreach (var addOn in closedAddOns)
await _opsEventService.RecordAddOnPermissionResolvedAsync(tenantId, addOn.Feature, GraphAccessHealthRecorder.ResolvedByAppHomingFlip);
}
/// <summary>
@@ -336,7 +336,7 @@ namespace AutopilotMonitor.Functions.Services.Backup.Queue
await _jobs.TryUpdateWithCasAsync(lostJob, lostETag.Value, ct).ConfigureAwait(false);
}
// Codex-Hotfix Wave2 #2: emit OpsEvent for the lease-loss terminal path so
// Telegram alert rules fire on this manual-queue failure mode the same way
// alert rules fire on this manual-queue failure mode the same way
// they do for the timer path.
await TryRecordOpsEventAsync(() => _opsEvents.RecordCriticalTableBackupFailedAsync(
backupIdForEvent, leaseLossReason, runningJobRequestedBy)).ConfigureAwait(false);

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