mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-11 04:09:51 +00:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7055a6f0fc | |||
| 587de860dd | |||
| 409794c037 | |||
| 337b3b012a | |||
| c2ae5ddd83 | |||
| 60baf5317c | |||
| a5d5a37dc1 | |||
| d4b56c417d | |||
| a5bbdf9f80 | |||
| 389d4f27dc | |||
| ce5f22bd19 | |||
| 85fadc8ac4 | |||
| 7633c02a1c | |||
| bd66ad7097 | |||
| be13d8f5ef | |||
| 8296fa7291 | |||
| 4faca8692b | |||
| ba715d5aa7 | |||
| ed73dc3a87 | |||
| 1e88d1db4b | |||
| f3778fbad6 | |||
| 1a85a75af1 | |||
| 5f13c71f1d | |||
| 041803e55c | |||
| eff89fd09f | |||
| 0cc35d2bae | |||
| 27cb286118 | |||
| c88e5411a8 | |||
| 5eba39f658 | |||
| 5f95648c73 | |||
| e4d527b6d5 | |||
| 258d7cd5f4 | |||
| 668ae14657 | |||
| 16bd82b148 | |||
| 225d0feca9 | |||
| 6b06edb222 | |||
| 12b29c2e08 | |||
| 5a37721d24 | |||
| 884f8495b2 | |||
| cc674b26ad | |||
| 7139374681 | |||
| e6d92c6005 | |||
| 46880b58e7 | |||
| ee96ff2d23 | |||
| 4275c13dfc | |||
| 1616e33c14 | |||
| 0b39455d4e | |||
| 48baffe753 | |||
| f6f88161f9 | |||
| d0a525ecbd | |||
| 257f403949 | |||
| 7cbcb6bc4c | |||
| f43462567e | |||
| b5ac4651a0 | |||
| 3bc8b87a19 | |||
| 950b3cc679 | |||
| 0559919a9e | |||
| e8f8b876e3 | |||
| 83afd4f1c9 | |||
| d695c9b8c4 | |||
| 1ee7c48c92 | |||
| 98ac414208 | |||
| 5153e90e7c | |||
| 5b139f90e7 | |||
| 05d21cb240 | |||
| 5cc69a696a | |||
| fd3ce44c0c | |||
| 0b48b1ce63 | |||
| 5cb536c46e | |||
| 3f97a418d4 | |||
| cdd44d152a | |||
| d26d877989 | |||
| 59959c663e | |||
| ed2e3fd96c | |||
| d35edeaf9f | |||
| 21b802a028 | |||
| ae258cd9a7 | |||
| 640a4fec04 | |||
| 96d2e6610a | |||
| a510418623 | |||
| 0d43abf249 | |||
| a867fea5bf | |||
| 1a0147c942 | |||
| 4ba5a2af55 | |||
| 327b5b07d0 | |||
| 141b3ca737 | |||
| 9b9ec8586e | |||
| 877c50ce6c | |||
| dcefbda2bf | |||
| e10f1ffda9 | |||
| a4f9d89c02 | |||
| 943087a026 | |||
| 20bf50c935 | |||
| a82a82757a | |||
| 6676565d5f | |||
| b9083c93df | |||
| 2c79d61003 | |||
| bb8ac2181e | |||
| 684f66f560 | |||
| 86e98f39d5 | |||
| 0be30de253 | |||
| 942650890a | |||
| a8f2fb2368 | |||
| 924ca6c916 | |||
| 3f40531b33 | |||
| 7b336c8cf0 | |||
| 74a0bc13a3 | |||
| c0e91ca0f9 | |||
| ccbc754b0e | |||
| 15cd78d81a | |||
| d95da2eeab | |||
| 967d38c7cc | |||
| b47487617a | |||
| f2b86dfc73 | |||
| 0c7b533107 | |||
| 5eec2d7e60 | |||
| e9cb51205e | |||
| cc5dd7d6c2 | |||
| 11731db5e4 | |||
| 6866fe79af | |||
| 6e34fa517c | |||
| dc05428dae | |||
| cd0c750e80 | |||
| cede4be654 | |||
| 0d63057725 | |||
| 7dd151b714 | |||
| 778eb44a34 | |||
| 00f37d869a | |||
| d88a118903 | |||
| 0ed3258d4f | |||
| 3941c3b984 | |||
| 3d831d16d7 | |||
| 46b895a70d | |||
| 77fae059ed | |||
| fd309da51d | |||
| 9c4039b3c3 | |||
| b0fb8eb5fa | |||
| dd2432fa62 | |||
| d9b67e444a | |||
| 47b91f76e7 | |||
| 9c6b55a8bc | |||
| f00b475f59 | |||
| f8743dcae0 | |||
| bd94382edb | |||
| 8abd3a8848 |
+1
-1
@@ -30,7 +30,7 @@
|
||||
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
|
||||
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
|
||||
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `.github/zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
|
||||
|
||||
## Code Quality
|
||||
|
||||
+1
-1
Submodule .claude/commands updated: d937abd228...3699da8230
@@ -24,6 +24,11 @@ updates:
|
||||
dotnet-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
# Application Insights 3 is the OpenTelemetry rewrite (no telemetry processors, another sampling
|
||||
# model) and the Functions worker integration still builds on 2.x: a planned migration, not a bump.
|
||||
ignore:
|
||||
- dependency-name: 'Microsoft.ApplicationInsights.WorkerService'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/Web/autopilot-monitor-web
|
||||
@@ -98,6 +103,24 @@ updates:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
|
||||
# SWA CLI for the deploy job of deploy-web.yml, which installs it from this lockfile next to
|
||||
# the production deploy token: a version reaches that job only through a reviewed bump here.
|
||||
# deploy-web watches this folder, so a merged bump redeploys and proves the new CLI at once.
|
||||
# Not under .github/: there Dependabot's update PR was refused as an unauthorized change.
|
||||
- package-ecosystem: npm
|
||||
directory: /tools/swa-cli
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
swa-cli:
|
||||
patterns: ['*']
|
||||
swa-cli-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
|
||||
# Every action is referenced by commit SHA with the version in a trailing comment;
|
||||
# action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot
|
||||
# is what keeps those pins current: it rewrites the SHA and the comment together, so the
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
name: Workflow gates
|
||||
|
||||
# Three checks over the workflow files and their helper scripts:
|
||||
# Four checks over the workflow files and their helper scripts:
|
||||
# * verify-pins -- every third-party action is referenced by a full commit SHA plus a
|
||||
# version comment (stricter than zizmor's unpinned-uses audit).
|
||||
# * zizmor -- static analysis of every workflow (permissions, credential
|
||||
# persistence, template injection, ...). Configuration and the
|
||||
# reasoned ignores live in zizmor.yml at the repository root; the
|
||||
# same run is expected locally before a workflow change is committed.
|
||||
# reasoned ignores live in .github/zizmor.yml, which zizmor finds on
|
||||
# its own; the same run is expected locally before a workflow change
|
||||
# is committed.
|
||||
# * hosts -- no workflow or .github/scripts helper spells out a host; every one
|
||||
# comes from .github/scripts/Get-Endpoints.ps1, CI's member of the URL
|
||||
# registry (the C#, web and MCP registries have guard tests of their own).
|
||||
# * packages -- no workflow or helper fetches a package at a version the repository
|
||||
# does not fix, in the forms zizmor's adhoc-packages audit does not see.
|
||||
#
|
||||
# Every third-party action must be referenced by a full commit SHA, never by a tag.
|
||||
# Tags are mutable: whoever controls an action repository can repoint v4 at new code,
|
||||
@@ -37,10 +40,10 @@ name: Workflow gates
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -105,7 +108,7 @@ jobs:
|
||||
# the local install so the gate and the pre-commit run agree on the finding set.
|
||||
# Informational findings (template expansion of our own step outputs in pwsh) are
|
||||
# visible locally but do not fail the gate.
|
||||
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
- uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||
with:
|
||||
version: 1.30.1
|
||||
min-severity: low
|
||||
@@ -123,3 +126,51 @@ jobs:
|
||||
- name: No host literal outside .github/scripts/Get-Endpoints.ps1
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Assert-HostsFromRegistry.ps1
|
||||
|
||||
packages:
|
||||
name: Packages only at a version the repository fixes
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# npm packages come from a committed lockfile via npm ci; zizmor's adhoc-packages flags
|
||||
# `npm install <pkg>` (also yarn, pnpm, gem), but not the forms below, which also fetch at
|
||||
# run time:
|
||||
# * npx / npm exec without --no: in CI they install a missing package without asking.
|
||||
# The -- is load-bearing: without it npx hands the tool's own flags to npm.
|
||||
# * a .NET tool without an exact --version, a PowerShell module without -RequiredVersion.
|
||||
# Other package managers (pip, cargo, dlx runners) are covered by neither check.
|
||||
- name: No package fetched at a version the repository does not fix
|
||||
run: |
|
||||
set -uo pipefail
|
||||
failed=0
|
||||
fail() { echo "::error file=$1,line=$2::$3"; failed=1; }
|
||||
while IFS=: read -r file line text; do
|
||||
# YAML, shell and PowerShell comments alike start the line with #.
|
||||
[[ "$text" =~ ^[[:space:]]*# ]] && continue
|
||||
|
||||
if [[ "$text" =~ (^|[^[:alnum:]_./-])(npx|npm[[:space:]]+(exec|x))([[:space:]]|$) ]] &&
|
||||
! [[ "$text" =~ (npx|npm[[:space:]]+(exec|x))[[:space:]]+--no[[:space:]]+--([[:space:]]|$) ]]; then
|
||||
fail "$file" "$line" "Write 'npx --no -- <tool>' (or 'npm exec --no -- <tool>'): without --no a missing package is fetched from the registry, without -- the tool's flags go to npm."
|
||||
fi
|
||||
|
||||
if [[ "$text" =~ dotnet[[:space:]]+tool[[:space:]]+(install|update) ]] &&
|
||||
! [[ "$text" =~ --version[[:space:]]+[0-9]+\.[0-9]+\.[0-9]+([^0-9.*]|$) ]]; then
|
||||
fail "$file" "$line" "Give the .NET tool an exact --version X.Y.Z on the same line; a range or a missing version resolves at run time."
|
||||
fi
|
||||
|
||||
if [[ "$text" =~ Install-(Module|PSResource)[[:space:]] ]] &&
|
||||
! [[ "$text" =~ -RequiredVersion[[:space:]]+[^[:space:]] || "$text" =~ -Version[[:space:]]+[0-9]+\.[0-9]+\.[0-9]+([^0-9.*]|$) ]]; then
|
||||
fail "$file" "$line" "Give the PowerShell module an exact version on the same line, -RequiredVersion for Install-Module, -Version X.Y.Z for Install-PSResource; -MinimumVersion resolves at run time."
|
||||
fi
|
||||
# -H is load-bearing, as in verify-pins: with a single matching file grep omits the name.
|
||||
done < <(grep -nHE 'npx|npm[[:space:]]+(exec|x)([[:space:]]|$)|dotnet[[:space:]]+tool[[:space:]]+(install|update)|Install-(Module|PSResource)' \
|
||||
.github/workflows/*.yml .github/scripts/*.ps1)
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "::error::Packages fetched at a version the repository does not fix -- fix them before this reaches main."
|
||||
exit 1
|
||||
fi
|
||||
echo "Every package the workflows and their helpers fetch has a version the repository fixes."
|
||||
|
||||
@@ -65,11 +65,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
# For all users: the SYSTEM pass below runs Pester as SYSTEM.
|
||||
- name: Install Pester 5+
|
||||
- name: Install Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
|
||||
Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -RequiredVersion 6.2.0
|
||||
|
||||
# Windows PowerShell 5.1 on purpose: that is the runtime IME uses to execute
|
||||
# platform scripts, so guard/relax logic is verified without resetting a device.
|
||||
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
Get-ChildItem $outDir | ForEach-Object { Write-Host "$($_.Name): $([math]::Round($_.Length / 1MB, 2)) MB" }
|
||||
|
||||
- name: Upload unsigned build output
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
@@ -293,7 +293,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download unsigned build output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
@@ -790,13 +790,13 @@ jobs:
|
||||
staging/agent/AutopilotMonitor.SummaryDialog.exe
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}
|
||||
path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload version metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
Write-Host "$env:RUNNER_SCRIPT : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
|
||||
|
||||
- name: Upload signed loader
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ env.RUNNER_SCRIPT }}
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download signed loader
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ runner.temp }}/loader
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
|
||||
- name: Upload unsigned MSI
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
@@ -310,7 +310,7 @@ jobs:
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
@@ -442,7 +442,7 @@ jobs:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
|
||||
+10
-10
@@ -24,7 +24,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -56,7 +56,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -145,10 +145,10 @@ jobs:
|
||||
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
|
||||
# v16); typegen generates them so tsc sees the same types as a build.
|
||||
- name: Generate Next.js types
|
||||
run: npx next typegen
|
||||
run: npx --no -- next typegen
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: npx --no -- tsc --noEmit
|
||||
|
||||
# Errors fail outright, and so does ANY warning: the codebase is
|
||||
# warning-clean and must stay that way — fix new findings, never
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
# tsconfig.json covers sources, tests and scripts; the build emits via
|
||||
# tsconfig.build.json.
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: npx --no -- tsc --noEmit
|
||||
|
||||
# Same rule as the web job: any warning fails — fix new findings, never
|
||||
# raise the cap.
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -48,7 +48,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -123,6 +123,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# `git push` needs.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
run: tar -czf "$RUNNER_TEMP/backend-publish.tgz" -C publish .
|
||||
|
||||
- name: Upload publish output
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-publish.tgz
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download publish output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-artifact
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
name: Deploy Web (Static Web App)
|
||||
|
||||
# Build here, upload with the pinned SWA CLI. No third-party action sees the deploy token
|
||||
# (the CLI is Microsoft-published npm code at an exact version), the build runs on the same
|
||||
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
|
||||
# action's container, and the exported site is attested like the agent ZIP and the MSI.
|
||||
# Build here, upload with the SWA CLI from its own lockfile (tools/swa-cli). No
|
||||
# third-party action sees the deploy token, the build runs on the same path ci.yml tests
|
||||
# (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the action's container,
|
||||
# and the exported site is attested like the agent ZIP and the MSI.
|
||||
#
|
||||
# Three jobs, each holding only what it needs: build runs all third-party code without any
|
||||
# credential, deploy holds the SWA token alone, record attests, verifies and writes the
|
||||
# manifest. A failed record job is re-run on its own; it writes only once the live site
|
||||
# serves this commit.
|
||||
# Three jobs, each holding only what it needs: build runs the third-party build code without
|
||||
# any credential, deploy holds the SWA token alone and runs nothing but the locked CLI, record
|
||||
# attests, verifies and writes the manifest. A failed record job is re-run on its own; it
|
||||
# writes only once the live site serves this commit.
|
||||
#
|
||||
# PR preview environments were dropped with the action: their per-PR URLs are never
|
||||
# registered as redirect URIs, so nobody could log in on them anyway.
|
||||
@@ -19,6 +19,7 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- 'tools/swa-cli/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,9 +33,6 @@ permissions:
|
||||
# repository variable.
|
||||
env:
|
||||
WEB_DIR: src/Web/autopilot-monitor-web
|
||||
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
|
||||
# only thing that keeps the deploy step's code fixed. Bump deliberately.
|
||||
SWA_CLI_VERSION: 2.0.10
|
||||
|
||||
jobs:
|
||||
# Build zone: no login, no id-token. The one secret here is the Application Insights
|
||||
@@ -74,7 +72,7 @@ jobs:
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -149,14 +147,15 @@ jobs:
|
||||
sha256sum "$archive"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Holds the SWA deployment token and nothing else -- deliberately no id-token: the SWA CLI
|
||||
# is third-party npm code whose dependencies resolve at run time.
|
||||
# Holds the SWA deployment token and nothing else -- deliberately no id-token. The token alone
|
||||
# can replace the portal, so the SWA CLI, the one third-party code here, comes from a committed
|
||||
# lockfile: a version published after the last reviewed bump never runs next to it.
|
||||
deploy:
|
||||
name: Deploy to Static Web App
|
||||
needs: build
|
||||
@@ -164,13 +163,21 @@ jobs:
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
# For staticwebapp.config.json only.
|
||||
# For staticwebapp.config.json and the CLI's lockfile.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# npm ci, never npx or npm install: the lockfile pins every transitive package by hash.
|
||||
# No lifecycle scripts: keytar, the only package with one, serves the keychain, which
|
||||
# --no-use-keychain keeps unloaded. Before the artifact arrives, so nothing from the
|
||||
# build zone can touch the install.
|
||||
- name: Install the SWA CLI from its lockfile
|
||||
working-directory: tools/swa-cli
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
@@ -186,7 +193,7 @@ jobs:
|
||||
env:
|
||||
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
|
||||
tools/swa-cli/node_modules/.bin/swa deploy \
|
||||
--app-location "$WEB_DIR" \
|
||||
--output-location out \
|
||||
--swa-config-location "$WEB_DIR" \
|
||||
@@ -215,7 +222,7 @@ jobs:
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
# when someone wants to look at the file.
|
||||
- name: Upload signed scripts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signed-scripts
|
||||
path: ${{ runner.temp }}/signed-scripts
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# The three submodules are private. "update = none" makes recursive clones skip them (the
|
||||
# public, Dependabot's NuGet updater); a clone with access sets
|
||||
# submodule.<name>.update = checkout once, or runs git submodule update --init --checkout.
|
||||
[submodule ".claude/commands"]
|
||||
path = .claude/commands
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-dotclaude.git
|
||||
update = none
|
||||
[submodule "docs"]
|
||||
path = internal
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-internal.git
|
||||
update = none
|
||||
[submodule "infra"]
|
||||
path = infra
|
||||
url = https://github.com/okieselbach/Autopilot-Monitor-infra.git
|
||||
update = none
|
||||
|
||||
+1
-1
Submodule internal updated: af17a42883...e0913c34d2
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -65,6 +65,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -80,7 +92,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -94,7 +106,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -104,6 +117,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -126,6 +140,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+23
-4
@@ -4103,10 +4103,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -4166,6 +4166,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -4181,7 +4193,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -4195,7 +4207,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -4205,6 +4218,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -4227,6 +4241,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -89,12 +89,14 @@
|
||||
"type": "array",
|
||||
"description": "Conditions evaluated against the event stream. All required conditions must match for the rule to fire.",
|
||||
"items": { "$ref": "#/$defs/ruleCondition" },
|
||||
"minItems": 1
|
||||
"minItems": 1,
|
||||
"maxItems": 20
|
||||
},
|
||||
"preconditions": {
|
||||
"type": "array",
|
||||
"description": "Optional device-fact gates evaluated BEFORE conditions. ALL preconditions must pass; if any fails the rule is silently skipped — no result, no UI card. Useful for filtering out hardware/OS profiles where the rule does not apply (e.g. virtual machines).",
|
||||
"items": { "$ref": "#/$defs/rulePrecondition" },
|
||||
"maxItems": 10,
|
||||
"default": []
|
||||
},
|
||||
"baseConfidence": {
|
||||
|
||||
+3
-3
@@ -18,9 +18,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -44,7 +44,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
IReadOnlyCollection<string>? whiteGloveSealingPatternIds = null,
|
||||
TimeSpan? drainInterval = null,
|
||||
TimeSpan? terminalDrainTimeout = null,
|
||||
TimeSpan? agentMaxLifetime = null) =>
|
||||
TimeSpan? agentMaxLifetime = null,
|
||||
TimeSpan? collectorHostStopTimeout = null) =>
|
||||
new EnrollmentOrchestrator(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
|
||||
drainInterval: drainInterval ?? TimeSpan.FromDays(1),
|
||||
terminalDrainTimeout: terminalDrainTimeout ?? TimeSpan.FromSeconds(2),
|
||||
agentMaxLifetime: agentMaxLifetime);
|
||||
agentMaxLifetime: agentMaxLifetime,
|
||||
collectorHostStopTimeout: collectorHostStopTimeout);
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> run against the netstandard2.0 build of
|
||||
/// <see cref="LocalAdminAllowList"/> — the one the agent ships. The backend runs the same file against
|
||||
/// its net10.0 build, the portal against <c>allowedAccounts.ts</c>.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAllowListFixtureTests
|
||||
{
|
||||
private static IEnumerable<JToken> ParseCases()
|
||||
{
|
||||
var path = Path.Combine(RepoPaths.Fixtures("local-admin-allow-list"), "cases.json");
|
||||
return JObject.Parse(File.ReadAllText(path))["parse"]!;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => ParseCases().Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Agent_parses_the_entry_as_the_shared_case_says(string entry)
|
||||
{
|
||||
var c = ParseCases().First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-21
@@ -18,10 +18,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static readonly List<string> Allowed = new List<string>
|
||||
{
|
||||
"Administrator", "Guest", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "defaultuser1", "adm-*"
|
||||
};
|
||||
// The real built-in catalog plus one tenant glob.
|
||||
private static readonly List<LocalAdminAnalyzer.AllowedEntry> Allowed =
|
||||
LocalAdminAnalyzer.BuildAllowedEntries(new[] { "adm-*" }, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, bool disabled, int rid) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
@@ -126,7 +125,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
[Fact]
|
||||
public void Local_admin_member_missing_from_the_wmi_inventory_is_still_flagged()
|
||||
{
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough.
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough,
|
||||
// and the account counts as unexpected too, like any unknown administrator (score 80).
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
@@ -136,8 +136,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(
|
||||
new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, true, Machine, Allowed);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -191,26 +192,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Well_known_rid_does_not_cover_ordinary_accounts_or_foreign_sids()
|
||||
public void Machine_account_rid_is_read_only_from_local_machine_sids()
|
||||
{
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "500"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "501"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "503"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "504"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "502"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1001"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-5-32-544"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(null));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(""));
|
||||
Assert.Equal(500, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "500"));
|
||||
Assert.Equal(501, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "501"));
|
||||
Assert.Equal(1001, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "1001"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-5-32-544"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "x1"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(null!));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_named_like_a_builtin_but_with_an_ordinary_rid_is_still_matched_by_name_only()
|
||||
public void Localized_builtin_name_on_an_ordinary_rid_is_not_allowed()
|
||||
{
|
||||
// Name-based allowance is unchanged: the RID match only ADDS coverage.
|
||||
// Only the RID makes a built-in: an ordinary account merely named like a localized
|
||||
// built-in is no entry at all.
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Gast", disabled: false, rid: 1001)
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Roles on the allowed list: <c>name|user</c> and the standard-user built-ins (Guest,
|
||||
/// DefaultAccount, WDAGUtilityAccount by RID; kioskUser0, WsiAccount and other accounts carrying a
|
||||
/// built-in name by name) allow an account only while it is not an Administrators member. A
|
||||
/// restriction always wins; the four built-in accounts are decided by RID alone; profile folders
|
||||
/// have no role; a login allows a profile folder, never an account. All names are synthetic.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAnalyzerRoleTests
|
||||
{
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static List<LocalAdminAnalyzer.AllowedEntry> Entries(params string[] tenantEntries)
|
||||
=> LocalAdminAnalyzer.BuildAllowedEntries(tenantEntries, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, int rid, bool disabled = false) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
|
||||
private static LocalGroupMember Member(string name, int rid) =>
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\" + name, Sid = MachineSidPrefix + rid, SidUsage = 1 };
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountCheckResult Evaluate(
|
||||
IList<LocalAdminAnalyzer.LocalAccountInfo> accounts, IList<LocalGroupMember> admins, List<LocalAdminAnalyzer.AllowedEntry> entries,
|
||||
bool enumerated = true)
|
||||
=> LocalAdminAnalyzer.EvaluateAccounts(accounts, admins, enumerated, Machine, entries);
|
||||
|
||||
// -------------------------------------------------------------- WsiAccount (the field case)
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_as_standard_user_with_its_profile_folder_scores_zero()
|
||||
{
|
||||
// The field case: web sign-in enabled the account for the sign-in, it holds no admin
|
||||
// membership and has C:\Users\WsiAccount — before roles existed this scored 80 (Error).
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrator", 500, disabled: true),
|
||||
Account("DefaultAccount", 503, disabled: true),
|
||||
Account("defaultuser0", 1000, disabled: true),
|
||||
Account("Gast", 501, disabled: true),
|
||||
Account("WDAGUtilityAccount", 504, disabled: true),
|
||||
Account("WsiAccount", 1001, disabled: true),
|
||||
Account("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
new LocalGroupMember { DomainAndName = "S-1-12-1-11-22-33-44", Sid = "S-1-12-1-11-22-33-44", SidUsage = 8 },
|
||||
Member("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("ADM-*"));
|
||||
var score = LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
bypassNro: false, result.Unexpected, result.UnexpectedAdminMembers, new List<string> { "jane.doe" });
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_in_administrators_is_a_role_violation_even_while_disabled()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001, disabled: true) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
false, result.Unexpected, result.UnexpectedAdminMembers, new List<string>()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WsiAccount")]
|
||||
[InlineData("wsiaccount")]
|
||||
[InlineData("Wsi*")]
|
||||
[InlineData("*")]
|
||||
public void Tenant_entries_cannot_lift_a_builtin_restriction(string tenantEntry)
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries(tenantEntry));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KioskUser0_in_administrators_is_a_role_violation()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("kioskUser0", 1003) },
|
||||
new List<LocalGroupMember> { Member("kioskUser0", 1003) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "kioskUser0" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- built-ins by RID
|
||||
|
||||
[Theory]
|
||||
[InlineData("Guest", 501)]
|
||||
[InlineData("Gast", 501)]
|
||||
[InlineData("Invité", 501)]
|
||||
[InlineData("DefaultAccount", 503)]
|
||||
[InlineData("WDAGUtilityAccount", 504)]
|
||||
public void Builtin_accounts_that_are_never_administrators_are_flagged_as_one(string name, int rid)
|
||||
{
|
||||
// A tenant entry naming the localized account (or even "*") cannot lift it either.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, rid, disabled: true) },
|
||||
new List<LocalGroupMember> { Member(name, rid) },
|
||||
Entries(name, "*"));
|
||||
|
||||
Assert.Equal(new[] { name }, result.Unexpected);
|
||||
Assert.Equal(new[] { name }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { name }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Administrator")]
|
||||
[InlineData("Administrateur")]
|
||||
[InlineData("Renamed_Admin")]
|
||||
public void The_builtin_administrator_is_allowed_by_rid_under_any_name(string name)
|
||||
{
|
||||
// "Admin*|user" must not turn the real Administrator into a finding: name entries never
|
||||
// decide the RID accounts — and the built-in name entry "Administrator|user" neither.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, 500) },
|
||||
new List<LocalGroupMember> { Member(name, 500) },
|
||||
Entries("Admin*|user", "Renamed*|user"));
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.True(result.BuiltInAdministratorEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_account_named_administrator_is_allowed_only_as_a_decoy()
|
||||
{
|
||||
// French device: the RID-500 account is "Administrateur", so "Administrator" is free.
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrateur", 500, disabled: true),
|
||||
Account("Administrator", 1004),
|
||||
};
|
||||
|
||||
var decoy = Evaluate(accounts, new List<LocalGroupMember> { Member("Administrateur", 500) }, Entries());
|
||||
Assert.Empty(decoy.Unexpected);
|
||||
|
||||
var impostor = Evaluate(accounts,
|
||||
new List<LocalGroupMember> { Member("Administrateur", 500), Member("Administrator", 1004) }, Entries());
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.RoleViolations);
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_oobe_account_may_be_an_administrator()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("defaultuser0", 1000) },
|
||||
new List<LocalGroupMember> { Member("defaultuser0", 1000) },
|
||||
Entries());
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_sid_lets_the_english_builtin_name_stand_in_for_the_rid()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Administrator", Disabled = false, Sid = null! },
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Guest", Disabled = false, Sid = null! },
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Administrator", Sid = null!, SidUsage = 1 },
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Guest", Sid = null!, SidUsage = 1 },
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries());
|
||||
|
||||
Assert.Equal(new[] { "Guest" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("Administrator", result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- tenant roles
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_entry_allows_the_account_until_it_is_an_administrator()
|
||||
{
|
||||
var accounts = new[] { Account("frontdesk", 1005) };
|
||||
|
||||
Assert.Empty(Evaluate(accounts, new List<LocalGroupMember>(), Entries("frontdesk|user")).Unexpected);
|
||||
|
||||
var admin = Evaluate(accounts, new List<LocalGroupMember> { Member("frontdesk", 1005) }, Entries("frontdesk|user"));
|
||||
Assert.Equal(new[] { "frontdesk" }, admin.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_glob_restricts_each_matching_account()
|
||||
{
|
||||
var accounts = new[] { Account("svc-print", 1006), Account("svc-scan", 1007) };
|
||||
|
||||
var result = Evaluate(accounts, new List<LocalGroupMember> { Member("svc-scan", 1007) }, Entries("svc-*|user"));
|
||||
|
||||
Assert.Equal(new[] { "svc-scan" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "svc-scan" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restriction_wins_over_a_broader_entry_that_allows_more()
|
||||
{
|
||||
var accounts = new[] { Account("adm-kiosk", 1008), Account("adm-4711", 1009) };
|
||||
var admins = new List<LocalGroupMember> { Member("adm-kiosk", 1008), Member("adm-4711", 1009) };
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("adm-*", "adm-kiosk|user"));
|
||||
|
||||
Assert.Equal(new[] { "adm-kiosk" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("adm-4711", result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Membership_unknown_lets_a_standard_user_entry_act_like_a_plain_one()
|
||||
{
|
||||
// Enumeration failed: nobody is known to be an administrator, so nothing is a violation.
|
||||
var result = Evaluate(new[] { Account("WsiAccount", 1001) }, new List<LocalGroupMember>(), Entries(), enumerated: false);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.False(result.AdministratorsGroupEnumerated);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- folders are no accounts
|
||||
|
||||
[Fact]
|
||||
public void An_account_named_like_a_profile_folder_is_unexpected_while_the_folder_is_allowed()
|
||||
{
|
||||
var entries = Entries();
|
||||
|
||||
var result = Evaluate(new[] { Account("Public", 1010) }, new List<LocalGroupMember> { Member("Public", 1010) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "Public" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "Public" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(LocalAdminAnalyzer.AccountDecision.Unlisted,
|
||||
LocalAdminAnalyzer.Decide("Public", MachineSidPrefix + "1010", isAdmin: false, entries));
|
||||
Assert.Contains(entries, e => e.Pattern == "Public" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- accounts WMI did not return
|
||||
|
||||
[Fact]
|
||||
public void Administrators_members_missing_from_wmi_are_judged_as_administrators()
|
||||
{
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
Member("WsiAccount", 1001),
|
||||
Member("Gast", 501),
|
||||
Member("adm-4711", 1009),
|
||||
};
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, Entries("adm-*"));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.RoleViolations);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_member_wmi_already_returned_is_not_judged_twice()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("backdoor", 1011) },
|
||||
new List<LocalGroupMember> { Member("backdoor", 1011) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_login_never_allows_an_account_not_even_when_wmi_failed()
|
||||
{
|
||||
// WMI failed: the split cannot recognise "backdoor" as local and allows it dynamically.
|
||||
// The dynamic entry is folder-only, so the signed-in backdoor administrator is still found.
|
||||
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, null!);
|
||||
var entries = Entries();
|
||||
entries.AddRange(split.Allowed.Select(u => new LocalAdminAnalyzer.AllowedEntry(u, LocalAccountRole.Any, profileFolderOnly: true)));
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), new List<LocalGroupMember> { Member("backdoor", 1011) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- the entry list
|
||||
|
||||
[Fact]
|
||||
public void Entries_carry_the_builtins_and_skip_invalid_tenant_entries()
|
||||
{
|
||||
var ignored = new List<string>();
|
||||
var entries = LocalAdminAnalyzer.BuildAllowedEntries(
|
||||
new[] { "kiosk|user", "x|admin", "|user", "legacy*", "CONTOSO\\admin", " ", "Guest|user" }, ignored);
|
||||
|
||||
Assert.Equal(new[] { "x|admin", "|user" }, ignored);
|
||||
var accounts = entries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList();
|
||||
Assert.Contains("WsiAccount|user", accounts);
|
||||
Assert.Contains("kioskUser0|user", accounts);
|
||||
Assert.Contains("defaultuser0", accounts);
|
||||
Assert.Contains("kiosk|user", accounts);
|
||||
Assert.Contains("legacy*", accounts);
|
||||
Assert.Contains("CONTOSO\\admin", accounts);
|
||||
Assert.Single(accounts, a => a == "Guest|user");
|
||||
Assert.DoesNotContain("Public", accounts);
|
||||
Assert.Contains(entries, e => e.Pattern == "All Users" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- scoring
|
||||
|
||||
[Fact]
|
||||
public void Score_follows_the_signals()
|
||||
{
|
||||
var none = new List<string>();
|
||||
var backdoor = new List<string> { "backdoor" };
|
||||
|
||||
Assert.Equal(0, LocalAdminAnalyzer.ComputeConfidenceScore(false, none, none, backdoor));
|
||||
Assert.Equal(20, LocalAdminAnalyzer.ComputeConfidenceScore(true, none, none, none));
|
||||
Assert.Equal(40, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, backdoor, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, new List<string> { "BACKDOOR" }));
|
||||
Assert.Equal(100, LocalAdminAnalyzer.ComputeConfidenceScore(true, backdoor, backdoor, backdoor));
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -13,6 +14,8 @@ using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
@@ -137,6 +140,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
public void Dispose()
|
||||
{
|
||||
Watcher.Dispose();
|
||||
ReleaseProcessesAndFiles();
|
||||
}
|
||||
|
||||
/// <summary>Teardown that leaves the watcher alone — for a test that found its Dispose blocked.</summary>
|
||||
public void ReleaseProcessesAndFiles()
|
||||
{
|
||||
foreach (var p in Running)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
@@ -210,6 +219,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Equal(new object[] { first.Id, second.Id }, pids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_the_attached_process_is_being_delivered()
|
||||
{
|
||||
var f = new Fixture();
|
||||
var child = f.Spawn(trusted: true);
|
||||
|
||||
// The watcher gets a Process object that already carries a handler. .NET runs that one
|
||||
// first and holds its own lock on the Process object for as long as it delivers Exited.
|
||||
var delivering = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
f.Watcher.ProcessSource = () =>
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
return new[] { handle };
|
||||
};
|
||||
f.Watcher.TryAttachNow();
|
||||
Assert.Equal(child.Id, f.Watcher.AttachedProcessId);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(TimeSpan.FromSeconds(10)), "the exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => f.Watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = dispose.Wait(TimeSpan.FromSeconds(10));
|
||||
// A blocked Dispose holds the watcher's lock for good; disposing the watcher again would
|
||||
// hang the test run instead of failing this test.
|
||||
f.ReleaseProcessesAndFiles();
|
||||
Assert.True(returned,
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_spoof_and_service_both_running_the_service_wins_regardless_of_order()
|
||||
{
|
||||
|
||||
+32
@@ -2,11 +2,14 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on tasks on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
@@ -148,6 +151,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
Assert.Equal(afterDispose, Volatile.Read(ref raises)); // no raises after dispose
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_the_initial_scan_handler_is_running()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
LayDownCoreBinary(tmp.Path); // the initial scan in Start() finds it and raises
|
||||
var watcher = new OfficeBinaryWatcher(tmp.Path, CoreBinaries, NewLogger(tmp.Path));
|
||||
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.BinaryAppeared += (_, __) => { entered.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
|
||||
var start = Task.Run(() => watcher.Start());
|
||||
try
|
||||
{
|
||||
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "initial scan did not raise");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while BinaryAppeared is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
start.Wait(TimeSpan.FromSeconds(10));
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SpinUntil(Func<bool> condition, int timeoutMs)
|
||||
{
|
||||
var deadline = Environment.TickCount + timeoutMs;
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeProcessWatcher"/> with stand-in worker processes handed in through the
|
||||
/// startup probe (the WMI start trace is never armed here, as in the sibling watchers' tests).
|
||||
/// Covers the worker window (started / stopped) and the two ways a worker's exit used to go
|
||||
/// wrong: disposing the watcher while the exit is being delivered, and a worker that was
|
||||
/// already gone when the watcher subscribed.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // Process.Exited is delivered on the shared ThreadPool
|
||||
public sealed class OfficeProcessWatcherTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<Process> _spawned = new List<Process>();
|
||||
|
||||
public OfficeProcessWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var p in _spawned)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
try { p.Dispose(); } catch { }
|
||||
}
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>A stand-in worker that blocks on stdin until killed.</summary>
|
||||
private Process Spawn()
|
||||
{
|
||||
var p = Process.Start(new ProcessStartInfo("cmd.exe", "/c pause")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
Assert.NotNull(p);
|
||||
_spawned.Add(p!);
|
||||
return p!;
|
||||
}
|
||||
|
||||
/// <summary>A second handle on the same process — what the watcher would have opened itself.</summary>
|
||||
private static Process HandleOn(Process child)
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Starts the watcher with <paramref name="handle"/> as the one running worker.</summary>
|
||||
private static void StartWith(OfficeProcessWatcher watcher, Process handle)
|
||||
{
|
||||
watcher.WorkerSource = () => new[] { handle };
|
||||
watcher.Start(armWmi: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_running_worker_is_reported_before_Start_returns_and_its_exit_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var started = new ManualResetEventSlim(false);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Started += (_, __) => started.Set();
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
var child = Spawn();
|
||||
|
||||
StartWith(watcher, HandleOn(child));
|
||||
|
||||
Assert.True(started.IsSet, "Started was not raised by the startup probe");
|
||||
Assert.True(watcher.IsActive);
|
||||
|
||||
child.Kill();
|
||||
|
||||
Assert.True(stopped.Wait(Timeout), "Stopped was not raised after the last worker exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_a_tracked_worker_is_being_delivered()
|
||||
{
|
||||
var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
|
||||
// Subscribed before the watcher, so .NET runs it first — and .NET holds its own lock on
|
||||
// the Process object for as long as it delivers Exited.
|
||||
using var delivering = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(Timeout); };
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(Timeout), "the worker's exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.True(dispose.Wait(Timeout),
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_worker_that_was_already_gone_when_the_watcher_subscribed_still_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
using var exitDelivered = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => exitDelivered.Set();
|
||||
child.Kill();
|
||||
// .NET raises Exited once per Process object. After this, a later subscriber never hears it.
|
||||
Assert.True(exitDelivered.Wait(Timeout), "the stand-in's exit was not delivered");
|
||||
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Assert.True(stopped.Wait(Timeout),
|
||||
"the window never closed: the worker exited before the watcher subscribed to Exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RegistryChangeWatcher"/> against a real key under HKCU (no elevation needed).
|
||||
/// The watcher's lock is a leaf lock: <c>Changed</c> is raised with the lock free, so a
|
||||
/// handler can take other locks or dispose the watcher, and <c>Dispose</c> never waits for a
|
||||
/// handler. One handler runs at a time, and a change that arrives while it runs is reported
|
||||
/// after it returns.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // callbacks arrive on the shared ThreadPool
|
||||
public sealed class RegistryChangeWatcherTests : IDisposable
|
||||
{
|
||||
private const string RootSubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeRegistryChangeWatcher";
|
||||
private const string TargetSubKey = RootSubKey + @"\Target";
|
||||
private const uint RegNotifyThreadAgnostic = 0x10000000;
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public RegistryChangeWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
DeleteTestKeys();
|
||||
using (var root = Registry.CurrentUser.CreateSubKey(RootSubKey)) Assert.NotNull(root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKeys();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKeys()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RootSubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void CreateTarget()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(TargetSubKey)) Assert.NotNull(key);
|
||||
}
|
||||
|
||||
private static void WriteValue(string name)
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(TargetSubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue(name, Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private RegistryChangeWatcher NewWatcher() =>
|
||||
new RegistryChangeWatcher(RegistryHive.CurrentUser, TargetSubKey, _logger);
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_when_a_value_is_written_under_the_target()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire for a value write under the target key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_once_a_missing_target_key_appears()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start(); // target absent: watches the parent for a new child key
|
||||
CreateTarget();
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire when the target key appeared");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_a_handler_is_running()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
Assert.True(entered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while Changed is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_handler_may_dispose_its_own_watcher()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var disposedInHandler = new ManualResetEventSlim(false);
|
||||
int calls = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
Interlocked.Increment(ref calls);
|
||||
watcher.Dispose();
|
||||
disposedInHandler.Set();
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(disposedInHandler.Wait(Timeout), "handler did not return from disposing its watcher");
|
||||
|
||||
WriteValue("Second");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // a disposed watcher stays quiet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handlers_never_overlap_and_a_change_during_a_handler_is_reported_after_it()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var firstEntered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
using var second = new ManualResetEventSlim(false);
|
||||
int calls = 0, running = 0, maxRunning = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref running);
|
||||
int seen;
|
||||
while (now > (seen = Volatile.Read(ref maxRunning)))
|
||||
Interlocked.CompareExchange(ref maxRunning, now, seen);
|
||||
|
||||
if (Interlocked.Increment(ref calls) == 1)
|
||||
{
|
||||
firstEntered.Set();
|
||||
release.Wait(Timeout);
|
||||
}
|
||||
else
|
||||
{
|
||||
second.Set();
|
||||
}
|
||||
Interlocked.Decrement(ref running);
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(firstEntered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
WriteValue("DuringHandler");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // no second handler while the first runs
|
||||
|
||||
release.Set();
|
||||
|
||||
Assert.True(second.Wait(Timeout), "the change that arrived during the handler was never reported");
|
||||
Assert.Equal(1, Volatile.Read(ref maxRunning));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Target_watch_is_not_tied_to_the_arming_thread()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
|
||||
// Re-arming happens on rotating ThreadPool threads. Without this flag Windows signals
|
||||
// the event when such a thread ends, which looks like a change that never happened.
|
||||
Assert.True((watcher.EffectivePrimaryFilter & RegNotifyThreadAgnostic) != 0,
|
||||
"the target watch must carry REG_NOTIFY_THREAD_AGNOSTIC");
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RealmJoinWatcher.Stop"/> while the "RealmJoin key appeared" callback is in
|
||||
/// flight. The callback runs on a real registry watcher over the signed-in user's
|
||||
/// <c>HKU\<sid>\SOFTWARE</c> (writable without elevation); the key probes are injected.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry callbacks arrive on the shared ThreadPool
|
||||
public sealed class RealmJoinWatcherStopTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
// A new direct child of HKCU\SOFTWARE is what the parent watch reacts to.
|
||||
private readonly string _probeKey = @"SOFTWARE\AutopilotMonitor.Tests.RealmJoinStop." + Guid.NewGuid().ToString("N");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(_probeKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_the_key_appeared_callback_is_running()
|
||||
{
|
||||
var sid = WindowsIdentity.GetCurrent().User!.Value;
|
||||
var userRealmJoinPath = sid + "\\" + RealmJoinInfo.UserRealmJoinSubPath;
|
||||
|
||||
var appearedProbe = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
int armed = 0;
|
||||
bool KeyExists(RegistryHive hive, string subPath)
|
||||
{
|
||||
// Everything is "absent" while the watcher arms. Afterwards the user-scope
|
||||
// RealmJoin key "exists": the callback sees it and goes on to switch watchers.
|
||||
if (hive != RegistryHive.Users || subPath != userRealmJoinPath || Volatile.Read(ref armed) == 0)
|
||||
return false;
|
||||
appearedProbe.Set();
|
||||
release.Wait(Timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
var watcher = new RealmJoinWatcher(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
enumeratePackageIds: (_, __) => Array.Empty<string>(),
|
||||
tryReadPackage: (RegistryHive _, string __, string ___, out RealmJoinPackageSnapshot snapshot) =>
|
||||
{
|
||||
snapshot = default!;
|
||||
return false;
|
||||
},
|
||||
keyExists: KeyExists,
|
||||
readVersionInfo: () => default,
|
||||
readUninstallDisplayVersion: () => null,
|
||||
readDeploymentPhase: () => null);
|
||||
|
||||
// Production order: the SID is recorded, then RealmJoin reaches the running phase and
|
||||
// the watcher arms its parent watch on HKU\<sid>\SOFTWARE.
|
||||
watcher.ArmHku(sid);
|
||||
watcher.TriggerNotifyRealmJoinPresenceFromTest(RealmJoinInfo.PhaseRunningThresholdMin);
|
||||
Volatile.Write(ref armed, 1);
|
||||
|
||||
Task stop;
|
||||
try
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(_probeKey)) Assert.NotNull(key);
|
||||
Assert.True(appearedProbe.Wait(Timeout), "the parent watch did not report the new key");
|
||||
|
||||
stop = Task.Run(() => watcher.Stop());
|
||||
Thread.Sleep(300); // let Stop reach the registry watcher whose callback is parked
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = stop.Wait(Timeout);
|
||||
// A blocked Stop holds the watcher's lock for good; disposing it would hang the run.
|
||||
if (returned) watcher.Dispose();
|
||||
Assert.True(returned,
|
||||
"Stop and the key-appeared callback blocked each other: watchers are disposed under the state lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -27,6 +27,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
/// already in flight misses its wake.</item>
|
||||
/// <item>DesktopArrivalHost before AadJoinHost — the AadJoinHost ctor takes the desktop
|
||||
/// host's RequestResetForRealUserSwitch callback, so the target must exist first.</item>
|
||||
/// <item>UserEspKeepAwakeHost before RealmJoinHost — after an agent restart the RealmJoin
|
||||
/// watcher posts realmjoin_detected from its own Start; the keep-awake host hands its
|
||||
/// hold over to RealmJoin (D-413) and must already observe the rail.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class DefaultComponentFactoryOrderingTests : IDisposable
|
||||
@@ -143,6 +146,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keep_awake_host_subscribes_before_the_RealmJoin_watcher_starts()
|
||||
{
|
||||
var hosts = CreateSurfaces(new AnalyzerConfiguration
|
||||
{
|
||||
EnableRealmJoinWatcher = true,
|
||||
KeepAwakeDuringUserEsp = true,
|
||||
}).Hosts;
|
||||
try
|
||||
{
|
||||
AssertBefore<UserEspKeepAwakeHost, RealmJoinHost>(
|
||||
hosts,
|
||||
"D-413 — a realmjoin_detected posted from the watcher's Start (agent restart with " +
|
||||
"RealmJoin installed) must reach the keep-awake host, or it waits for a start it missed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0)]
|
||||
[InlineData(true, 1)]
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a call that never returns is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Stopping the collector hosts is bounded per host: termination reaches the diagnostics
|
||||
/// upload and the cleanup whatever a collector does. A host that hangs is left behind, named
|
||||
/// in one <c>agent_trace</c>, and never called again; every other host is stopped and disposed
|
||||
/// as before.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class EnrollmentOrchestratorCollectorStopTests
|
||||
{
|
||||
private static readonly TimeSpan Bound = TimeSpan.FromMilliseconds(400);
|
||||
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10);
|
||||
|
||||
private sealed class Host : ICollectorHost
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
private readonly ManualResetEventSlim? _hangUntil;
|
||||
private readonly Exception? _throwOnStop;
|
||||
private int _stopCalls;
|
||||
private int _disposeCalls;
|
||||
|
||||
public Host(string name, List<string> order, ManualResetEventSlim? hangUntil = null, Exception? throwOnStop = null)
|
||||
{
|
||||
Name = name;
|
||||
_order = order;
|
||||
_hangUntil = hangUntil;
|
||||
_throwOnStop = throwOnStop;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public int StopCalls => Volatile.Read(ref _stopCalls);
|
||||
public int DisposeCalls => Volatile.Read(ref _disposeCalls);
|
||||
|
||||
public void Start() { }
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Interlocked.Increment(ref _stopCalls);
|
||||
lock (_order) _order.Add(Name);
|
||||
if (_throwOnStop != null) throw _throwOnStop;
|
||||
_hangUntil?.Wait();
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Increment(ref _disposeCalls);
|
||||
}
|
||||
|
||||
private sealed class Factory : IComponentFactory
|
||||
{
|
||||
private readonly IReadOnlyList<ICollectorHost> _hosts;
|
||||
public Factory(IReadOnlyList<ICollectorHost> hosts) { _hosts = hosts; }
|
||||
|
||||
public CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<DecisionState?>? decisionStateProbe = null) => new CollectorSurfaces(_hosts);
|
||||
}
|
||||
|
||||
private static JObject? StopTimeoutEvent(EnrollmentOrchestrator sut) =>
|
||||
sut.SpoolForTests!.Peek(1000)
|
||||
.Where(i => i.Kind == TelemetryItemKind.Event && i.PayloadJson.Contains("collector_host_stop_timeout"))
|
||||
.Select(i => JObject.Parse(i.PayloadJson))
|
||||
.FirstOrDefault();
|
||||
|
||||
[Fact]
|
||||
public void A_host_that_hangs_in_Stop_is_left_behind_and_the_others_are_still_stopped()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
var order = new List<string>();
|
||||
var before = new Host("Before", order);
|
||||
var hanging = new Host("Hanging", order, hangUntil: release);
|
||||
var after = new Host("After", order);
|
||||
var sut = rig.Build(
|
||||
componentFactory: new Factory(new ICollectorHost[] { before, hanging, after }),
|
||||
collectorHostStopTimeout: Bound);
|
||||
sut.Start();
|
||||
|
||||
try
|
||||
{
|
||||
var stop = Task.Run(() => sut.StopCollectorHosts());
|
||||
|
||||
Assert.True(stop.Wait(Wait), "StopCollectorHosts never returned although one host hangs");
|
||||
Assert.Equal(new[] { "Before", "Hanging", "After" }, order.ToArray());
|
||||
|
||||
JObject? evt = null;
|
||||
Assert.True(SpinWait.SpinUntil(() => (evt = StopTimeoutEvent(sut)) != null, Wait),
|
||||
"the hanging host was not reported");
|
||||
Assert.Equal("agent_trace", (string?)evt!["EventType"]);
|
||||
var data = (JObject)evt["Data"]!;
|
||||
Assert.Equal("collector_host_stop_timeout", (string?)data["decision"]);
|
||||
Assert.Equal("Hanging", (string?)data["host"]);
|
||||
Assert.Equal(1, (int)data["timeoutSeconds"]!);
|
||||
|
||||
// Stop() afterwards: the stuck host is not touched again, the others are disposed.
|
||||
var fullStop = Task.Run(() => sut.Stop());
|
||||
Assert.True(fullStop.Wait(Wait), "Stop never returned");
|
||||
Assert.Equal(1, before.DisposeCalls);
|
||||
Assert.Equal(0, hanging.DisposeCalls);
|
||||
Assert.Equal(1, after.DisposeCalls);
|
||||
Assert.Equal(1, hanging.StopCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Healthy_hosts_are_stopped_and_disposed_in_order_without_a_report()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
var order = new List<string>();
|
||||
var first = new Host("First", order);
|
||||
var throwing = new Host("Throwing", order, throwOnStop: new InvalidOperationException("boom"));
|
||||
var last = new Host("Last", order);
|
||||
var hosts = new[] { first, throwing, last };
|
||||
var sut = rig.Build(componentFactory: new Factory(hosts)); // default bound: nothing here comes near it
|
||||
sut.Start();
|
||||
|
||||
sut.StopCollectorHosts();
|
||||
sut.StopCollectorHosts(); // idempotent
|
||||
|
||||
Assert.Equal(new[] { "First", "Throwing", "Last" }, order.ToArray());
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.StopCalls));
|
||||
|
||||
Thread.Sleep(200);
|
||||
Assert.Null(StopTimeoutEvent(sut));
|
||||
|
||||
sut.Stop();
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.DisposeCalls));
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeInstallDetectorHost"/> with its real watchers: the registry watcher on a
|
||||
/// key under HKCU, the binary watcher on a temp folder, snapshots injected. Stopping the host
|
||||
/// must never wait for a watcher callback, and a completion that coincides with a registry
|
||||
/// callback must not leave the host unable to stop.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry + filesystem callbacks arrive on the shared ThreadPool
|
||||
public sealed class OfficeInstallDetectorHostTests : IDisposable
|
||||
{
|
||||
private const string RegistrySubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeInstallDetectorHost";
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly string _installPath;
|
||||
private Func<OfficeC2RSnapshot> _snapshot;
|
||||
|
||||
public OfficeInstallDetectorHostTests()
|
||||
{
|
||||
DeleteTestKey();
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(RegistrySubKey)) Assert.NotNull(key);
|
||||
_installPath = Path.Combine(_tmp.Path, "Office");
|
||||
Directory.CreateDirectory(_installPath);
|
||||
_snapshot = Idle;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKey();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKey()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RegistrySubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void TouchRegistry()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(RegistrySubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue("Probe", Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private OfficeInstallDetectorHost NewHost() =>
|
||||
new OfficeInstallDetectorHost(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
ingress: _sink,
|
||||
clock: new VirtualClock(At),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
settleSeconds: 0,
|
||||
statePersistence: null,
|
||||
resumeState: null,
|
||||
registryHive: RegistryHive.CurrentUser,
|
||||
registrySubKey: RegistrySubKey,
|
||||
snapshotProvider: () => _snapshot());
|
||||
|
||||
// No install scenario, no install path: the detector stays idle and the host keeps
|
||||
// raising OfficeExpected on registry changes.
|
||||
private static OfficeC2RSnapshot Idle() => new OfficeC2RSnapshot { ConfigurationKeyPresent = true };
|
||||
|
||||
private OfficeC2RSnapshot Installing()
|
||||
{
|
||||
var s = new OfficeC2RSnapshot
|
||||
{
|
||||
ConfigurationKeyPresent = true,
|
||||
ActiveScenarioPresent = true,
|
||||
ActiveScenarioName = "INSTALL",
|
||||
InstallScenarioPresent = true,
|
||||
InstallScenarioName = "INSTALL",
|
||||
InstallationPath = _installPath,
|
||||
VersionToReport = "16.0.1.1",
|
||||
};
|
||||
s.Products.Add("O365ProPlusRetail");
|
||||
return s;
|
||||
}
|
||||
|
||||
private int OfficeEvents(string eventType) => _sink.Posted.Count(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType);
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_a_registry_handler_is_running()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_completion_during_a_running_registry_handler_does_not_block_Stop()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
// 1) A registry callback is in flight (parked in the host's OfficeExpected handler).
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
// 2) The install starts and completes meanwhile: the binary watcher sees the core
|
||||
// binary, the detector reports completed, the host tears its watchers down.
|
||||
_snapshot = Installing;
|
||||
host.SubmitDoSample(new OfficeDoSample { JobCount = 1, FileSize = 100, TotalBytesDownloaded = 10 });
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallStarted));
|
||||
|
||||
var versionDir = Path.Combine(_installPath, "root", "Office16");
|
||||
Directory.CreateDirectory(versionDir);
|
||||
File.WriteAllText(Path.Combine(versionDir, "EXCEL.EXE"), "stub");
|
||||
Assert.True(
|
||||
SpinWait.SpinUntil(() => OfficeEvents(Constants.EventTypes.OfficeInstallCompleted) == 1, Timeout),
|
||||
"the install was not reported as completed");
|
||||
|
||||
// 3) The agent stops while that registry callback is still running.
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited behind the completion's teardown, which waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallCompleted));
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>The watchers only listen on the real keys; settings are played in, none is changed.</summary>
|
||||
public sealed class ProxySettingsHostTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _ingress = new FakeSignalIngressSink();
|
||||
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 53, 43, DateTimeKind.Utc));
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly ProxyObserver _observer;
|
||||
private ProxySettings _settings = new ProxySettings(true, null, null, null, null);
|
||||
private string _route = "proxy.example.test:8080";
|
||||
|
||||
public ProxySettingsHostTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_observer = new ProxyObserver(() => _settings, () => _route, _logger, _clock, TimeSpan.FromSeconds(30));
|
||||
_observer.CaptureBaseline();
|
||||
}
|
||||
|
||||
private ProxySettingsHost NewHost() => new ProxySettingsHost(_observer, _ingress, _clock, _logger);
|
||||
|
||||
private void SwitchToNoProxy()
|
||||
{
|
||||
_settings = new ProxySettings(false, null, null, null, null);
|
||||
_route = "direct";
|
||||
}
|
||||
|
||||
private List<FakeSignalIngressSink.PostedSignal> ProxyEvents() =>
|
||||
_ingress.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
&& eventType == Constants.EventTypes.ProxyConfiguration)
|
||||
.ToList();
|
||||
|
||||
private string Log()
|
||||
{
|
||||
var text = string.Empty;
|
||||
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
|
||||
{
|
||||
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
text += reader.ReadToEnd();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void A_change_goes_on_the_timeline_as_a_warning_with_what_moved()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
var signal = Assert.Single(ProxyEvents());
|
||||
Assert.Equal(nameof(EventSeverity.Warning), signal.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("Proxy settings changed: IE settings of this account: auto-detect -> no proxy", signal.Payload[SignalPayloadKeys.Message]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
|
||||
Assert.Equal("changed", data["trigger"]);
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", data["change"]);
|
||||
Assert.Equal("direct", data["route"]);
|
||||
Assert.Equal("Direct", data["proxyType"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_between_the_baseline_and_the_start_is_noticed_at_the_start()
|
||||
{
|
||||
SwitchToNoProxy();
|
||||
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
|
||||
Assert.Single(ProxyEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_change_means_no_event()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
Assert.Empty(ProxyEvents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stopped_ingress_does_not_reach_the_upload_path()
|
||||
{
|
||||
using var host = NewHost();
|
||||
host.Start();
|
||||
_ingress.ThrowOnPost = new InvalidOperationException("SignalIngress has been stopped.");
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("uploads are failing", Log());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_while_uploads_fail_says_so_once()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.Start();
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
host.Stop();
|
||||
host.Stop();
|
||||
host.Dispose();
|
||||
|
||||
var log = Log();
|
||||
var first = log.IndexOf("stopping with uploads still failing", StringComparison.Ordinal);
|
||||
Assert.True(first >= 0);
|
||||
Assert.Equal(-1, log.IndexOf("stopping with uploads still failing", first + 1, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_after_the_stop_no_longer_posts()
|
||||
{
|
||||
var host = NewHost();
|
||||
host.Start();
|
||||
host.Stop();
|
||||
SwitchToNoProxy();
|
||||
|
||||
_observer.CheckForChange();
|
||||
|
||||
Assert.Empty(ProxyEvents());
|
||||
host.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_host_that_never_started_stays_silent_on_stop()
|
||||
{
|
||||
var host = NewHost();
|
||||
_observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
host.Dispose();
|
||||
|
||||
Assert.DoesNotContain("stopping with uploads still failing", Log());
|
||||
}
|
||||
}
|
||||
}
|
||||
+362
-2
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
// ManualResetEventSlim.Wait inside the controller is driven deterministically here; SpinUntil is
|
||||
@@ -52,10 +53,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
|
||||
private AgentLogger NewLogger() => new AgentLogger(_tmp.Path);
|
||||
|
||||
private SignalIngress BuildIngress(VirtualClock clock) =>
|
||||
private SignalIngress BuildIngress(VirtualClock clock, string signalLogName = "signal-log.jsonl") =>
|
||||
new SignalIngress(
|
||||
engine: new DecisionEngine(),
|
||||
signalLog: new SignalLogWriter(_tmp.File("signal-log.jsonl")),
|
||||
signalLog: new SignalLogWriter(_tmp.File(signalLogName)),
|
||||
traceCounter: new SessionTraceOrdinalProvider(),
|
||||
processor: new FakeDecisionStepProcessor(),
|
||||
clock: clock);
|
||||
@@ -300,6 +301,365 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
// -------- RealmJoin hand-over (D-413) --------
|
||||
|
||||
/// <summary>Records keep_awake_released payloads from the rail (posted on ThreadPool threads).</summary>
|
||||
private sealed class ReleaseRecorder
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private readonly List<Dictionary<string, string>> _released = new List<Dictionary<string, string>>();
|
||||
|
||||
public ReleaseRecorder(SignalIngress ing) => ing.SignalPosted += OnPosted;
|
||||
|
||||
private void OnPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
if (kind != DecisionSignalKind.InformationalEvent || payload == null) return;
|
||||
if (!payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
|| eventType != SharedEventTypes.KeepAwakeReleased) return;
|
||||
var copy = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var kv in payload) copy[kv.Key] = kv.Value;
|
||||
lock (_lock) { _released.Add(copy); }
|
||||
}
|
||||
|
||||
public List<Dictionary<string, string>> Snapshot()
|
||||
{
|
||||
lock (_lock) { return new List<Dictionary<string, string>>(_released); }
|
||||
}
|
||||
}
|
||||
|
||||
private static void PostRealmJoin(SignalIngress ing, DecisionSignalKind kind, int? phase)
|
||||
{
|
||||
var payload = new Dictionary<string, string>();
|
||||
if (phase.HasValue) payload[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = phase.Value.ToString();
|
||||
ing.Post(kind, At, "RealmJoinWatcher", Raw("rj"), payload: payload);
|
||||
}
|
||||
|
||||
private UserEspKeepAwakeHost NewHost(
|
||||
SignalIngress ing,
|
||||
VirtualClock clock,
|
||||
KeepAwakeController controller,
|
||||
Func<bool>? progress = null,
|
||||
TimeSpan? safetyCap = null,
|
||||
TimeSpan? realmJoinWait = null,
|
||||
StartupEventGate? gate = null) =>
|
||||
new UserEspKeepAwakeHost(ing, clock, NewLogger(),
|
||||
controller: controller,
|
||||
safetyCapOverride: safetyCap,
|
||||
espTimeoutProvider: () => null,
|
||||
accountSetupProgressProbe: progress ?? (() => true),
|
||||
realmJoinStartWaitOverride: realmJoinWait,
|
||||
startupGate: gate);
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_with_RealmJoin_still_Blank_holds_until_RealmJoin_starts()
|
||||
{
|
||||
// The common RealmJoin shape: detected during the ESP, deployment starts after the
|
||||
// user-ESP page closed. The gap in between is what the hold now covers.
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
|
||||
PostEspExiting(ing);
|
||||
Assert.True(WaitFor(() => host.AwaitingRealmJoinForTest), "the page exit must start the RealmJoin wait");
|
||||
PostEspExiting(ing); // a repeated exit changes nothing
|
||||
Assert.False(WaitFor(() => api.AllowCount > 0, 500), "the hold must outlive the page exit while RealmJoin is Blank");
|
||||
Assert.True(controller.IsEngaged);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(90));
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinPhaseChanged, 101);
|
||||
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1), "RealmJoin leaving Blank must release the hold");
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
var released = recorder.Snapshot()[0];
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinStarted, released["reason"]);
|
||||
Assert.Equal("90", released["waitedSeconds"]);
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_wait_bound_releases_when_RealmJoin_never_starts()
|
||||
{
|
||||
// RealmJoin may never leave Blank in the session — the wait is bounded.
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMilliseconds(150));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
PostEspExiting(ing);
|
||||
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1), "the RealmJoin wait must end the hold");
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
var released = recorder.Snapshot()[0];
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinNotStarted, released["reason"]);
|
||||
Assert.True(released.ContainsKey("waitMinutes"));
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_replaces_the_ESP_safety_cap_with_the_RealmJoin_wait()
|
||||
{
|
||||
// One timer, one bound per phase of the hold: once the page closed, the ESP-sized cap
|
||||
// must not end the RealmJoin wait.
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller,
|
||||
safetyCap: TimeSpan.FromSeconds(1), realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostEspExiting(ing);
|
||||
Assert.True(WaitFor(() => host.AwaitingRealmJoinForTest));
|
||||
|
||||
Assert.False(WaitFor(() => api.AllowCount > 0, 1500), "the superseded ESP cap must not fire");
|
||||
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinPhaseChanged, 101);
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1));
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinStarted, recorder.Snapshot()[0]["reason"]);
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_without_RealmJoin_releases_as_before()
|
||||
{
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostEspExiting(ing);
|
||||
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1));
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonUserEspExited, recorder.Snapshot()[0]["reason"]);
|
||||
Assert.False(host.AwaitingRealmJoinForTest);
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DecisionSignalKind.RealmJoinPhaseChanged, 100)]
|
||||
[InlineData(DecisionSignalKind.RealmJoinPhaseChanged, 210)]
|
||||
[InlineData(DecisionSignalKind.RealmJoinResolved, null)]
|
||||
public void RealmJoin_leaving_Blank_before_the_page_exit_releases_the_hold(DecisionSignalKind kind, int? phase)
|
||||
{
|
||||
// RealmJoin can start shortly before the page closes; it keeps the device awake from
|
||||
// there. Resolved carries no phase and still means "left Blank".
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var recorder = new ReleaseRecorder(ing);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostAccountSetupPhase(ing);
|
||||
Assert.True(WaitFor(() => api.PreventCount == 1));
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
Assert.False(WaitFor(() => api.AllowCount > 0, 300), "detection alone must not release");
|
||||
|
||||
PostRealmJoin(ing, kind, phase);
|
||||
|
||||
Assert.True(WaitFor(() => api.AllowCount == 1));
|
||||
Assert.True(WaitFor(() => recorder.Snapshot().Count == 1));
|
||||
var released = recorder.Snapshot()[0];
|
||||
Assert.Equal(UserEspKeepAwakeHost.ReasonRealmJoinStarted, released["reason"]);
|
||||
Assert.False(released.ContainsKey("waitedSeconds"));
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_already_deploying_before_AccountSetup_prevents_the_engage()
|
||||
{
|
||||
// Agent restart: the watcher reports the running phase from its start, and the
|
||||
// AccountSetup phase is posted again afterwards — there is nothing to hand over.
|
||||
var clock = new VirtualClock(At);
|
||||
using var ing = BuildIngress(clock);
|
||||
var api = new FakeKeepAwakeApi();
|
||||
var controller = new KeepAwakeController(NewLogger(), api);
|
||||
var host = NewHost(ing, clock, controller, realmJoinWait: TimeSpan.FromMinutes(5));
|
||||
|
||||
ing.Start();
|
||||
host.Start();
|
||||
PostRealmJoin(ing, DecisionSignalKind.RealmJoinDetected, 101);
|
||||
PostAccountSetupPhase(ing);
|
||||
|
||||
Assert.False(WaitFor(() => api.PreventCount > 0, 500), "no hold once RealmJoin deploys");
|
||||
Assert.False(controller.IsEngaged);
|
||||
|
||||
host.Dispose();
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
// -------- Page-closed latch across agent restarts (D-414) --------
|
||||
|
||||
/// <summary>
|
||||
/// One agent run: its own ingress and host on the shared state folder. A later run is a new
|
||||
/// <see cref="StartupEventGate"/> loading the same folder, exactly what a restart does.
|
||||
/// </summary>
|
||||
private sealed class AgentRun : IDisposable
|
||||
{
|
||||
public SignalIngress Ingress { get; }
|
||||
public FakeKeepAwakeApi Api { get; } = new FakeKeepAwakeApi();
|
||||
public KeepAwakeController Controller { get; }
|
||||
public StartupEventGate Gate { get; }
|
||||
public UserEspKeepAwakeHost Host { get; }
|
||||
|
||||
public AgentRun(UserEspKeepAwakeHostTests t, string stateDir, string runName, Func<bool> progress)
|
||||
{
|
||||
var clock = new VirtualClock(At);
|
||||
Ingress = t.BuildIngress(clock, $"signal-log-{runName}.jsonl");
|
||||
Controller = new KeepAwakeController(t.NewLogger(), Api);
|
||||
Gate = new StartupEventGate(stateDir, t.NewLogger());
|
||||
Host = t.NewHost(Ingress, clock, Controller, progress: progress,
|
||||
realmJoinWait: TimeSpan.FromMinutes(5), gate: Gate);
|
||||
Ingress.Start();
|
||||
Host.Start();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Host.Dispose();
|
||||
Ingress.Stop();
|
||||
Ingress.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closed_page_keeps_a_later_agent_run_from_engaging()
|
||||
{
|
||||
// After an agent restart the AccountSetup phase reaches the rail again (IME logs it at
|
||||
// the next sign-in, the adapter's dedup lives per process). The page is gone, so no hold.
|
||||
using var stateDir = new TempDirectory();
|
||||
using (var first = new AgentRun(this, stateDir.Path, "1", () => true))
|
||||
{
|
||||
PostAccountSetupPhase(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
|
||||
PostEspExiting(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.AllowCount == 1));
|
||||
Assert.True(WaitFor(() => first.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey)));
|
||||
}
|
||||
|
||||
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
|
||||
PostAccountSetupPhase(second.Ingress);
|
||||
|
||||
Assert.False(WaitFor(() => second.Api.PreventCount > 0, 500), "the page already closed in this enrollment");
|
||||
Assert.False(second.Controller.IsEngaged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Restart_before_the_page_closed_engages_again()
|
||||
{
|
||||
// A reboot in the middle of the user ESP: the page is still to come, so the next run holds.
|
||||
using var stateDir = new TempDirectory();
|
||||
using (var first = new AgentRun(this, stateDir.Path, "1", () => true))
|
||||
{
|
||||
PostAccountSetupPhase(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
|
||||
}
|
||||
|
||||
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
|
||||
PostAccountSetupPhase(second.Ingress);
|
||||
|
||||
Assert.True(WaitFor(() => second.Api.PreventCount == 1), "the user ESP is still running");
|
||||
Assert.True(second.Controller.IsEngaged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handoff_exit_without_AccountSetup_progress_sets_no_latch()
|
||||
{
|
||||
using var stateDir = new TempDirectory();
|
||||
using (var first = new AgentRun(this, stateDir.Path, "1", () => false))
|
||||
{
|
||||
PostAccountSetupPhase(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
|
||||
PostEspExiting(first.Ingress); // the Device→Account handoff
|
||||
Assert.False(WaitFor(() => first.Api.AllowCount > 0, 300));
|
||||
Assert.False(first.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey));
|
||||
}
|
||||
|
||||
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
|
||||
PostAccountSetupPhase(second.Ingress);
|
||||
|
||||
Assert.True(WaitFor(() => second.Api.PreventCount == 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_while_waiting_for_RealmJoin_is_latched()
|
||||
{
|
||||
// The hold outlived the page while RealmJoin was Blank, then the agent restarted: after
|
||||
// the reboot RealmJoin needs the next sign-in anyway, so the next run does not hold.
|
||||
using var stateDir = new TempDirectory();
|
||||
using (var first = new AgentRun(this, stateDir.Path, "1", () => true))
|
||||
{
|
||||
PostRealmJoin(first.Ingress, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
PostAccountSetupPhase(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Api.PreventCount == 1));
|
||||
PostEspExiting(first.Ingress);
|
||||
Assert.True(WaitFor(() => first.Host.AwaitingRealmJoinForTest));
|
||||
Assert.True(WaitFor(() => first.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey)));
|
||||
}
|
||||
|
||||
using var second = new AgentRun(this, stateDir.Path, "2", () => true);
|
||||
PostRealmJoin(second.Ingress, DecisionSignalKind.RealmJoinDetected, 0);
|
||||
PostAccountSetupPhase(second.Ingress);
|
||||
|
||||
Assert.False(WaitFor(() => second.Api.PreventCount > 0, 500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_exit_seen_while_idle_is_latched_and_blocks_a_later_engage()
|
||||
{
|
||||
// The exit counts in every hold state — here the host never engaged before it.
|
||||
using var stateDir = new TempDirectory();
|
||||
using var run = new AgentRun(this, stateDir.Path, "1", () => true);
|
||||
|
||||
PostEspExiting(run.Ingress);
|
||||
Assert.True(WaitFor(() => run.Gate.AlreadySucceeded(UserEspKeepAwakeHost.UserEspPageClosedKey)),
|
||||
"an exit seen while idle must still be latched");
|
||||
|
||||
PostAccountSetupPhase(run.Ingress);
|
||||
Assert.False(WaitFor(() => run.Api.PreventCount > 0, 500));
|
||||
}
|
||||
|
||||
// -------- KeepAwakeController: late-confirm (ready-timeout) edge --------
|
||||
|
||||
[Fact]
|
||||
|
||||
+4
-4
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression coverage for the IsHybridJoin detection that runs at session-registration
|
||||
/// time. Field evidence (session c4c8d206-…, Lenovo ThinkPad T14 Gen 5, Win11 23H2
|
||||
/// time. Field evidence (Lenovo ThinkPad T14 Gen 5, Win11 23H2
|
||||
/// 22631.4317): the AutopilotPolicyCache key on this device exposes
|
||||
/// <c>CloudAssignedDomainJoinMethod</c> only inside the embedded <c>PolicyJsonCache</c>
|
||||
/// blob, not as a top-level registry value. Without the JSON fallback the agent registered
|
||||
@@ -47,11 +47,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
[Fact]
|
||||
public void Missing_TopLevel_falls_back_to_PolicyJsonCache_one_returns_true()
|
||||
{
|
||||
// Real-world payload from the Bayer Lenovo T14 Gen 5 / Win11 23H2 22631.4317
|
||||
// device that triggered this fix. Truncated for readability — the only relevant
|
||||
// Shape of a real PolicyJsonCache payload (Win11 23H2 22631.4317) with a
|
||||
// placeholder tenant domain. Truncated for readability — the only relevant
|
||||
// key is CloudAssignedDomainJoinMethod=1.
|
||||
const string policyJson =
|
||||
"{\r\n \"CloudAssignedTenantDomain\": \"bayergroup.onmicrosoft.com\",\r\n" +
|
||||
"{\r\n \"CloudAssignedTenantDomain\": \"contoso.onmicrosoft.com\",\r\n" +
|
||||
" \"CloudAssignedDomainJoinMethod\": 1,\r\n" +
|
||||
" \"AutopilotMode\": 2\r\n}";
|
||||
|
||||
|
||||
+105
@@ -600,6 +600,111 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.DoesNotContain(ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted);
|
||||
}
|
||||
|
||||
// ---- Detected carries the current phase (service-key attach) -------------------------
|
||||
|
||||
private sealed class PhaseHarness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||
public int? Phase;
|
||||
public RealmJoinWatcher Watcher { get; }
|
||||
public RealmJoinWatcherAdapter Adapter { get; }
|
||||
|
||||
public PhaseHarness()
|
||||
{
|
||||
var registry = new FakeRealmJoinRegistry();
|
||||
Watcher = new RealmJoinWatcher(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
enumeratePackageIds: registry.Enumerate,
|
||||
tryReadPackage: registry.TryRead,
|
||||
keyExists: registry.KeyExists,
|
||||
readVersionInfo: () => RealmJoinVersionInfo.Empty,
|
||||
readUninstallDisplayVersion: () => null,
|
||||
readDeploymentPhase: () => Phase);
|
||||
Adapter = new RealmJoinWatcherAdapter(Watcher, Ingress, new VirtualClock(Fixed));
|
||||
}
|
||||
|
||||
public string DetectedPhase() =>
|
||||
Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinDetected)
|
||||
.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Adapter.Dispose();
|
||||
Watcher.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(101)]
|
||||
[InlineData(200)]
|
||||
public void Detected_carries_the_phase_RealmJoin_is_in_when_the_service_key_attaches(int phase)
|
||||
{
|
||||
// Agent restarted while RJ deploys. Detected used to say Blank, which reset the
|
||||
// engine's persisted LastDeploymentPhase and hid from the keep-awake host that RJ had
|
||||
// started — no PhaseChanged follows while the phase stays put.
|
||||
using var h = new PhaseHarness { Phase = phase };
|
||||
|
||||
h.Watcher.TriggerServiceStageAttachedFromTest();
|
||||
|
||||
Assert.Equal(phase.ToString(), h.DetectedPhase());
|
||||
Assert.True(h.Watcher.PackageWatchersArmedForTest);
|
||||
Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detected_with_a_completed_phase_resolves_in_the_same_pass()
|
||||
{
|
||||
using var h = new PhaseHarness { Phase = RealmJoinInfo.PhaseCompletedFirstDeployment };
|
||||
|
||||
h.Watcher.TriggerServiceStageAttachedFromTest();
|
||||
|
||||
Assert.Equal("110", h.DetectedPhase());
|
||||
var kinds = h.Ingress.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.RealmJoinDetected || p.Kind == DecisionSignalKind.RealmJoinResolved)
|
||||
.Select(p => p.Kind)
|
||||
.ToList();
|
||||
Assert.Equal(new[] { DecisionSignalKind.RealmJoinDetected, DecisionSignalKind.RealmJoinResolved }, kinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Phase_not_written_at_attach_reports_Blank_and_the_first_value_surfaces_as_PhaseChanged()
|
||||
{
|
||||
// Detected says Blank while Parameters\DeploymentPhase is missing; that report is the
|
||||
// baseline, so a first value of 101 must not be swallowed silently.
|
||||
using var h = new PhaseHarness { Phase = null };
|
||||
|
||||
h.Watcher.TriggerServiceStageAttachedFromTest();
|
||||
Assert.Equal("0", h.DetectedPhase());
|
||||
Assert.False(h.Watcher.PackageWatchersArmedForTest);
|
||||
|
||||
h.Phase = 101;
|
||||
h.Watcher.TriggerCheckParametersFromTest();
|
||||
|
||||
var changed = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
|
||||
Assert.Equal("101", changed.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]);
|
||||
Assert.Equal("0", changed.Payload[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase]);
|
||||
Assert.True(h.Watcher.PackageWatchersArmedForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_at_attach_then_running_fires_one_PhaseChanged()
|
||||
{
|
||||
// The common first-run shape (RJ installed during the ESP, deployment starts after
|
||||
// the user-ESP page closed): Blank on attach, 0 -> 101 later.
|
||||
using var h = new PhaseHarness { Phase = 0 };
|
||||
|
||||
h.Watcher.TriggerServiceStageAttachedFromTest();
|
||||
h.Watcher.TriggerCheckParametersFromTest(); // same value again — no change
|
||||
h.Phase = 101;
|
||||
h.Watcher.TriggerCheckParametersFromTest();
|
||||
|
||||
Assert.Equal("0", h.DetectedPhase());
|
||||
var changed = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPhaseChanged);
|
||||
Assert.Equal("0", changed.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousPhase]);
|
||||
}
|
||||
|
||||
|
||||
// ---- auto-update (Uninstall\RealmJoin) -----------------------------------------------
|
||||
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression coverage for the duplicate <c>performance_snapshot</c> events observed on
|
||||
/// session c4c8d206-… (Bayer / Lenovo T14 Gen 5): two snapshots fired with timestamps
|
||||
/// Regression coverage for duplicate <c>performance_snapshot</c> events observed in the
|
||||
/// field (Lenovo T14 Gen 5): two snapshots fired with timestamps
|
||||
/// 0–1ms apart, the second one carrying a fresh-baseline net delta of 0. Root cause:
|
||||
/// <see cref="System.Threading.Timer"/> is documented to dispatch callbacks on different
|
||||
/// ThreadPool threads when a callback exceeds the period, and the
|
||||
|
||||
+130
@@ -6,6 +6,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -35,6 +36,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
requiresImmediateFlush: false,
|
||||
enqueuedAtUtc: At);
|
||||
|
||||
private static ProxyObserver NewProxyObserver(AgentLogger logger) =>
|
||||
new ProxyObserver(
|
||||
() => new ProxySettings(false, null, null, null, null),
|
||||
() => "direct",
|
||||
logger,
|
||||
new VirtualClock(At),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
private static string ReadLogs(string path)
|
||||
{
|
||||
// AgentLogger writes to a dated file; scan the whole dir to stay robust to format.
|
||||
@@ -235,5 +244,126 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.DoesNotContain("flushing", logContent);
|
||||
Assert.DoesNotContain("ingest", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Network_failure_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("ingest NETWORK fail after", logContent);
|
||||
Assert.Contains("items=1, route=direct): ", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new TaskCanceledException("A task was canceled."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("ingest TIMEOUT after", logContent);
|
||||
Assert.Contains("items=1, route=direct): ", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interception_names_the_route_and_opens_a_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
var result = await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
Assert.True(result.IsIntercepted);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains(", route=direct).", logContent);
|
||||
Assert.Contains("ProxyObserver: uploads are failing since", logContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_successful_upload_ends_the_series()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
handler.QueueStatus(HttpStatusCode.OK);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.Contains("ProxyObserver: uploads work again after 1 failed attempt(s)", ReadLogs(tmp.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_answer_from_the_backend_that_is_not_a_success_leaves_the_series_alone()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueStatus(HttpStatusCode.InternalServerError);
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger, proxy: NewProxyObserver(logger));
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.DoesNotContain("ProxyObserver: uploads", ReadLogs(tmp.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_an_observer_the_failure_line_stays_as_it_was()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var handler = new RecordingHttpMessageHandler();
|
||||
handler.QueueThrow(new HttpRequestException("An error occurred while sending the request."));
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
var sut = new BackendTelemetryUploader(
|
||||
httpClient: http, baseUrl: "https://backend.test", tenantId: "T1", logger: logger);
|
||||
|
||||
await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
var logContent = ReadLogs(tmp.Path);
|
||||
Assert.Contains("items=1): ", logContent);
|
||||
Assert.DoesNotContain("route=", logContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>Settings and route are played in: no test changes a real proxy setting.</summary>
|
||||
public sealed class ProxyObserverTests : IDisposable
|
||||
{
|
||||
private static readonly ProxySettings AutoDetect = new ProxySettings(true, null, null, null, null);
|
||||
private static readonly ProxySettings NoProxy = new ProxySettings(false, null, null, null, null);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly VirtualClock _clock = new VirtualClock(new DateTime(2026, 11, 2, 6, 29, 13, DateTimeKind.Utc));
|
||||
private ProxySettings _settings = AutoDetect;
|
||||
private string _route = "proxy.example.test:8080";
|
||||
|
||||
private ProxyObserver NewObserver(Func<string>? readRoute = null, TimeSpan? routeBound = null, Func<ProxySettings>? readSettings = null) =>
|
||||
new ProxyObserver(
|
||||
readSettings ?? (() => _settings),
|
||||
readRoute ?? (() => _route),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
_clock,
|
||||
routeBound ?? TimeSpan.FromSeconds(30));
|
||||
|
||||
private ProxyObserver StartedObserver()
|
||||
{
|
||||
var observer = NewObserver();
|
||||
observer.CaptureBaseline();
|
||||
return observer;
|
||||
}
|
||||
|
||||
private void SwitchToNoProxy()
|
||||
{
|
||||
_settings = NoProxy;
|
||||
_route = "direct";
|
||||
}
|
||||
|
||||
private List<string> LogLines()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var file in Directory.EnumerateFiles(_tmp.Path, "*.log", SearchOption.AllDirectories))
|
||||
{
|
||||
using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream);
|
||||
lines.AddRange(reader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
return lines.Where(l => l.Contains("ProxyObserver:")).ToList();
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
[Fact]
|
||||
public void The_baseline_names_settings_and_route_at_info()
|
||||
{
|
||||
StartedObserver();
|
||||
|
||||
var line = Assert.Single(LogLines());
|
||||
Assert.Contains("[INFO]", line);
|
||||
Assert.Contains("start - IE settings of this account: auto-detect; WinHTTP: direct; route to backend: proxy.example.test:8080", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rebuild_of_the_clients_does_not_repeat_the_baseline()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
foreach (var _ in new[] { 1, 2 })
|
||||
{
|
||||
new ProxyObserver(() => _settings, () => _route, logger, _clock, TimeSpan.FromSeconds(30)).CaptureBaseline();
|
||||
}
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_warns_once_with_both_sides()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
observer.CheckForChange();
|
||||
|
||||
var line = Assert.Single(LogLines().Skip(1));
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy; route to backend: proxy.example.test:8080 -> direct.", line);
|
||||
Assert.Contains("bitsadmin /util /getieproxy localsystem", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rewriting_the_same_content_says_nothing()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, null);
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_winhttp_change_says_uploads_do_not_use_it()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, "p:8080");
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("WinHTTP: direct -> proxy p:8080", line);
|
||||
Assert.Contains("do not use WinHTTP", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_winhttp_change_is_not_blamed_for_failing_uploads()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = new ProxySettings(true, null, null, null, "p:8080");
|
||||
observer.CheckForChange();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_reaches_the_subscriber_with_settings_and_route()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
ProxySettings? seen = null;
|
||||
string? seenWhat = null;
|
||||
string? seenRoute = null;
|
||||
observer.SettingsChanged += (settings, what, route) =>
|
||||
{
|
||||
seen = settings;
|
||||
seenWhat = what;
|
||||
seenRoute = route;
|
||||
};
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Equal(NoProxy, seen);
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", seenWhat);
|
||||
Assert.Equal("direct", seenRoute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_subscriber_that_throws_does_not_reach_the_caller()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.SettingsChanged += (_, _, _) => throw new InvalidOperationException("SignalIngress has been stopped.");
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Contains(LogLines(), l => l.Contains("proxy settings changed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_failure_ties_the_series_to_the_change()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_clock.Advance(TimeSpan.FromMinutes(23));
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromSeconds(90));
|
||||
SwitchToNoProxy();
|
||||
observer.CheckForChange();
|
||||
_clock.Advance(TimeSpan.FromSeconds(62));
|
||||
|
||||
observer.OnSendFailed(TimeSpan.FromSeconds(21));
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("uploads are failing since 06:54:24, 41 s after the proxy settings changed at 06:53:43 (IE settings of this account: auto-detect -> no proxy).", line);
|
||||
Assert.Contains("Route to backend: direct. Last successful upload 06:52:13.", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_notices_a_change_the_watcher_did_not_report()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
var lines = LogLines();
|
||||
Assert.Contains(lines, l => l.Contains("proxy settings changed - IE settings of this account: auto-detect -> no proxy"));
|
||||
Assert.Contains("after the proxy settings changed", lines.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_series_writes_its_finding_once()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Single(LogLines(), l => l.Contains("uploads are failing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_without_a_change_says_the_settings_are_unchanged()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", line);
|
||||
Assert.Contains("Route to backend: proxy.example.test:8080. No successful upload yet.", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_before_the_last_success_is_not_blamed()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
observer.CheckForChange();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.Contains("Proxy settings of this account unchanged since the last successful upload: no proxy.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovery_is_noted_and_a_new_series_gets_its_own_finding()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
_clock.Advance(TimeSpan.FromMinutes(9));
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[INFO]", line);
|
||||
Assert.Contains("uploads work again after 2 failed attempt(s) over 9 min; route to backend: proxy.example.test:8080.", line);
|
||||
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
Assert.Equal(2, LogLines().Count(l => l.Contains("uploads are failing")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Recovery_reports_settings_that_came_back()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
SwitchToNoProxy();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
_settings = AutoDetect;
|
||||
_route = "proxy.example.test:8080";
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
Assert.Contains(LogLines(), l => l.Contains("no proxy -> auto-detect"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_success_without_a_series_stays_silent()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
|
||||
observer.OnSendSucceeded();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_while_failing_sums_it_up()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendSucceeded();
|
||||
_clock.Advance(TimeSpan.FromMinutes(1));
|
||||
SwitchToNoProxy();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
observer.LogOpenFailures();
|
||||
|
||||
var line = LogLines().Last();
|
||||
Assert.Contains("[WARN]", line);
|
||||
Assert.Contains("stopping with uploads still failing: 2 failed attempt(s) since 06:30:13, last success 06:29:13, route to backend: direct.", line);
|
||||
Assert.Contains("Proxy settings changed at 06:30:13 (IE settings of this account: auto-detect -> no proxy).", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stopping_without_a_series_stays_silent()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
observer.OnSendSucceeded();
|
||||
var before = LogLines().Count;
|
||||
|
||||
observer.LogOpenFailures();
|
||||
|
||||
Assert.Equal(before, LogLines().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_settings_read_that_throws_is_not_a_change_and_never_reaches_the_upload()
|
||||
{
|
||||
var throwing = false;
|
||||
var observer = NewObserver(readSettings: () => throwing ? throw new InvalidOperationException("boom") : _settings);
|
||||
observer.CaptureBaseline();
|
||||
throwing = true;
|
||||
|
||||
observer.CheckForChange();
|
||||
observer.OnSendFailed(TimeSpan.Zero);
|
||||
|
||||
Assert.DoesNotContain(LogLines(), l => l.Contains("proxy settings changed"));
|
||||
Assert.Contains("Proxy settings of this account unchanged since start: auto-detect.", LogLines().Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_state_is_not_a_change()
|
||||
{
|
||||
var observer = StartedObserver();
|
||||
_settings = ProxySettings.Unreadable(5);
|
||||
|
||||
observer.CheckForChange();
|
||||
|
||||
Assert.Single(LogLines());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_route_read_that_throws_reads_as_unknown()
|
||||
{
|
||||
var observer = NewObserver(readRoute: () => throw new InvalidOperationException("boom"));
|
||||
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hanging_route_read_gives_up_and_does_not_pile_up()
|
||||
{
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
var reads = 0;
|
||||
var observer = NewObserver(
|
||||
readRoute: () =>
|
||||
{
|
||||
Interlocked.Increment(ref reads);
|
||||
release.Wait();
|
||||
return "direct";
|
||||
},
|
||||
routeBound: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
Assert.Equal("unknown", observer.RouteText());
|
||||
Assert.Equal(1, Volatile.Read(ref reads));
|
||||
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
public sealed class ProxySettingsTests
|
||||
{
|
||||
private static ProxySettings Settings(
|
||||
bool autoDetect = false, string? pac = null, string? proxy = null, string? bypass = null, string? winHttp = null) =>
|
||||
new ProxySettings(autoDetect, pac, proxy, bypass, winHttp);
|
||||
|
||||
[Fact]
|
||||
public void Each_kind_of_setting_describes_itself()
|
||||
{
|
||||
Assert.Equal("no proxy", Settings().Describe());
|
||||
Assert.Equal("auto-detect", Settings(autoDetect: true).Describe());
|
||||
Assert.Equal("PAC https://pac.example.test/proxy.pac", Settings(pac: "https://pac.example.test/proxy.pac").Describe());
|
||||
Assert.Equal("proxy proxy.example.test:8080", Settings(proxy: "proxy.example.test:8080").Describe());
|
||||
Assert.Equal("auto-detect, proxy p:8080", Settings(autoDetect: true, proxy: "p:8080").Describe());
|
||||
Assert.Equal("direct", Settings().DescribeWinHttp());
|
||||
Assert.Equal("proxy p:8080", Settings(winHttp: "p:8080").DescribeWinHttp());
|
||||
Assert.Equal("unreadable (Win32 error 5)", ProxySettings.Unreadable(5).Describe());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, null, null, "Direct")]
|
||||
[InlineData(true, null, null, "AutoDetect")]
|
||||
[InlineData(true, "https://pac.example.test/p.pac", null, "PAC")]
|
||||
[InlineData(true, "https://pac.example.test/p.pac", "p:8080", "Proxy")]
|
||||
public void Type_keeps_the_values_the_download_rule_matches(bool autoDetect, string? pac, string? proxy, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Settings(autoDetect, pac, proxy).ProxyType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_state_has_its_own_type()
|
||||
{
|
||||
Assert.Equal("Unknown", ProxySettings.Unreadable(5).ProxyType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equal_content_is_equal_and_any_field_makes_it_different()
|
||||
{
|
||||
Assert.Equal(Settings(autoDetect: true), Settings(autoDetect: true));
|
||||
Assert.NotEqual(Settings(autoDetect: true), Settings());
|
||||
Assert.NotEqual(Settings(pac: "https://a.test/p"), Settings(pac: "https://b.test/p"));
|
||||
Assert.NotEqual(Settings(proxy: "a:1"), Settings(proxy: "b:1"));
|
||||
Assert.NotEqual(Settings(proxy: "a:1", bypass: "x"), Settings(proxy: "a:1", bypass: "y"));
|
||||
Assert.NotEqual(Settings(winHttp: "a:1"), Settings());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_string_counts_as_not_set()
|
||||
{
|
||||
Assert.Equal(Settings(), Settings(pac: "", proxy: "", bypass: "", winHttp: ""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("user:secret@p:8080")]
|
||||
[InlineData("http://user:secret@p:8080")]
|
||||
[InlineData("http=user:secret@p:8080;https=user:secret@q:8443")]
|
||||
[InlineData("user:se=cret@p:8080")]
|
||||
[InlineData("user:se;cret@p:8080")]
|
||||
[InlineData("http=user:se;cret@p:8080")]
|
||||
[InlineData("user:se://cret@p:8080")]
|
||||
public void Credentials_never_reach_logs_or_events(string configured)
|
||||
{
|
||||
var settings = Settings(proxy: configured, winHttp: configured);
|
||||
var data = settings.ToEventData();
|
||||
|
||||
foreach (var text in new[] { settings.DescribeAll(), (string)data["proxyServer"], (string)data["winHttpProxy"] })
|
||||
{
|
||||
Assert.DoesNotContain("user", text);
|
||||
Assert.DoesNotContain("cret", text);
|
||||
}
|
||||
|
||||
Assert.Equal(ProxySettings.CredentialsHidden, settings.Proxy);
|
||||
Assert.Equal(ProxySettings.CredentialsHidden, settings.WinHttpProxy);
|
||||
Assert.True((bool)data["proxyEnabled"]);
|
||||
Assert.True((bool)data["winHttpProxyConfigured"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("p:8080")]
|
||||
[InlineData("http://p:8080")]
|
||||
[InlineData("http=p:8080;https=q:8443")]
|
||||
public void A_value_without_credentials_stays_as_it_is(string configured)
|
||||
{
|
||||
Assert.Equal(configured, Settings(proxy: configured).Proxy);
|
||||
Assert.Equal(configured, Settings(winHttp: configured).WinHttpProxy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://pac.example.test/p.pac?key=secret", "https://pac.example.test/p.pac")]
|
||||
[InlineData("http://user:secret@pac.example.test:8080/p.pac", "http://pac.example.test:8080/p.pac")]
|
||||
[InlineData("http://user:se=cr;et@pac.example.test/p.pac", "http://pac.example.test/p.pac")]
|
||||
[InlineData("not a url with secret", "(unparsable URL)")]
|
||||
public void A_pac_url_keeps_scheme_host_port_and_path(string configured, string expected)
|
||||
{
|
||||
Assert.Equal(expected, Settings(pac: configured).PacUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_change_names_what_moved()
|
||||
{
|
||||
var before = Settings(autoDetect: true);
|
||||
|
||||
Assert.Equal("IE settings of this account: auto-detect -> no proxy", Settings().DescribeChangeFrom(before));
|
||||
Assert.Equal("WinHTTP: direct -> proxy p:1", Settings(autoDetect: true, winHttp: "p:1").DescribeChangeFrom(before));
|
||||
Assert.Equal(
|
||||
"IE settings of this account: auto-detect -> no proxy; WinHTTP: direct -> proxy p:1",
|
||||
Settings(winHttp: "p:1").DescribeChangeFrom(before));
|
||||
Assert.Equal(
|
||||
"IE settings of this account: proxy bypass list changed",
|
||||
Settings(proxy: "p:1", bypass: "b").DescribeChangeFrom(Settings(proxy: "p:1", bypass: "a")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_account_settings_count_as_a_change_for_uploads()
|
||||
{
|
||||
var before = Settings(autoDetect: true);
|
||||
|
||||
Assert.True(Settings().DiffersForUploadsFrom(before));
|
||||
Assert.False(Settings(autoDetect: true, winHttp: "p:1").DiffersForUploadsFrom(before));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_tells_auto_detect_from_direct()
|
||||
{
|
||||
var data = Settings(autoDetect: true).ToEventData();
|
||||
|
||||
Assert.Equal("AutoDetect", data["proxyType"]);
|
||||
Assert.True((bool)data["autoDetect"]);
|
||||
Assert.False((bool)data["proxyEnabled"]);
|
||||
Assert.Null(data["proxyServer"]);
|
||||
Assert.False((bool)data["winHttpProxyConfigured"]);
|
||||
Assert.Null(data["winHttpProxy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_carries_a_real_winhttp_proxy()
|
||||
{
|
||||
var data = Settings(winHttp: "p:8080").ToEventData();
|
||||
|
||||
Assert.True((bool)data["winHttpProxyConfigured"]);
|
||||
Assert.Equal("p:8080", data["winHttpProxy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_real_settings_of_this_account_can_be_read()
|
||||
{
|
||||
var settings = ProxySettingsReader.Read();
|
||||
|
||||
Assert.False(settings.IsUnreadable, settings.Describe());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_real_route_can_be_read()
|
||||
{
|
||||
var read = Task.Run(() => ProxySettingsReader.ReadRoute(new Uri("https://backend.example.invalid/")));
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(await read));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@
|
||||
<Import Project="V2Isolation.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+11
-6
@@ -200,6 +200,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Process attached;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -208,12 +209,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_discoveryTimer?.Dispose();
|
||||
_discoveryTimer = null;
|
||||
|
||||
if (_imeProcess != null)
|
||||
{
|
||||
try { _imeProcess.Exited -= OnImeExited; } catch { }
|
||||
_imeProcess.Dispose();
|
||||
_imeProcess = null;
|
||||
}
|
||||
attached = _imeProcess;
|
||||
_imeProcess = null;
|
||||
}
|
||||
|
||||
// Not under the lock: .NET holds its own lock on the Process while it delivers Exited,
|
||||
// OnImeExited takes _lock, and Process.Dispose waits for that delivery to finish.
|
||||
if (attached != null)
|
||||
{
|
||||
try { attached.Exited -= OnImeExited; } catch { }
|
||||
try { attached.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-29
@@ -114,6 +114,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// fire once; <see cref="RealmJoinPackageStarted"/> / <see cref="RealmJoinPackageCompleted"/>
|
||||
/// fire once per <c>(scope, packageId)</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="RealmJoinDetected"/> carries the phase RJ is in when the service key is first
|
||||
/// seen in this process — Blank (0) only while the value is not written yet. After an agent
|
||||
/// restart RJ may already be deploying: the engine's restart path and the keep-awake
|
||||
/// hand-over to RJ both read the phase from Detected, because no PhaseChanged follows when
|
||||
/// the phase does not change again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RealmJoinWatcher : IDisposable
|
||||
{
|
||||
@@ -127,6 +134,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal delegate bool KeyExistsDelegate(RegistryHive hive, string subPath);
|
||||
internal delegate RealmJoinVersionInfo ReadVersionInfoDelegate();
|
||||
internal delegate string? ReadUninstallDisplayVersionDelegate();
|
||||
internal delegate int? ReadDeploymentPhaseDelegate();
|
||||
|
||||
/// <summary>
|
||||
/// Debounce for the Uninstall-key watcher. Longer than <see cref="DebounceMilliseconds"/>:
|
||||
@@ -141,6 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly KeyExistsDelegate _keyExists;
|
||||
private readonly ReadVersionInfoDelegate _readVersionInfo;
|
||||
private readonly ReadUninstallDisplayVersionDelegate _readUninstallDisplayVersion;
|
||||
private readonly ReadDeploymentPhaseDelegate _readDeploymentPhase;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
// Auto-update stage (armed once Detected fired, held until Stop).
|
||||
@@ -185,6 +194,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// subtree) into RJ's lifecycle.
|
||||
private bool _packageWatchersArmed;
|
||||
private bool _disposed;
|
||||
// Set by Stop under _stateLock: a registry callback that is still running then must not
|
||||
// arm or attach another watcher.
|
||||
private bool _stopped;
|
||||
|
||||
public event EventHandler<RealmJoinDetectedEventArgs>? RealmJoinDetected;
|
||||
public event EventHandler<RealmJoinResolvedEventArgs>? RealmJoinResolved;
|
||||
@@ -204,7 +216,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
TryReadPackageDelegate tryReadPackage,
|
||||
KeyExistsDelegate keyExists,
|
||||
ReadVersionInfoDelegate? readVersionInfo = null,
|
||||
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null)
|
||||
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null,
|
||||
ReadDeploymentPhaseDelegate? readDeploymentPhase = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_enumeratePackageIds = enumeratePackageIds ?? throw new ArgumentNullException(nameof(enumeratePackageIds));
|
||||
@@ -212,11 +225,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_keyExists = keyExists ?? throw new ArgumentNullException(nameof(keyExists));
|
||||
_readVersionInfo = readVersionInfo ?? RealmJoinInfo.TryReadRealmJoinVersionInfo;
|
||||
_readUninstallDisplayVersion = readUninstallDisplayVersion ?? RealmJoinInfo.TryReadUninstallDisplayVersion;
|
||||
_readDeploymentPhase = readDeploymentPhase ?? (() => RealmJoinInfo.TryReadDeploymentPhase(RegistryHive.LocalMachine));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(RealmJoinWatcher));
|
||||
lock (_stateLock) { _stopped = false; }
|
||||
|
||||
// The Services\realmjoin gate is the SOLE entry point. HKLM\SOFTWARE\RealmJoin and
|
||||
// the HKU watcher do not run until this fires — RJ presence is the prerequisite for
|
||||
@@ -266,27 +281,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var stoppedAny = false;
|
||||
var watchers = new List<RegistryWatcher>(8);
|
||||
lock (_stateLock)
|
||||
{
|
||||
stoppedAny |= DisposeAndNull(ref _servicesAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallAppearanceWatcher);
|
||||
_stopped = true;
|
||||
|
||||
Take(ref _servicesAppearanceWatcher, watchers);
|
||||
Take(ref _machineSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _userSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _uninstallAppearanceWatcher, watchers);
|
||||
|
||||
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
|
||||
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
|
||||
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
|
||||
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
|
||||
|
||||
stoppedAny |= DisposeAndNull(ref _serviceRealmjoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallWatcher);
|
||||
Take(ref _serviceRealmjoinWatcher, watchers);
|
||||
Take(ref _machineRealmJoinWatcher, watchers);
|
||||
Take(ref _userRealmJoinWatcher, watchers);
|
||||
Take(ref _uninstallWatcher, watchers);
|
||||
}
|
||||
|
||||
// Disposed outside the lock: RegistryWatcher.Dispose waits for a callback that is
|
||||
// still running, and the appearance callback takes _stateLock.
|
||||
foreach (var watcher in watchers)
|
||||
{
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
// Hosts call Stop and then Dispose, which stops again; only the pass that
|
||||
// stopped a watcher logs.
|
||||
if (stoppedAny) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
if (watchers.Count > 0) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -380,6 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Action clearSlot,
|
||||
Action onAppeared)
|
||||
{
|
||||
lock (_stateLock) { if (_stopped) return; }
|
||||
try
|
||||
{
|
||||
var watcher = new RegistryWatcher(
|
||||
@@ -429,7 +455,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_serviceRealmjoinWatcher != null) return;
|
||||
if (_stopped || _serviceRealmjoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_serviceRealmjoinDebounce = new Timer(_ => CheckParameters(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -452,21 +478,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
// The appearance of Services\realmjoin is itself the RJ-detected signal —
|
||||
// surface it even if Parameters\DeploymentPhase isn't populated yet. The package
|
||||
// watchers (HKLM + HKU SOFTWARE\RealmJoin) intentionally stay disarmed here:
|
||||
// NotifyRealmJoinPresence arms them once DeploymentPhase >= PhaseRunningThresholdMin
|
||||
// is observed. Before that the same subtree may still receive in-flight IME
|
||||
// package writes that are not part of RJ's lifecycle.
|
||||
NotifyRealmJoinPresence(phase: null);
|
||||
CheckParameters();
|
||||
NotifyServiceStageAttached();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The appearance of Services\realmjoin is itself the RJ-detected signal — surfaced even
|
||||
/// if Parameters\DeploymentPhase isn't populated yet. One read feeds Detected, so it
|
||||
/// carries the phase RJ is in right now (see the class remarks). The package watchers
|
||||
/// (HKLM + HKU SOFTWARE\RealmJoin) stay disarmed below PhaseRunningThresholdMin: before
|
||||
/// that the same subtree may still receive in-flight IME package writes that are not
|
||||
/// part of RJ's lifecycle.
|
||||
/// </summary>
|
||||
private void NotifyServiceStageAttached()
|
||||
{
|
||||
int? phase = null;
|
||||
try { phase = _readDeploymentPhase(); }
|
||||
catch (Exception ex) { _logger.Warning($"RealmJoinWatcher: DeploymentPhase read at attach threw: {ex.Message}"); }
|
||||
NotifyRealmJoinPresence(phase);
|
||||
}
|
||||
|
||||
private void AttachMachineRealmJoinSubtreeWatcher()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_machineRealmJoinWatcher != null) return;
|
||||
if (_stopped || _machineRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets with every package sub-key already present at arming time.
|
||||
@@ -509,7 +544,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_userRealmJoinWatcher != null) return;
|
||||
if (_stopped || _userRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets — see AttachMachineRealmJoinSubtreeWatcher for rationale.
|
||||
@@ -550,7 +585,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var phase = RealmJoinInfo.TryReadDeploymentPhase(RegistryHive.LocalMachine);
|
||||
var phase = _readDeploymentPhase();
|
||||
if (phase == null) return;
|
||||
NotifyRealmJoinPresence(phase);
|
||||
}
|
||||
@@ -580,6 +615,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
fireDetected = !_detectedFired;
|
||||
if (fireDetected) _detectedFired = true;
|
||||
|
||||
// Detected reports Blank (0) while the value is not written yet. That report is
|
||||
// the baseline, so a first real value of 100+ surfaces as PhaseChanged instead of
|
||||
// silently setting _lastPhase.
|
||||
if (fireDetected && !phase.HasValue && !_lastPhase.HasValue) _lastPhase = 0;
|
||||
|
||||
if (phase.HasValue)
|
||||
{
|
||||
if (_lastPhase.HasValue && _lastPhase.Value != phase.Value)
|
||||
@@ -686,7 +726,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_disposed || _uninstallWatcher != null) return;
|
||||
if (_disposed || _stopped || _uninstallWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -916,16 +956,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DisposeAndNull(ref RegistryWatcher? w)
|
||||
// Caller holds _stateLock; the taken watchers are disposed after it is released.
|
||||
private static void Take(ref RegistryWatcher? slot, List<RegistryWatcher> taken)
|
||||
{
|
||||
if (w == null) return false;
|
||||
try { w.Dispose(); } catch { }
|
||||
w = null;
|
||||
return true;
|
||||
if (slot == null) return;
|
||||
taken.Add(slot);
|
||||
slot = null;
|
||||
}
|
||||
|
||||
// Test seams — drive the read paths deterministically without touching the real registry.
|
||||
internal void TriggerCheckParametersFromTest() => CheckParameters();
|
||||
internal void TriggerServiceStageAttachedFromTest() => NotifyServiceStageAttached();
|
||||
internal void TriggerCheckMachinePackagesFromTest() => CheckMachinePackages();
|
||||
internal void TriggerCheckUserPackagesFromTest() => CheckUserPackages();
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
internal static class WinHttpNativeMethods
|
||||
{
|
||||
public const int ERROR_FILE_NOT_FOUND = 2;
|
||||
public const uint WINHTTP_ACCESS_TYPE_NAMED_PROXY = 3;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct WINHTTP_CURRENT_USER_IE_PROXY_CONFIG
|
||||
{
|
||||
[MarshalAs(UnmanagedType.Bool)]
|
||||
public bool fAutoDetect;
|
||||
public IntPtr lpszAutoConfigUrl;
|
||||
public IntPtr lpszProxy;
|
||||
public IntPtr lpszProxyBypass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct WINHTTP_PROXY_INFO
|
||||
{
|
||||
public uint dwAccessType;
|
||||
public IntPtr lpszProxy;
|
||||
public IntPtr lpszProxyBypass;
|
||||
}
|
||||
|
||||
[DllImport("winhttp.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool WinHttpGetIEProxyConfigForCurrentUser(ref WINHTTP_CURRENT_USER_IE_PROXY_CONFIG config);
|
||||
|
||||
[DllImport("winhttp.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool WinHttpGetDefaultProxyConfiguration(ref WINHTTP_PROXY_INFO info);
|
||||
|
||||
// WinHTTP allocates the strings of both structs with GlobalAlloc; the caller frees them.
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern IntPtr GlobalFree(IntPtr hMem);
|
||||
}
|
||||
}
|
||||
+237
-127
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
@@ -36,21 +37,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// (<c>builtin_administrator_enabled</c>) but not scored: whether OOBE enables it
|
||||
/// transiently is not established, so the field first collects evidence.
|
||||
///
|
||||
/// Built-in accounts are recognised by well-known RID (500/501/503/504 on the machine SID)
|
||||
/// in addition to their English names, so localized installs (Gast, Invité, Administrateur)
|
||||
/// do not flag their own disabled Guest/Administrator.
|
||||
/// Allowed list (<see cref="LocalAdminAllowList"/> built-ins plus the tenant's entries): an entry
|
||||
/// allows an account in any role, or — <c>name|user</c> — only while it is not an
|
||||
/// Administrators member. A restriction always wins over an entry that allows more. The four
|
||||
/// built-in accounts are decided by their well-known RID alone (500 any role; 501/503/504 never
|
||||
/// an administrator), so localized names (Gast, Administrateur) are recognised and no name entry
|
||||
/// can lift or impose a role on them. Profile folders have no role.
|
||||
///
|
||||
/// At shutdown the logged-in users are allowed dynamically — but only identities that are
|
||||
/// NOT local SAM accounts (Entra / domain users, whose profile folder is the only artifact
|
||||
/// they leave). A logged-in local account is exactly the delta this analyzer exists to
|
||||
/// catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// At shutdown the logged-in users are allowed dynamically — as profile folders only, and only
|
||||
/// identities that are NOT local SAM accounts (Entra / domain users, whose profile folder is the
|
||||
/// only artifact they leave). A logged-in local account is exactly the delta this analyzer exists
|
||||
/// to catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// adversary's control); it is reported as <c>logged_in_local_accounts</c>.
|
||||
///
|
||||
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
|
||||
/// enabling delta detection between pre- and post-enrollment state.
|
||||
///
|
||||
/// Tenant-supplied allowed accounts may contain glob wildcards (e.g. "adm-*");
|
||||
/// see <see cref="MatchesAllowedEntry"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAnalyzer : IAgentAnalyzer
|
||||
{
|
||||
@@ -58,26 +59,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<string> _allowedAccounts;
|
||||
|
||||
// Built-in accounts and profile folders always present on a freshly imaged Windows device.
|
||||
// "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts.
|
||||
// "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment.
|
||||
private static readonly List<string> BuiltInAllowedAccounts = new List<string>
|
||||
{
|
||||
"Administrator",
|
||||
"Guest",
|
||||
"DefaultAccount",
|
||||
"WDAGUtilityAccount",
|
||||
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
|
||||
"defaultuser1", // Sometimes seen in OOBE, but not always present
|
||||
"defaultuser2", // Sometimes seen in OOBE, but not always present
|
||||
"kioskUser0", // Assigned Access (kiosk profile) autologon account, created by Windows
|
||||
"Public", // Profile folder (not a user account)
|
||||
"Default", // Default user profile template
|
||||
"Default User", // Symlink to Default in some Windows versions
|
||||
"All Users" // Junction pointing to C:\ProgramData, always present
|
||||
};
|
||||
private readonly List<AllowedEntry> _allowedEntries;
|
||||
|
||||
public string Name => "LocalAdminAnalyzer";
|
||||
|
||||
@@ -97,19 +79,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_startupGate = startupGate;
|
||||
|
||||
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
|
||||
_allowedAccounts = new List<string>(BuiltInAllowedAccounts);
|
||||
if (allowedAccounts != null)
|
||||
{
|
||||
foreach (var account in allowedAccounts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(account) &&
|
||||
!_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_allowedAccounts.Add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
var ignored = new List<string>();
|
||||
_allowedEntries = BuildAllowedEntries(allowedAccounts, ignored);
|
||||
foreach (var entry in ignored)
|
||||
_logger.Warning($"{Name}: Allowed-list entry '{entry}' is not valid (expected 'name' or 'name|user') — ignored, it allows nothing");
|
||||
}
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
@@ -149,46 +122,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
foreach (var user in loggedIn.LocalAccounts)
|
||||
_logger.Warning($"{Name}: Logged-in user '{user}' is a local account — not allowed dynamically");
|
||||
|
||||
// Build effective allowed list: static + dynamic (non-local logged-in users at shutdown)
|
||||
var effectiveAllowed = _allowedAccounts;
|
||||
// Effective list: static entries + the profile folders of non-local logged-in users at
|
||||
// shutdown. Folder-only: a login never allows a local ACCOUNT — not even when the WMI
|
||||
// inventory failed and the split above could not recognise a local name.
|
||||
var effectiveEntries = _allowedEntries;
|
||||
if (loggedIn.Allowed.Count > 0)
|
||||
{
|
||||
effectiveAllowed = new List<string>(_allowedAccounts);
|
||||
effectiveEntries = new List<AllowedEntry>(_allowedEntries);
|
||||
foreach (var user in loggedIn.Allowed)
|
||||
{
|
||||
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
|
||||
if (!effectiveEntries.Any(e => string.Equals(e.Pattern, user, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
effectiveAllowed.Add(user);
|
||||
_logger.Info($"{Name}: Dynamically allowing logged-in user: {user}");
|
||||
effectiveEntries.Add(new AllowedEntry(user, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
_logger.Info($"{Name}: Dynamically allowing profile folder of logged-in user: {user}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bypassNroResult = CheckBypassNroRegistry();
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveAllowed);
|
||||
var profilesResult = CheckUserProfiles(effectiveAllowed);
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveEntries);
|
||||
var profilesResult = CheckUserProfiles(effectiveEntries);
|
||||
|
||||
int confidenceScore = 0;
|
||||
|
||||
if (bypassNroResult.Value == 1)
|
||||
confidenceScore += 20;
|
||||
|
||||
if (accountsResult.Unexpected.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// An unexpected account that is also a member of Administrators is the
|
||||
// actual backdoor (enabled or dormant) — high indicator on its own.
|
||||
if (accountsResult.UnexpectedAdminMembers.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// Profile overlap: unexpected account AND matching C:\Users folder
|
||||
bool profileOverlap = accountsResult.Unexpected.Any(a =>
|
||||
profilesResult.Unexpected.Any(p =>
|
||||
string.Equals(a, p, StringComparison.OrdinalIgnoreCase)));
|
||||
if (profileOverlap)
|
||||
confidenceScore += 40;
|
||||
|
||||
confidenceScore = Math.Min(confidenceScore, 100);
|
||||
var confidenceScore = ComputeConfidenceScore(
|
||||
bypassNroResult.Value == 1, accountsResult.Unexpected, accountsResult.UnexpectedAdminMembers, profilesResult.Unexpected);
|
||||
|
||||
EventSeverity severity;
|
||||
string findingLabel;
|
||||
@@ -219,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
$"bypassNro={bypassNroResult.Value}, " +
|
||||
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
|
||||
$"unexpectedAdminMembers={accountsResult.UnexpectedAdminMembers.Count}, " +
|
||||
$"roleViolations={accountsResult.RoleViolations.Count}, " +
|
||||
$"adminGroupEnumerated={accountsResult.AdministratorsGroupEnumerated}, " +
|
||||
$"builtinAdministratorEnabled={accountsResult.BuiltInAdministratorEnabled?.ToString() ?? "unknown"}, " +
|
||||
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
|
||||
@@ -230,7 +187,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{ "finding", findingLabel },
|
||||
{ "triggered_at", trigger },
|
||||
{ "enrollment_phase_at_check", phase.ToString() },
|
||||
{ "allowed_accounts", effectiveAllowed },
|
||||
// Account entries in canonical form ("Guest|user"); the folder-only entries are
|
||||
// constant or listed in dynamically_allowed_users.
|
||||
{ "allowed_accounts", _allowedEntries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList() },
|
||||
{ "dynamically_allowed_users", loggedIn.Allowed },
|
||||
{ "logged_in_local_accounts", loggedIn.LocalAccounts },
|
||||
{ "checks", new Dictionary<string, object>
|
||||
@@ -243,6 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
},
|
||||
{ "unexpected_accounts", accountsResult.Unexpected },
|
||||
{ "unexpected_admin_members", accountsResult.UnexpectedAdminMembers },
|
||||
{ "role_violations", accountsResult.RoleViolations },
|
||||
{ "unexpected_profiles", profilesResult.Unexpected },
|
||||
{ "accounts_checked", accountsResult.AllChecked },
|
||||
{ "account_details", accountsResult.AccountDetails },
|
||||
@@ -299,31 +259,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence 0-100: BypassNRO +20, any unexpected account +40, any unexpected Administrators
|
||||
/// member +40 (the actual backdoor, enabled or dormant; a role violation is one), an unexpected
|
||||
/// account with a C:\Users folder of the same name +40. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static int ComputeConfidenceScore(
|
||||
bool bypassNro, IList<string> unexpectedAccounts, IList<string> unexpectedAdminMembers, IList<string> unexpectedProfiles)
|
||||
{
|
||||
var score = 0;
|
||||
if (bypassNro)
|
||||
score += 20;
|
||||
if (unexpectedAccounts.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAdminMembers.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAccounts.Any(a => unexpectedProfiles.Any(p => string.Equals(a, p, StringComparison.OrdinalIgnoreCase))))
|
||||
score += 40;
|
||||
return Math.Min(score, 100);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allowed-list matching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Matches an account or profile-folder name against one allowed-list entry.
|
||||
/// Entries containing <c>*</c> or <c>?</c> are glob patterns (same semantics as the
|
||||
/// Matches an account or profile-folder name against one allowed-list pattern.
|
||||
/// Patterns containing <c>*</c> or <c>?</c> are globs (same semantics as the
|
||||
/// hardware whitelist: <c>*</c> = any run, <c>?</c> = one char, case-insensitive);
|
||||
/// all other entries match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// all other patterns match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// <c>?</c> are invalid characters in Windows account names.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool MatchesAllowedEntry(string name, string entry)
|
||||
internal static bool MatchesAllowedEntry(string name, string pattern)
|
||||
{
|
||||
if (entry.IndexOf('*') < 0 && entry.IndexOf('?') < 0)
|
||||
return string.Equals(entry, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (pattern.IndexOf('*') < 0 && pattern.IndexOf('?') < 0)
|
||||
return string.Equals(pattern, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(entry)
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
try
|
||||
{
|
||||
// Timeout guards against pathological patterns (ReDoS)
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
|
||||
// Timeout guards against pathological patterns (ReDoS). CultureInvariant: under a
|
||||
// Turkish culture "i" and "I" are no case pair, so "WLapsAdmin*" missed "WLAPSADMIN01".
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
@@ -331,39 +313,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string name, List<string> allowedAccounts)
|
||||
/// <summary>
|
||||
/// The effective static list: the built-in account-name and profile-folder entries of
|
||||
/// <see cref="LocalAdminAllowList.BuiltIns"/> (RID entries are decided by RID, see
|
||||
/// <see cref="Decide"/>) plus the tenant's entries. An entry that does not parse is added to
|
||||
/// <paramref name="ignored"/> and allows nothing. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static List<AllowedEntry> BuildAllowedEntries(IEnumerable<string> tenantEntries, List<string> ignored)
|
||||
{
|
||||
return allowedAccounts.Any(a => MatchesAllowedEntry(name, a));
|
||||
var entries = new List<AllowedEntry>();
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match == LocalAdminBuiltInMatch.AccountName)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, builtIn.Role, profileFolderOnly: false));
|
||||
else if (builtIn.Match == LocalAdminBuiltInMatch.ProfileFolder)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
}
|
||||
|
||||
foreach (var raw in tenantEntries ?? Enumerable.Empty<string>())
|
||||
{
|
||||
if (LocalAdminAllowList.TryParseEntry(raw, out var pattern, out var role))
|
||||
AddDistinct(entries, new AllowedEntry(pattern, role, profileFolderOnly: false));
|
||||
else if (!string.IsNullOrWhiteSpace(raw))
|
||||
ignored.Add(raw);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void AddDistinct(List<AllowedEntry> entries, AllowedEntry entry)
|
||||
{
|
||||
if (!entries.Any(e => e.ProfileFolderOnly == entry.ProfileFolderOnly
|
||||
&& string.Equals(e.Display, entry.Display, StringComparison.OrdinalIgnoreCase)))
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known built-in accounts of the local machine domain, recognised by RID so that
|
||||
/// localized names (Gast, Invité, Administrateur, …) are not flagged:
|
||||
/// 500 Administrator, 501 Guest, 503 DefaultAccount, 504 WDAGUtilityAccount.
|
||||
/// Only S-1-5-21-… SIDs qualify (the WMI query is already restricted to local accounts).
|
||||
/// Decides one local account. The built-in accounts are decided by their RID alone: 500 in any
|
||||
/// role, 501/503/504 never as an administrator — no name entry, built-in or tenant, lifts or
|
||||
/// imposes a role on them. Without a SID (a WMI corner case) the English name stands in for the
|
||||
/// RID. Every other account is allowed when an account entry matches it, unless it is an
|
||||
/// administrator and ANY matching entry is standard-user only: a restriction always wins.
|
||||
/// Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static AccountDecision Decide(string name, string sid, bool isAdmin, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var builtIn = FindBuiltInRidEntry(name, sid);
|
||||
if (builtIn != null)
|
||||
return isAdmin && builtIn.Role == LocalAccountRole.StandardUser ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
|
||||
var covered = false;
|
||||
var restricted = false;
|
||||
foreach (var entry in allowed)
|
||||
{
|
||||
if (entry.ProfileFolderOnly || !MatchesAllowedEntry(name, entry.Pattern))
|
||||
continue;
|
||||
covered = true;
|
||||
if (entry.Role == LocalAccountRole.StandardUser)
|
||||
restricted = true;
|
||||
}
|
||||
|
||||
if (!covered)
|
||||
return AccountDecision.Unlisted;
|
||||
return isAdmin && restricted ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
}
|
||||
|
||||
private static LocalAdminBuiltInEntry FindBuiltInRidEntry(string name, string sid)
|
||||
{
|
||||
var rid = MachineAccountRid(sid);
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match != LocalAdminBuiltInMatch.Rid)
|
||||
continue;
|
||||
var matches = rid.HasValue
|
||||
? builtIn.Rid == rid.Value
|
||||
: string.IsNullOrEmpty(sid) && string.Equals(builtIn.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (matches)
|
||||
return builtIn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RID (the last sub-authority) of a local-machine account SID (<c>S-1-5-21-…</c>; the WMI query
|
||||
/// and the member filter already restrict to local accounts), else null.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool IsWellKnownBuiltInRid(string sid)
|
||||
internal static int? MachineAccountRid(string sid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sid) || !sid.StartsWith("S-1-5-21-", StringComparison.Ordinal))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var dash = sid.LastIndexOf('-');
|
||||
if (dash < 0 || dash == sid.Length - 1)
|
||||
return false;
|
||||
|
||||
switch (sid.Substring(dash + 1))
|
||||
{
|
||||
case "500":
|
||||
case "501":
|
||||
case "503":
|
||||
case "504":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return int.TryParse(sid.Substring(dash + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var rid)
|
||||
? rid
|
||||
: (int?)null;
|
||||
}
|
||||
|
||||
/// <summary>A profile folder has no role: any entry of the same name allows it.</summary>
|
||||
private static bool IsAllowedFolder(string folderName, IReadOnlyList<AllowedEntry> allowed)
|
||||
=> allowed.Any(e => MatchesAllowedEntry(folderName, e.Pattern));
|
||||
|
||||
/// <summary>
|
||||
/// Splits the logged-in user names into those that may be allowed dynamically (not a
|
||||
/// local SAM account — Entra / domain identities whose only artifact is a profile
|
||||
@@ -481,7 +531,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, List<string> allowedAccounts)
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
// Administrators-group membership is read from the SAM, independent of WMI and of
|
||||
// the enabled state — a dormant backdoor with admin rights is still a member.
|
||||
@@ -489,13 +539,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
if (!group.Succeeded)
|
||||
_logger.Warning($"{Name}: Failed to enumerate members of '{group.GroupName}': NET_API_STATUS={group.ErrorCode}");
|
||||
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowedAccounts);
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowed);
|
||||
result.AdministratorsGroupErrorCode = group.ErrorCode;
|
||||
|
||||
foreach (var name in result.Unexpected)
|
||||
_logger.Debug($"{Name}: Unexpected local account: {name}");
|
||||
foreach (var name in result.UnexpectedAdminMembers)
|
||||
_logger.Debug($"{Name}: Unexpected Administrators member: {name}");
|
||||
foreach (var name in result.RoleViolations)
|
||||
_logger.Debug($"{Name}: Allowed only as a standard user but an Administrators member: {name}");
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -508,10 +560,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// - every local account is checked, disabled ones included (state is reported);
|
||||
/// - a local account is an Administrators member when its SID matches a group member,
|
||||
/// or (SID unavailable) its name matches a member of the local machine domain;
|
||||
/// - an account is allowed by name (allowed list, globs) or by well-known RID
|
||||
/// (<see cref="IsWellKnownBuiltInRid"/>) — localized built-ins are never flagged;
|
||||
/// - <c>UnexpectedAdminMembers</c> = unexpected accounts holding membership, plus local
|
||||
/// members not on the allowed list that the WMI inventory did not return at all;
|
||||
/// - each account is decided by <see cref="Decide"/>; an account no entry allows, or one
|
||||
/// that breaks a standard-user entry, is unexpected (and an unexpected admin when it
|
||||
/// holds membership; the latter also a role violation);
|
||||
/// - local members the WMI inventory did not return at all are decided as administrators
|
||||
/// and land in both unexpected lists, like any unknown administrator;
|
||||
/// - with membership unknown (enumeration failed) nobody counts as an administrator, so a
|
||||
/// standard-user entry acts like a plain one;
|
||||
/// - members outside the local machine domain (Entra role SIDs, domain groups) are
|
||||
/// listed for delta comparison but never flagged — they are expected on joined devices.
|
||||
/// </summary>
|
||||
@@ -520,7 +575,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
IList<LocalGroupMember> adminMembers,
|
||||
bool adminGroupEnumerated,
|
||||
string machineName,
|
||||
List<string> allowedAccounts)
|
||||
IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var result = new LocalAccountCheckResult { AdministratorsGroupEnumerated = adminGroupEnumerated };
|
||||
|
||||
@@ -539,45 +594,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
var inventorySids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var inventoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var account in accounts ?? new List<LocalAccountInfo>())
|
||||
{
|
||||
var isAdmin = (!string.IsNullOrEmpty(account.Sid) && adminSids.Contains(account.Sid))
|
||||
|| adminLocalNames.Contains(account.Name);
|
||||
var rid = MachineAccountRid(account.Sid);
|
||||
|
||||
if (!string.IsNullOrEmpty(account.Sid))
|
||||
inventorySids.Add(account.Sid);
|
||||
inventoryNames.Add(account.Name);
|
||||
|
||||
result.AllChecked.Add(account.Name);
|
||||
result.AccountDetails.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "name", account.Name },
|
||||
{ "disabled", account.Disabled },
|
||||
{ "administrators_member", isAdmin }
|
||||
{ "administrators_member", isAdmin },
|
||||
{ "rid", rid }
|
||||
});
|
||||
|
||||
if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal))
|
||||
if (rid == 500)
|
||||
result.BuiltInAdministratorEnabled = !account.Disabled;
|
||||
|
||||
if (IsWellKnownBuiltInRid(account.Sid) || IsAllowed(account.Name, allowedAccounts))
|
||||
continue;
|
||||
|
||||
result.Unexpected.Add(account.Name);
|
||||
if (isAdmin)
|
||||
result.UnexpectedAdminMembers.Add(account.Name);
|
||||
Record(result, account.Name, Decide(account.Name, account.Sid, isAdmin, allowed), isAdmin);
|
||||
}
|
||||
|
||||
// Local members the WMI inventory did not surface (WMI failure, or an account the
|
||||
// provider does not list) — still an unexpected admin when not on the allowed list.
|
||||
// provider does not list): the SAM is authoritative for membership.
|
||||
foreach (var member in adminLocalMembers)
|
||||
{
|
||||
var name = member.Name;
|
||||
if (IsWellKnownBuiltInRid(member.Sid) || IsAllowed(name, allowedAccounts))
|
||||
var returnedByWmi = !string.IsNullOrEmpty(member.Sid)
|
||||
? inventorySids.Contains(member.Sid)
|
||||
: inventoryNames.Contains(member.Name);
|
||||
if (returnedByWmi)
|
||||
continue;
|
||||
if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
result.UnexpectedAdminMembers.Add(name);
|
||||
Record(result, member.Name, Decide(member.Name, member.Sid, isAdmin: true, allowed), isAdmin: true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Record(LocalAccountCheckResult result, string name, AccountDecision decision, bool isAdmin)
|
||||
{
|
||||
if (decision == AccountDecision.Allowed)
|
||||
return;
|
||||
AddOnce(result.Unexpected, name);
|
||||
if (isAdmin)
|
||||
AddOnce(result.UnexpectedAdminMembers, name);
|
||||
if (decision == AccountDecision.RoleViolation)
|
||||
AddOnce(result.RoleViolations, name);
|
||||
}
|
||||
|
||||
private static void AddOnce(List<string> names, string name)
|
||||
{
|
||||
if (!names.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)))
|
||||
names.Add(name);
|
||||
}
|
||||
|
||||
private static bool IsLocalMember(LocalGroupMember member, string machineName)
|
||||
{
|
||||
var domain = member.Domain;
|
||||
@@ -586,7 +661,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return string.Equals(domain, machineName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
|
||||
private UserProfileCheckResult CheckUserProfiles(IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var allFound = new List<string>();
|
||||
var unexpected = new List<string>();
|
||||
@@ -616,7 +691,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
allFound.Add(folderName);
|
||||
|
||||
if (!IsAllowed(folderName, allowedAccounts))
|
||||
if (!IsAllowedFolder(folderName, allowed))
|
||||
{
|
||||
unexpected.Add(folderName);
|
||||
_logger.Debug($"{Name}: Unexpected profile folder: {folderName}");
|
||||
@@ -740,7 +815,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// <summary>Unexpected accounts that are members of the Administrators group.</summary>
|
||||
public List<string> UnexpectedAdminMembers { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member.</summary>
|
||||
/// <summary>Administrators members that an entry allows only as a standard user (subset of <see cref="UnexpectedAdminMembers"/>).</summary>
|
||||
public List<string> RoleViolations { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member, rid.</summary>
|
||||
public List<Dictionary<string, object>> AccountDetails { get; set; } = new List<Dictionary<string, object>>();
|
||||
|
||||
/// <summary>Every Administrators member (DOMAIN\name or SID), local or not — for delta comparison.</summary>
|
||||
@@ -753,6 +831,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
public bool? BuiltInAdministratorEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One effective allowed-list entry: a name or glob and what it may allow.</summary>
|
||||
internal sealed class AllowedEntry
|
||||
{
|
||||
public AllowedEntry(string pattern, LocalAccountRole role, bool profileFolderOnly)
|
||||
{
|
||||
Pattern = pattern;
|
||||
Role = role;
|
||||
ProfileFolderOnly = profileFolderOnly;
|
||||
}
|
||||
|
||||
public string Pattern { get; }
|
||||
|
||||
public LocalAccountRole Role { get; }
|
||||
|
||||
/// <summary>Allows the C:\Users folder of this name, never a local account.</summary>
|
||||
public bool ProfileFolderOnly { get; }
|
||||
|
||||
/// <summary>Canonical entry text as the event lists it ("Guest|user").</summary>
|
||||
public string Display => LocalAdminAllowList.Format(Pattern, Role);
|
||||
}
|
||||
|
||||
internal enum AccountDecision
|
||||
{
|
||||
Allowed,
|
||||
|
||||
/// <summary>No entry allows the account.</summary>
|
||||
Unlisted,
|
||||
|
||||
/// <summary>An administrator that a standard-user entry (or a built-in RID) allows only as a standard user.</summary>
|
||||
RoleViolation,
|
||||
}
|
||||
|
||||
/// <summary>Logged-in users at shutdown, split by whether the name is a local SAM account.</summary>
|
||||
internal sealed class LoggedInUserSplit
|
||||
{
|
||||
|
||||
+3
-28
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -113,34 +114,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
|
||||
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
|
||||
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
|
||||
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Also check WinHTTP proxy
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
|
||||
{
|
||||
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
|
||||
}
|
||||
|
||||
var proxyType = "Direct";
|
||||
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
|
||||
proxyType = "Proxy";
|
||||
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
|
||||
proxyType = "PAC";
|
||||
|
||||
data["proxyType"] = proxyType;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {proxyType}", data);
|
||||
var settings = ProxySettingsReader.Read();
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.ProxyConfiguration, $"Proxy configuration: {settings.ProxyType}", settings.ToEventData());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+16
-12
@@ -79,11 +79,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
TryArm();
|
||||
raise = TryArm();
|
||||
}
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,17 +132,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Caller holds _lock.
|
||||
private void TryArm()
|
||||
// Caller holds _lock. Returns true when BinaryAppeared is due; the caller raises it after
|
||||
// releasing the lock, like OnRecheck does — the handler probes and may dispose this watcher.
|
||||
private bool TryArm()
|
||||
{
|
||||
if (_disposed || _fsw != null) return;
|
||||
if (_disposed || _fsw != null) return false;
|
||||
|
||||
// Initial scan — the binaries may already be present (armed late / update over existing).
|
||||
if (CoreBinariesPresent())
|
||||
{
|
||||
Raise();
|
||||
return;
|
||||
}
|
||||
if (CoreBinariesPresent()) return true;
|
||||
|
||||
if (!Directory.Exists(_installationPath))
|
||||
{
|
||||
@@ -154,7 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Debug($"[OfficeBinaryWatcher] install path '{_installationPath}' never appeared — giving up (no completion proof)");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -172,17 +172,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeBinaryWatcher] watching '{_installationPath}' for core Office binaries");
|
||||
|
||||
// Race: a binary may have appeared between the scan above and arming — re-scan once.
|
||||
if (CoreBinariesPresent()) Raise();
|
||||
return CoreBinariesPresent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[OfficeBinaryWatcher] could not watch '{_installationPath}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnArmRetry(object? state)
|
||||
{
|
||||
lock (_lock) { TryArm(); }
|
||||
bool raise;
|
||||
lock (_lock) { raise = TryArm(); }
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
private void OnFileEvent(object sender, FileSystemEventArgs e) => CheckCandidate(e.Name);
|
||||
|
||||
+132
-52
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
@@ -15,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// idle polling:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Start</b> — a true ETW-backed push via WMI <c>Win32_ProcessStartTrace</c>
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a one-shot startup probe so an install already
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a startup probe so an install already
|
||||
/// in flight when the agent boots (realistic during OOBE) is still caught.</item>
|
||||
/// <item><b>Stop</b> — <see cref="Process.Exited"/> on each tracked worker (clean OS event, same
|
||||
/// pattern as <c>ImeProcessWatcher</c>).</item>
|
||||
@@ -26,6 +27,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// a single active window). Fail-soft: if the WMI watcher cannot start, it is logged and the
|
||||
/// startup probe still covers the already-running case — no polling fallback by design.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state. No event is raised and no
|
||||
/// <see cref="Process"/> is disposed while it is held: .NET holds its own lock on a
|
||||
/// <see cref="Process"/> for as long as it delivers <see cref="Process.Exited"/>, that handler
|
||||
/// takes this lock, and <see cref="Process.Dispose()"/> waits for the delivery to finish.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OfficeProcessWatcher : IDisposable
|
||||
{
|
||||
@@ -53,6 +60,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </summary>
|
||||
public event EventHandler? Stopped;
|
||||
|
||||
/// <summary>Test seam: enumerates the running workers (default: Process.GetProcessesByName).</summary>
|
||||
internal Func<Process[]> WorkerSource { get; set; } = () => Process.GetProcessesByName(WorkerProcessNameNoExt);
|
||||
|
||||
/// <param name="settleSeconds">Grace period after the last worker exits before the window is
|
||||
/// declared closed; a new worker within it keeps the window open. <=0 closes immediately.</param>
|
||||
public OfficeProcessWatcher(AgentLogger logger, int settleSeconds = 10)
|
||||
@@ -67,7 +77,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
get { lock (_lock) { return _active; } }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
public void Start() => Start(armWmi: true);
|
||||
|
||||
/// <summary>Test seam: <paramref name="armWmi"/> = false leaves out the WMI subscription.</summary>
|
||||
internal void Start(bool armWmi)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -75,10 +88,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_started = true;
|
||||
}
|
||||
|
||||
// 1) Startup probe — catch a worker already running before the watcher armed.
|
||||
// 1) Startup probe — catch a worker already running before the subscription exists. It
|
||||
// stays on the caller's thread: it needs no WMI, so a worker that is already running is
|
||||
// reported even when the subscription below never completes.
|
||||
ProbeRunningWorkers();
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling. WinMgmt can hang during OOBE, so the
|
||||
// subscription never runs on the caller's thread (the orchestrator starts its hosts one
|
||||
// after another on it).
|
||||
if (armWmi) Task.Run(() => ArmStartTrace());
|
||||
}
|
||||
|
||||
private void ProbeRunningWorkers()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var proc in Process.GetProcessesByName(WorkerProcessNameNoExt))
|
||||
foreach (var proc in WorkerSource())
|
||||
{
|
||||
TrackWorker(proc);
|
||||
}
|
||||
@@ -87,24 +112,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Warning($"[OfficeProcessWatcher] startup probe failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling.
|
||||
private void ArmStartTrace()
|
||||
{
|
||||
ManagementEventWatcher? watcher = null;
|
||||
try
|
||||
{
|
||||
var query = new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'");
|
||||
_startWatcher = new ManagementEventWatcher(query);
|
||||
_startWatcher.EventArrived += OnProcessStartTrace;
|
||||
_startWatcher.Start();
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
watcher = new ManagementEventWatcher(new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'"));
|
||||
watcher.EventArrived += OnProcessStartTrace;
|
||||
watcher.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// No polling fallback by design — the startup probe already covers the in-flight case.
|
||||
_logger.Warning($"[OfficeProcessWatcher] could not start WMI process-start watcher " +
|
||||
$"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only.");
|
||||
_startWatcher = null;
|
||||
if (watcher != null) TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
// Published only once it is armed, so Dispose never meets a half-started subscription; one
|
||||
// that completes after Dispose is taken down right here.
|
||||
bool keep;
|
||||
lock (_lock)
|
||||
{
|
||||
keep = !_disposed;
|
||||
if (keep) _startWatcher = watcher;
|
||||
}
|
||||
if (!keep)
|
||||
{
|
||||
TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
|
||||
// A worker may have started between the probe in Start and the subscription.
|
||||
ProbeRunningWorkers();
|
||||
}
|
||||
|
||||
private void TearDownStartTrace(ManagementEventWatcher watcher)
|
||||
{
|
||||
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void OnProcessStartTrace(object sender, EventArrivedEventArgs e)
|
||||
@@ -128,39 +181,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
private void TrackWorker(Process proc)
|
||||
{
|
||||
bool tracked = false;
|
||||
bool raiseStarted = false;
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) { try { proc.Dispose(); } catch { } return; }
|
||||
if (_workers.ContainsKey(proc.Id)) { try { proc.Dispose(); } catch { } return; }
|
||||
if (!_disposed && !_workers.ContainsKey(proc.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
tracked = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
if (tracked)
|
||||
{
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[OfficeProcessWatcher] TrackWorker error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (!tracked)
|
||||
{
|
||||
// Disposed, already tracked under another handle, or gone before the hookup.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,6 +231,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeProcessWatcher] Office worker started (PID={SafePid(proc)}) — install active");
|
||||
RaiseStarted();
|
||||
}
|
||||
|
||||
// .NET raises Exited once per Process object. A worker that exited before the hookup
|
||||
// above was subscribed would stay tracked forever and the window would never close.
|
||||
if (HasExitedSafe(proc))
|
||||
ThreadPool.QueueUserWorkItem(_ => OnWorkerExited(proc, EventArgs.Empty));
|
||||
}
|
||||
|
||||
private void OnWorkerExited(object? sender, EventArgs e)
|
||||
@@ -178,11 +245,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_workers.Remove(SafePid(proc)))
|
||||
{
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
}
|
||||
// Only the call that still finds this worker acts: after Dispose nothing is tracked,
|
||||
// and TrackWorker's own re-check can deliver the same exit a second time.
|
||||
if (_disposed || !_workers.TryGetValue(SafePid(proc), out var tracked) || !ReferenceEquals(tracked, proc))
|
||||
return;
|
||||
_workers.Remove(SafePid(proc));
|
||||
|
||||
if (_active && _workers.Count == 0)
|
||||
{
|
||||
if (_settleSeconds <= 0)
|
||||
@@ -200,6 +268,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
|
||||
if (raiseStoppedNow)
|
||||
{
|
||||
_logger.Info("[OfficeProcessWatcher] last Office worker exited — install window closed");
|
||||
@@ -260,31 +331,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
try { return proc.Id; } catch { return -1; }
|
||||
}
|
||||
|
||||
private static bool HasExitedSafe(Process proc)
|
||||
{
|
||||
try { return proc.HasExited; } catch { return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ManagementEventWatcher? startWatcher;
|
||||
Process[] workers;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
if (_startWatcher != null)
|
||||
{
|
||||
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { _startWatcher.Stop(); } catch { }
|
||||
try { _startWatcher.Dispose(); } catch { }
|
||||
startWatcher = _startWatcher;
|
||||
_startWatcher = null;
|
||||
CancelSettleTimer();
|
||||
workers = new Process[_workers.Count];
|
||||
_workers.Values.CopyTo(workers, 0);
|
||||
_workers.Clear();
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
if (startWatcher != null)
|
||||
{
|
||||
CancelSettleTimer();
|
||||
foreach (var kv in _workers)
|
||||
{
|
||||
try { kv.Value.Exited -= OnWorkerExited; } catch { }
|
||||
try { kv.Value.Dispose(); } catch { }
|
||||
}
|
||||
_workers.Clear();
|
||||
// Cancelling a WMI subscription can take tens of seconds on a busy WinMgmt. The caller
|
||||
// is the agent's stop path, so it never waits for it.
|
||||
var armed = startWatcher;
|
||||
Task.Run(() => TearDownStartTrace(armed));
|
||||
}
|
||||
|
||||
foreach (var worker in workers)
|
||||
{
|
||||
try { worker.Exited -= OnWorkerExited; } catch { }
|
||||
try { worker.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-28
@@ -26,6 +26,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </para>
|
||||
/// One-shot per OS contract, so it re-arms after each fire. Fail-soft: any failure is logged and
|
||||
/// the watcher stays quiet (the detector still gets the process Started/Stopped boundary events).
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state and is never held while
|
||||
/// <see cref="Changed"/> is raised. A handler may dispose the watcher or take locks of its own,
|
||||
/// and <see cref="Dispose"/> never waits for a handler. Raising under the lock deadlocks against
|
||||
/// an owner that disposes this watcher under a lock its handler also takes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RegistryChangeWatcher : IDisposable
|
||||
{
|
||||
@@ -34,10 +40,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
ChangeName = 0x00000001, // subkey added/removed (ClickToRun appearing; Scenario\* churn)
|
||||
ChangeLastSet = 0x00000004, // value set (Configuration: VersionToReport / StreamingFinished)
|
||||
// The registration must outlive the thread that issued it: re-arming happens on rotating
|
||||
// ThreadPool threads, and without this flag Windows signals the event when such a thread
|
||||
// ends — reported here as a change that never happened.
|
||||
ThreadAgnostic = 0x10000000,
|
||||
}
|
||||
|
||||
private const RegNotifyFilter PrimaryFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet;
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName; // cheap: direct-child create/delete only
|
||||
private const RegNotifyFilter PrimaryFilter =
|
||||
RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet | RegNotifyFilter.ThreadAgnostic;
|
||||
// Cheap: direct-child create/delete only.
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ThreadAgnostic;
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
private static extern int RegNotifyChangeKeyValue(
|
||||
@@ -49,6 +61,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
private enum WatchMode { None, Primary, Bootstrap }
|
||||
|
||||
private readonly RegistryHive _hive;
|
||||
private readonly string _hiveLabel;
|
||||
private readonly string _targetSubKey;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly object _lock = new object();
|
||||
@@ -66,13 +80,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
/// <param name="targetSubKey">HKLM-relative key, e.g. <c>SOFTWARE\Microsoft\Office\ClickToRun</c>.</param>
|
||||
public RegistryChangeWatcher(string targetSubKey, AgentLogger logger)
|
||||
: this(RegistryHive.LocalMachine, targetSubKey, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: production always watches HKLM; tests use a hive they may write to.</summary>
|
||||
internal RegistryChangeWatcher(RegistryHive hive, string targetSubKey, AgentLogger logger)
|
||||
{
|
||||
_hive = hive;
|
||||
_hiveLabel = hive == RegistryHive.LocalMachine ? "HKLM" : hive.ToString();
|
||||
_targetSubKey = targetSubKey ?? throw new ArgumentNullException(nameof(targetSubKey));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>The filter the full target watch is armed with (tests).</summary>
|
||||
internal uint EffectivePrimaryFilter => (uint)PrimaryFilter;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise = false;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_started || _disposed) return;
|
||||
@@ -81,24 +107,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
// Forced Registry64 view — consistent with the C2R reads (AnyCPU net48 may otherwise
|
||||
// resolve the stale WOW6432Node mirror).
|
||||
_baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
_baseKey = RegistryKey.OpenBaseKey(_hive, RegistryView.Registry64);
|
||||
_signal = new AutoResetEvent(false);
|
||||
ArmBestEffort(raiseOnPromote: false);
|
||||
raise = ArmBestEffort(targetIsNews: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on HKLM\\{_targetSubKey}: {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on {_hiveLabel}\\{_targetSubKey}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arms the best available watch: the target key (full, subtree) if it exists, otherwise a cheap
|
||||
/// name-only watch on the nearest existing ancestor until the target appears. Must hold <see cref="_lock"/>.
|
||||
/// Returns true when the target key appeared and the caller has to raise <see cref="Changed"/>
|
||||
/// for it — after releasing the lock.
|
||||
/// </summary>
|
||||
private void ArmBestEffort(bool raiseOnPromote)
|
||||
/// <param name="targetIsNews">False when finding the target right away is the expected start
|
||||
/// state; true when the watch was waiting for it.</param>
|
||||
private bool ArmBestEffort(bool targetIsNews)
|
||||
{
|
||||
if (_disposed || _baseKey == null) return;
|
||||
if (_disposed || _baseKey == null) return false;
|
||||
|
||||
CloseWatchedKey();
|
||||
|
||||
@@ -107,27 +139,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_watchedKey = target;
|
||||
_mode = WatchMode.Primary;
|
||||
if (!ArmNotify(subtree: true, PrimaryFilter)) return;
|
||||
if (raiseOnPromote) RaiseChanged();
|
||||
return;
|
||||
return ArmNotify(subtree: true, PrimaryFilter) && targetIsNews;
|
||||
}
|
||||
|
||||
// Target absent — watch the nearest existing ancestor (non-recursive, name-only = cheap).
|
||||
var ancestor = OpenNearestExistingAncestor(out var ancestorPath);
|
||||
if (ancestor == null)
|
||||
{
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for HKLM\\{_targetSubKey}; not arming");
|
||||
return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for {_hiveLabel}\\{_targetSubKey}; not arming");
|
||||
return false;
|
||||
}
|
||||
_watchedKey = ancestor;
|
||||
_mode = WatchMode.Bootstrap;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target HKLM\\{_targetSubKey} absent — bootstrap-watching HKLM\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target {_hiveLabel}\\{_targetSubKey} absent — bootstrap-watching {_hiveLabel}\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return false;
|
||||
|
||||
// TOCTOU: the target may have been created between TryOpen and arming the ancestor watch.
|
||||
using (var recheck = TryOpen(_targetSubKey))
|
||||
{
|
||||
if (recheck != null) ArmBestEffort(raiseOnPromote: true);
|
||||
return recheck != null && ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
int rc = RegNotifyChangeKeyValue(_watchedKey.Handle, subtree, filter, _signal.SafeWaitHandle, fAsynchronous: true);
|
||||
if (rc != 0)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} (HKLM\\{_targetSubKey})");
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} ({_hiveLabel}\\{_targetSubKey})");
|
||||
return false;
|
||||
}
|
||||
UnregisterWait();
|
||||
@@ -149,29 +179,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed (HKLM\\{_targetSubKey}): {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed ({_hiveLabel}\\{_targetSubKey}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSignalled(object? state, bool timedOut)
|
||||
{
|
||||
bool raise;
|
||||
bool rearmAfterHandler;
|
||||
lock (_lock)
|
||||
{
|
||||
UnregisterWait();
|
||||
if (_disposed || timedOut) return;
|
||||
|
||||
if (_mode == WatchMode.Primary)
|
||||
{
|
||||
RaiseChanged();
|
||||
ArmNotify(subtree: true, PrimaryFilter); // re-arm on the same target key
|
||||
}
|
||||
else
|
||||
{
|
||||
// A direct child changed under the ancestor — promote to the target watch if it now
|
||||
// exists (raises Changed on promotion), else keep the cheap bootstrap watch.
|
||||
ArmBestEffort(raiseOnPromote: true);
|
||||
}
|
||||
rearmAfterHandler = _mode == WatchMode.Primary;
|
||||
// Bootstrap: a direct child changed under the ancestor — promote to the target watch if
|
||||
// it now exists (then Changed is due), else keep the cheap bootstrap watch.
|
||||
raise = rearmAfterHandler || ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
|
||||
if (!rearmAfterHandler) return;
|
||||
lock (_lock)
|
||||
{
|
||||
// Re-arm on the same target key, and only now: no wait was registered while the handler
|
||||
// ran, so handlers never overlap, and a change that arrived meanwhile completes this
|
||||
// registration at once.
|
||||
ArmNotify(subtree: true, PrimaryFilter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Never called with _lock held.
|
||||
private void RaiseChanged()
|
||||
{
|
||||
var handler = Changed;
|
||||
|
||||
@@ -96,6 +96,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
private readonly DateTime? _previousRunLastAliveUtc;
|
||||
|
||||
private readonly Transport.ProxyObserver? _proxyObserver;
|
||||
|
||||
public DefaultComponentFactory(
|
||||
AgentConfiguration agentConfig,
|
||||
AgentConfigResponse remoteConfig,
|
||||
@@ -105,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
Persistence.StartupEventGate? startupEventGate = null,
|
||||
DateTime? previousBootUtc = null,
|
||||
string? previousExitType = null,
|
||||
DateTime? previousRunLastAliveUtc = null)
|
||||
DateTime? previousRunLastAliveUtc = null,
|
||||
Transport.ProxyObserver? proxyObserver = null)
|
||||
{
|
||||
_agentConfig = agentConfig ?? throw new ArgumentNullException(nameof(agentConfig));
|
||||
_remoteConfig = remoteConfig ?? throw new ArgumentNullException(nameof(remoteConfig));
|
||||
@@ -116,6 +119,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_previousBootUtc = previousBootUtc;
|
||||
_previousExitType = previousExitType;
|
||||
_previousRunLastAliveUtc = previousRunLastAliveUtc;
|
||||
_proxyObserver = proxyObserver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -352,28 +356,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
hosts.Add(aadJoinHost);
|
||||
aadJoinHostRef = aadJoinHost;
|
||||
|
||||
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
|
||||
// the DesktopArrival observer above already null-guards it, so the HKU watcher is
|
||||
// never armed and no RealmJoin signals are produced. The compile-time const
|
||||
// RealmJoinHost.RealmJoinTrackingEnabled remains a build-time master kill-switch;
|
||||
// the effective enable is (remote flag AND const).
|
||||
var analyzers = _remoteConfig.Analyzers ?? new AnalyzerConfiguration();
|
||||
if (analyzers.EnableRealmJoinWatcher && RealmJoinHost.RealmJoinTrackingEnabled)
|
||||
{
|
||||
realmJoinHost = new RealmJoinHost(logger, ingress, clock);
|
||||
hosts.Add(realmJoinHost);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"DefaultComponentFactory: RealmJoinHost not created (EnableRealmJoinWatcher={analyzers.EnableRealmJoinWatcher}, RealmJoinTrackingEnabled={RealmJoinHost.RealmJoinTrackingEnabled})");
|
||||
}
|
||||
|
||||
// Keep-awake during User-ESP — opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// KeepAwakeDuringUserEsp, default off). The host observes the signal rail and holds the
|
||||
// device awake (system + display) from AccountSetup entry until provisioning completes
|
||||
// (or a safety cap), so idle standby cannot stall app installs / account setup. Reboots
|
||||
// device awake (system + display) from AccountSetup entry until the user-ESP page
|
||||
// closes — with the RealmJoin Watcher on, until RealmJoin starts its deployment (D-413) —
|
||||
// or a safety cap, so idle standby cannot stall app installs / account setup. Reboots
|
||||
// are unaffected; the OS auto-clears the hold on process exit.
|
||||
// ORDER: before the RealmJoinHost — after an agent restart the watcher posts
|
||||
// realmjoin_detected from its own Start, and this host must already be subscribed.
|
||||
if (analyzers.KeepAwakeDuringUserEsp)
|
||||
{
|
||||
hosts.Add(new UserEspKeepAwakeHost(
|
||||
@@ -382,7 +374,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
logger: logger,
|
||||
// q8n: release on the user-ESP page exit, i.e. a Shell-Core 62407 that arrives
|
||||
// after the AccountSetup registry left notStarted — never on the handoff exit.
|
||||
accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress));
|
||||
accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress,
|
||||
// D-414: that exit is latched here, so a later agent run does not engage again.
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
|
||||
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
|
||||
// the DesktopArrival observer above already null-guards it, so the HKU watcher is
|
||||
// never armed and no RealmJoin signals are produced. The compile-time const
|
||||
// RealmJoinHost.RealmJoinTrackingEnabled remains a build-time master kill-switch;
|
||||
// the effective enable is (remote flag AND const).
|
||||
if (analyzers.EnableRealmJoinWatcher && RealmJoinHost.RealmJoinTrackingEnabled)
|
||||
{
|
||||
realmJoinHost = new RealmJoinHost(logger, ingress, clock);
|
||||
hosts.Add(realmJoinHost);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"DefaultComponentFactory: RealmJoinHost not created (EnableRealmJoinWatcher={analyzers.EnableRealmJoinWatcher}, RealmJoinTrackingEnabled={RealmJoinHost.RealmJoinTrackingEnabled})");
|
||||
}
|
||||
|
||||
// OOBE-console / Shift+F10 detection — opt-OUT per tenant (portal toggle →
|
||||
@@ -666,6 +676,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
clock: clock,
|
||||
logger: logger));
|
||||
|
||||
// The exact time of a proxy change for the agent log and the timeline. Always on: when
|
||||
// this matters, nothing else from the device reaches the backend.
|
||||
if (_proxyObserver != null)
|
||||
{
|
||||
hosts.Add(new ProxySettingsHost(
|
||||
observer: _proxyObserver,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
logger: logger));
|
||||
}
|
||||
|
||||
// Live AC/battery watcher — WMI Win32_PowerManagementEvent push (no polling), emits
|
||||
// power_state_change on AC↔battery transitions and 50/30/15 % downward threshold
|
||||
// crossings. Deliberately NOT under PeriodicCollectorLifecycleHost: it is push-based
|
||||
|
||||
@@ -67,6 +67,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <summary>Default Timeout für den Terminal-Drain beim Stop.</summary>
|
||||
public static readonly TimeSpan DefaultTerminalDrainTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long termination waits for ONE collector host to stop (and later to dispose). A whole
|
||||
/// healthy stop pass takes about 0.2 s and the slowest single host seen in the field 7 s, so
|
||||
/// this only ever ends the wait for a host that hangs.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultCollectorHostStopTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _stateDirectory;
|
||||
@@ -81,6 +88,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly int _quarantineThreshold;
|
||||
private readonly TimeSpan _drainInterval;
|
||||
private readonly TimeSpan _terminalDrainTimeout;
|
||||
private readonly TimeSpan _collectorHostStopTimeout;
|
||||
private readonly TimeSpan? _agentMaxLifetime;
|
||||
private readonly int _uploadBatchSize;
|
||||
|
||||
@@ -124,6 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private SessionTraceOrdinalProvider? _traceCounter;
|
||||
private SignalIngress? _ingress;
|
||||
private IReadOnlyList<ICollectorHost>? _collectorHosts;
|
||||
// Hosts that did not come back from Stop or Dispose in time. A thread is still inside them,
|
||||
// so nothing calls into them again. Locked on itself.
|
||||
private readonly HashSet<ICollectorHost> _unresponsiveHosts = new HashSet<ICollectorHost>();
|
||||
|
||||
private EventHandler<DeadlineFiredEventArgs>? _deadlineBridge;
|
||||
|
||||
@@ -181,7 +192,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int uploadBatchSize = 100,
|
||||
IDeadlineScheduler? schedulerOverride = null,
|
||||
Func<bool>? traceEventsEnabled = null,
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null)
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null,
|
||||
TimeSpan? collectorHostStopTimeout = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -211,6 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_quarantineThreshold = quarantineThreshold;
|
||||
_drainInterval = drainInterval ?? DefaultDrainInterval;
|
||||
_terminalDrainTimeout = terminalDrainTimeout ?? DefaultTerminalDrainTimeout;
|
||||
_collectorHostStopTimeout = collectorHostStopTimeout ?? DefaultCollectorHostStopTimeout;
|
||||
|
||||
if (agentMaxLifetime.HasValue && agentMaxLifetime.Value <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(agentMaxLifetime), "AgentMaxLifetime must be positive when set.");
|
||||
@@ -947,6 +960,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// diagnostics ZIP is built so periodic collectors (PerformanceCollector,
|
||||
/// AgentSelfMetricsCollector, …) don't keep emitting <c>performance_snapshot</c> /
|
||||
/// <c>agent_metrics_snapshot</c> events while the package is being assembled.
|
||||
/// <para>
|
||||
/// Bounded per host (<see cref="DefaultCollectorHostStopTimeout"/>): a host that hangs is
|
||||
/// left behind and the remaining hosts are still stopped. The diagnostics upload and the
|
||||
/// self-destruct come after this call, so it has to return whatever a collector does.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void StopCollectorHosts()
|
||||
{
|
||||
@@ -955,8 +973,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {ex.Message}"); }
|
||||
RunHostStepBounded(host, "stop", h => h.Stop());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one host's Stop or Dispose on a thread of its own and waits at most
|
||||
/// <see cref="_collectorHostStopTimeout"/> for it.
|
||||
/// </summary>
|
||||
private void RunHostStepBounded(ICollectorHost host, string step, Action<ICollectorHost> action)
|
||||
{
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
if (_unresponsiveHosts.Contains(host)) return;
|
||||
}
|
||||
|
||||
Exception? failure = null;
|
||||
var worker = new Thread(() =>
|
||||
{
|
||||
try { action(host); }
|
||||
catch (Exception ex) { failure = ex; }
|
||||
})
|
||||
{
|
||||
// Not a ThreadPool thread: a host that hangs would keep one for good, and the pool may
|
||||
// be the very thing that is stuck. Background, so it cannot keep the process alive.
|
||||
IsBackground = true,
|
||||
Name = $"CollectorHost.{step}:{host.Name}",
|
||||
};
|
||||
try { worker.Start(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' {step} could not be started: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (worker.Join(_collectorHostStopTimeout))
|
||||
{
|
||||
if (failure == null) return;
|
||||
if (step == "stop")
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {failure.Message}");
|
||||
else
|
||||
_logger.Debug($"EnrollmentOrchestrator: host '{host.Name}' {step} failed: {failure.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
_unresponsiveHosts.Add(host);
|
||||
}
|
||||
var seconds = (int)Math.Ceiling(_collectorHostStopTimeout.TotalSeconds);
|
||||
_logger.Warning(
|
||||
$"EnrollmentOrchestrator: host '{host.Name}' did not return from {step} within {seconds}s — " +
|
||||
"termination continues without it.");
|
||||
if (step == "stop") EmitCollectorHostStopTimeout(host.Name, seconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>agent_trace</c> per host that hung in Stop, so the fleet can count it. Deliberately
|
||||
/// not <c>collector_degraded</c>: that one says the collector's signals are missing and gates
|
||||
/// analyze rules, while a host that will not stop has missed nothing. (A host that hangs in
|
||||
/// Dispose is logged only — the ingress is already down by then.)
|
||||
/// </summary>
|
||||
private void EmitCollectorHostStopTimeout(string hostName, int timeoutSeconds)
|
||||
{
|
||||
var ingress = _ingress;
|
||||
if (ingress == null) return;
|
||||
try
|
||||
{
|
||||
new InformationalEventPost(ingress, _clock).Emit(new Shared.Models.EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.AgentTrace,
|
||||
Severity = Shared.Models.EventSeverity.Warning,
|
||||
Source = "EnrollmentOrchestrator",
|
||||
Phase = Shared.Models.EnrollmentPhase.Unknown,
|
||||
Message = $"Collector host '{hostName}' did not stop within {timeoutSeconds}s; termination continued without it.",
|
||||
Timestamp = _clock.UtcNow,
|
||||
Data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["decision"] = "collector_host_stop_timeout",
|
||||
["host"] = hostName,
|
||||
["timeoutSeconds"] = timeoutSeconds,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"EnrollmentOrchestrator: failed to post collector_host_stop_timeout: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,12 +1147,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: final snapshot save failed: {ex.Message}"); }
|
||||
|
||||
// 7) Dispose aller Disposables.
|
||||
// 7) Dispose all disposables. Hosts go through the same bound as in StopCollectorHosts;
|
||||
// one that is still stuck in Stop is skipped (its Dispose would wait on the same thing).
|
||||
if (_collectorHosts != null)
|
||||
{
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* best-effort */ }
|
||||
RunHostStepBounded(host, "dispose", h => h.Dispose());
|
||||
}
|
||||
}
|
||||
try { _transport?.Dispose(); } catch { /* best-effort */ }
|
||||
|
||||
+64
-23
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
@@ -49,9 +50,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly OfficeInstallDetector _detector;
|
||||
private readonly OfficeProcessWatcher _processWatcher;
|
||||
private readonly AgentLogger _logger;
|
||||
// Guards the fields below, nothing else. Never held across a call into a watcher or the
|
||||
// detector: their callbacks come back into this host and take it.
|
||||
private readonly object _lock = new object();
|
||||
|
||||
private readonly OfficeInstallStateData? _resumeState;
|
||||
private readonly RegistryHive _registryHive;
|
||||
private readonly string _registrySubKey;
|
||||
private RegistryChangeWatcher? _registryWatcher;
|
||||
private OfficeBinaryWatcher? _binaryWatcher;
|
||||
private bool _pathObserved; // InstallationPath known → stop poking the DO collector
|
||||
@@ -74,16 +79,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence = null,
|
||||
OfficeInstallStateData? resumeState = null)
|
||||
: this(sessionId, tenantId, ingress, clock, logger, settleSeconds, statePersistence, resumeState,
|
||||
RegistryHive.LocalMachine, ClickToRunSubKey, snapshotProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: points the registry watcher at a key a test may write to and feeds the
|
||||
/// detector synthetic snapshots. Production uses the constructor above.
|
||||
/// </summary>
|
||||
internal OfficeInstallDetectorHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence,
|
||||
OfficeInstallStateData? resumeState,
|
||||
RegistryHive registryHive,
|
||||
string registrySubKey,
|
||||
Func<OfficeC2RSnapshot>? snapshotProvider)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_resumeState = resumeState;
|
||||
_registryHive = registryHive;
|
||||
_registrySubKey = registrySubKey ?? throw new ArgumentNullException(nameof(registrySubKey));
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_detector = new OfficeInstallDetector(sessionId, tenantId, post, logger, clock,
|
||||
onInstallationPathObserved: OnInstallationPathObserved,
|
||||
statePersistence: statePersistence);
|
||||
statePersistence: statePersistence)
|
||||
{
|
||||
SnapshotProvider = snapshotProvider,
|
||||
};
|
||||
_processWatcher = new OfficeProcessWatcher(logger, settleSeconds);
|
||||
_processWatcher.Started += OnWorkerStarted;
|
||||
}
|
||||
@@ -108,12 +139,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Arm the registry watcher up front (not at worker-start) so the early Scenario\INSTALL key
|
||||
// is caught before the (late) worker process. The watcher's bootstrap fallback handles the
|
||||
// case where the ClickToRun key does not exist yet on a clean first install.
|
||||
lock (_lock)
|
||||
{
|
||||
_registryWatcher = new RegistryChangeWatcher(ClickToRunSubKey, _logger);
|
||||
_registryWatcher.Changed += OnRegistryChanged;
|
||||
_registryWatcher.Start();
|
||||
}
|
||||
var registryWatcher = new RegistryChangeWatcher(_registryHive, _registrySubKey, _logger);
|
||||
registryWatcher.Changed += OnRegistryChanged;
|
||||
lock (_lock) { _registryWatcher = registryWatcher; }
|
||||
registryWatcher.Start();
|
||||
_processWatcher.Start();
|
||||
|
||||
// Resume a persisted *Active* lifecycle (enrollments span reboots) AFTER the watchers are
|
||||
@@ -134,7 +163,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// re-poking it on every registry value churn is pure log noise (field session 7da7dead).
|
||||
bool raise;
|
||||
lock (_lock) { raise = !_pathObserved && !_lifecycleEnded; }
|
||||
if (raise)
|
||||
// A callback can still be running after Dispose returned — Dispose never waits for one.
|
||||
if (raise && Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
try { OfficeExpected?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"[OfficeInstallDetectorHost] OfficeExpected handler threw: {ex.Message}"); }
|
||||
@@ -147,15 +177,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void OnInstallationPathObserved(string? installationPath)
|
||||
{
|
||||
OfficeBinaryWatcher watcher;
|
||||
lock (_lock)
|
||||
{
|
||||
_pathObserved = true; // stop raising OfficeExpected (lifecycle has started)
|
||||
if (_lifecycleEnded || _binaryWatcher != null || string.IsNullOrEmpty(installationPath)) return;
|
||||
if (Volatile.Read(ref _disposed) == 1) return;
|
||||
|
||||
_binaryWatcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
_binaryWatcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher.Start();
|
||||
watcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
watcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher = watcher;
|
||||
}
|
||||
|
||||
// Start can raise BinaryAppeared right away (Office already on disk), and that handler
|
||||
// ends in DisposeWatchers.
|
||||
watcher.Start();
|
||||
}
|
||||
|
||||
private void OnBinaryAppeared(object? sender, EventArgs e)
|
||||
@@ -191,20 +227,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void DisposeWatchers()
|
||||
{
|
||||
RegistryChangeWatcher? registryWatcher;
|
||||
OfficeBinaryWatcher? binaryWatcher;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_registryWatcher != null)
|
||||
{
|
||||
try { _registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { _registryWatcher.Dispose(); } catch { }
|
||||
_registryWatcher = null;
|
||||
}
|
||||
if (_binaryWatcher != null)
|
||||
{
|
||||
try { _binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { _binaryWatcher.Dispose(); } catch { }
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
registryWatcher = _registryWatcher;
|
||||
_registryWatcher = null;
|
||||
binaryWatcher = _binaryWatcher;
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
|
||||
if (registryWatcher != null)
|
||||
{
|
||||
try { registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { registryWatcher.Dispose(); } catch { }
|
||||
}
|
||||
if (binaryWatcher != null)
|
||||
{
|
||||
try { binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { binaryWatcher.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Tells <see cref="ProxyObserver"/> the moment proxy settings change, so the log has the exact
|
||||
/// time. A watcher that fails costs only that time: the observer compares again on every failed
|
||||
/// upload.
|
||||
/// </summary>
|
||||
internal sealed class ProxySettingsHost : ICollectorHost
|
||||
{
|
||||
private const string Source = "ProxySettingsHost";
|
||||
private const int DebounceMs = 1000;
|
||||
|
||||
// The whole subtree: "Connections" does not exist yet in an account nothing has written to.
|
||||
// HKCU of a SYSTEM process is HKU\S-1-5-18.
|
||||
private const string AccountSettingsKey = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
// Holds WinHttpSettings, which netsh winhttp writes.
|
||||
private const string MachineConnectionsKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections";
|
||||
|
||||
private const RegistryNativeMethods.RegChangeNotifyFilter Filter =
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.Name |
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.LastSet;
|
||||
|
||||
private readonly ProxyObserver _observer;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<RegistryWatcher> _watchers = new List<RegistryWatcher>();
|
||||
private readonly Timer _debounce;
|
||||
private int _started;
|
||||
private int _stopped;
|
||||
|
||||
public string Name => "ProxySettingsWatcher";
|
||||
|
||||
public ProxySettingsHost(
|
||||
ProxyObserver observer,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_observer = observer ?? throw new ArgumentNullException(nameof(observer));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_post = new InformationalEventPost(ingress, clock);
|
||||
_debounce = new Timer(_ => OnDebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
AddWatcher(RegistryHive.CurrentUser, AccountSettingsKey, watchSubtree: true, "account settings");
|
||||
AddWatcher(RegistryHive.LocalMachine, MachineConnectionsKey, watchSubtree: false, "WinHTTP settings");
|
||||
}
|
||||
|
||||
private void AddWatcher(RegistryHive hive, string subKey, bool watchSubtree, string label)
|
||||
{
|
||||
var watcher = new RegistryWatcher(hive, subKey, watchSubtree, RegistryView.Registry64, Filter);
|
||||
watcher.Changed += (_, _) => _debounce.Change(DebounceMs, Timeout.Infinite);
|
||||
watcher.Error += (_, ex) => _logger.Warning($"ProxySettingsHost: {label} watcher error - {ex.GetType().Name}: {ex.Message}");
|
||||
_watchers.Add(watcher);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) == 1) return;
|
||||
|
||||
_observer.SettingsChanged += OnSettingsChanged;
|
||||
foreach (var watcher in _watchers)
|
||||
{
|
||||
try { watcher.Start(); }
|
||||
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: a watcher failed to start: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Whatever moved between the baseline and the start of the watchers.
|
||||
OnDebounceElapsed();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _stopped, 1) == 1) return;
|
||||
|
||||
DisposeWatchers();
|
||||
_observer.SettingsChanged -= OnSettingsChanged;
|
||||
if (Volatile.Read(ref _started) == 1) _observer.LogOpenFailures();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_debounce.Dispose();
|
||||
}
|
||||
|
||||
private void DisposeWatchers()
|
||||
{
|
||||
foreach (var watcher in _watchers)
|
||||
{
|
||||
try { watcher.Dispose(); }
|
||||
catch (Exception ex) { _logger.Debug($"ProxySettingsHost: disposing a watcher threw: {ex.Message}"); }
|
||||
}
|
||||
_watchers.Clear();
|
||||
}
|
||||
|
||||
private void OnDebounceElapsed()
|
||||
{
|
||||
_logger.Trace("ProxySettingsHost: checking the proxy settings.");
|
||||
try { _observer.CheckForChange(); }
|
||||
catch (Exception ex) { _logger.Warning($"ProxySettingsHost: checking the proxy settings failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits in the spool if uploads are down, so the timeline explains the gap once they work
|
||||
/// again. The portal shows the newest event of this type.
|
||||
/// </summary>
|
||||
private void OnSettingsChanged(ProxySettings settings, string what, string route)
|
||||
{
|
||||
var data = settings.ToEventData();
|
||||
data["trigger"] = "changed";
|
||||
data["change"] = what;
|
||||
data["route"] = route;
|
||||
|
||||
_post.Emit(
|
||||
eventType: Constants.EventTypes.ProxyConfiguration,
|
||||
source: Source,
|
||||
message: $"Proxy settings changed: {what}",
|
||||
severity: EventSeverity.Warning,
|
||||
typedPayload: data);
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
-39
@@ -1,10 +1,12 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -21,11 +23,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// It observes the central signal rail (<see cref="SignalIngress.SignalPosted"/>, the same
|
||||
/// hook <c>GatherRuleExecutorHost</c> uses for phase_change triggers):
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Engage</b> when a signal reports the ESP phase as <c>AccountSetup</c>.</item>
|
||||
/// <item><b>Engage</b> when a signal reports the ESP phase as <c>AccountSetup</c> — unless the
|
||||
/// user-ESP page already closed in this enrollment (D-414). The page exit is latched in the
|
||||
/// <see cref="StartupEventGate"/>, because after an agent restart the AccountSetup phase
|
||||
/// reaches the rail again (IME logs it at the next sign-in, and the adapter's dedup lives
|
||||
/// per process).</item>
|
||||
/// <item><b>Release</b> on <see cref="DecisionSignalKind.EspExiting"/> once the AccountSetup
|
||||
/// registry shows progress (the real user-ESP page exit — see below), on
|
||||
/// <see cref="Stop"/> (session teardown), or on the safety-cap timer (a backstop so a
|
||||
/// device is never held awake for the agent's full max-lifetime).</item>
|
||||
/// <item><b>RealmJoin hand-over</b> (D-413): RealmJoin signals only exist when the tenant runs
|
||||
/// the RealmJoin Watcher. When RealmJoin was detected and is still Blank as the user-ESP
|
||||
/// page closes, the hold stays until RealmJoin leaves Blank (it keeps the device awake
|
||||
/// itself from there), bounded by <see cref="RealmJoinStartWaitMinutes"/>. RealmJoin
|
||||
/// leaving Blank ends any hold; once it has, the host does not engage in this process.</item>
|
||||
/// </list>
|
||||
/// The hold itself is owned by <see cref="KeepAwakeController"/>; the OS also auto-clears it
|
||||
/// on process exit / reboot, so it can never leak.
|
||||
@@ -57,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// than host construction) means the ESP policy is already present in the registry. When the
|
||||
/// value is unavailable (ESP skipped, WDP v2 with no ESP, or unreadable) the cap falls back to
|
||||
/// <see cref="DefaultFallbackCapMinutes"/>. The resolved cap and its source are reported on the
|
||||
/// <c>keep_awake_engaged</c> event for fleet-wide observability.
|
||||
/// <c>keep_awake_engaged</c> event for fleet-wide observability. The RealmJoin wait replaces
|
||||
/// this cap when the page closes: one timer, one bound per phase of the hold.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class UserEspKeepAwakeHost : ICollectorHost
|
||||
@@ -78,9 +90,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
internal const int EspCapMarginMinutes = 30;
|
||||
|
||||
/// <summary>
|
||||
/// How long the hold waits for RealmJoin to leave Blank after the user-ESP page closed
|
||||
/// (D-413 has the measured start delays behind the value).
|
||||
/// </summary>
|
||||
internal const int RealmJoinStartWaitMinutes = 30;
|
||||
|
||||
/// <summary><see cref="StartupEventGate"/> latch: the user-ESP page closed in this enrollment (D-414).</summary>
|
||||
internal const string UserEspPageClosedKey = "keep_awake.user_esp_page_closed";
|
||||
|
||||
// Release reasons (carried on the keep_awake_released event payload). "account_setup_complete"
|
||||
// (release on the synthesized completion signal) was retired with q8n — see the class doc.
|
||||
internal const string ReasonUserEspExited = "user_esp_exited";
|
||||
internal const string ReasonRealmJoinStarted = "realmjoin_started";
|
||||
internal const string ReasonRealmJoinNotStarted = "realmjoin_not_started";
|
||||
internal const string ReasonHostStop = "host_stop";
|
||||
internal const string ReasonSafetyCap = "safety_cap";
|
||||
|
||||
@@ -92,13 +115,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private static readonly string AccountSetupPhaseName = EnrollmentPhase.AccountSetup.ToString();
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly KeepAwakeController _controller;
|
||||
private readonly SignalIngress? _observableIngress;
|
||||
private readonly int _fallbackCapMinutes;
|
||||
private readonly TimeSpan? _safetyCapOverride;
|
||||
private readonly TimeSpan _realmJoinStartWait;
|
||||
private readonly Func<int?> _espTimeoutProvider;
|
||||
private readonly Func<bool> _accountSetupProgressProbe;
|
||||
private readonly StartupEventGate? _startupGate;
|
||||
|
||||
private enum HoldState { Idle, Engaging, Engaged, Released }
|
||||
|
||||
@@ -107,7 +133,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private Timer? _safetyCapTimer;
|
||||
private HoldState _state = HoldState.Idle; // guarded by _gate
|
||||
private int _activeCapMinutes; // guarded by _gate — for the cap-elapsed log line
|
||||
private int _capGeneration; // guarded by _gate — a superseded timer callback is ignored
|
||||
private bool _awaitingRealmJoin; // guarded by _gate — page closed, RealmJoin still Blank
|
||||
private DateTime _userEspExitedUtc; // guarded by _gate — valid while _awaitingRealmJoin
|
||||
private int _disposed;
|
||||
private int _pageClosedLatched; // 0/1 — the latch write runs once per process
|
||||
|
||||
// RealmJoin observations of this process. Monotonic, written on the posting thread so the
|
||||
// ThreadPool work that follows always sees them (0/1 for Volatile/Interlocked).
|
||||
private int _realmJoinDetected;
|
||||
private int _realmJoinStarted;
|
||||
|
||||
public UserEspKeepAwakeHost(
|
||||
ISignalIngressSink ingress,
|
||||
@@ -117,17 +152,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
KeepAwakeController? controller = null,
|
||||
TimeSpan? safetyCapOverride = null,
|
||||
Func<int?>? espTimeoutProvider = null,
|
||||
Func<bool>? accountSetupProgressProbe = null)
|
||||
Func<bool>? accountSetupProgressProbe = null,
|
||||
TimeSpan? realmJoinStartWaitOverride = null,
|
||||
StartupEventGate? startupGate = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_post = new InformationalEventPost(ingress, clock, logger);
|
||||
_controller = controller ?? new KeepAwakeController(logger);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
_fallbackCapMinutes = fallbackCapMinutes > 0 ? fallbackCapMinutes : DefaultFallbackCapMinutes;
|
||||
// safetyCapOverride is a test seam for a fixed (e.g. sub-second) cap.
|
||||
// safetyCapOverride / realmJoinStartWaitOverride are test seams for fixed (e.g. sub-second) bounds.
|
||||
_safetyCapOverride = safetyCapOverride;
|
||||
_realmJoinStartWait = realmJoinStartWaitOverride ?? TimeSpan.FromMinutes(RealmJoinStartWaitMinutes);
|
||||
// The ESP SyncFailureTimeout is read at engage time (the ESP policy is present by then).
|
||||
_espTimeoutProvider = espTimeoutProvider
|
||||
?? (() => EspSkipConfigurationProbe.ReadFull(_logger).SyncFailureTimeoutMinutes);
|
||||
@@ -135,6 +173,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// lasts until the safety cap (erring long, never early). Production always wires the
|
||||
// EspAndHelloHost probe (DefaultComponentFactory).
|
||||
_accountSetupProgressProbe = accountSetupProgressProbe ?? (() => false);
|
||||
// Without a gate (tests) the page-closed latch is off and every agent run engages anew.
|
||||
_startupGate = startupGate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -180,21 +220,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// (which briefly waits on the keep-awake thread) is dispatched to the ThreadPool.
|
||||
try
|
||||
{
|
||||
if (kind == DecisionSignalKind.EspExiting)
|
||||
switch (kind)
|
||||
{
|
||||
// Only the user-ESP page exit releases: the Device→Account handoff 62407 arrives
|
||||
// before any AccountSetup subcategory has left notStarted, the real exit after.
|
||||
// Cheap to skip while idle; the probe is a locked dictionary scan.
|
||||
if (IsEngagedOrEngaging() && ProbeAccountSetupProgress())
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeRelease(ReasonUserEspExited, EventSeverity.Info));
|
||||
return;
|
||||
case DecisionSignalKind.EspExiting:
|
||||
// Only the user-ESP page exit counts: the Device→Account handoff 62407 arrives
|
||||
// before any AccountSetup subcategory has left notStarted, the real exit after.
|
||||
// Checked in every hold state — the page-closed latch (D-414) must also be set
|
||||
// while idle, capped or waiting. The probe is a locked dictionary scan.
|
||||
if (ProbeAccountSetupProgress())
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeRun(OnUserEspPageClosed, "user-ESP exit"));
|
||||
return;
|
||||
|
||||
case DecisionSignalKind.RealmJoinDetected:
|
||||
case DecisionSignalKind.RealmJoinPhaseChanged:
|
||||
case DecisionSignalKind.RealmJoinResolved:
|
||||
ObserveRealmJoin(kind, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload != null
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EspPhase, out var phaseName)
|
||||
&& string.Equals(phaseName, AccountSetupPhaseName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeEngage());
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeRun(Engage, "engage"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -203,15 +251,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
private void SafeEngage()
|
||||
/// <summary>
|
||||
/// Records what the RealmJoin Watcher reported. The flags are set here, on the posting
|
||||
/// thread, before any work is queued: the ThreadPool items run in no fixed order, and each
|
||||
/// re-checks the hold state under <see cref="_gate"/>, so no hand-over is lost.
|
||||
/// </summary>
|
||||
private void ObserveRealmJoin(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
try { Engage(); }
|
||||
catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: engage failed.", ex); }
|
||||
Volatile.Write(ref _realmJoinDetected, 1);
|
||||
|
||||
// Resolved is phase 110 by definition; Detected and PhaseChanged carry the phase.
|
||||
var leftBlank = kind == DecisionSignalKind.RealmJoinResolved
|
||||
|| (payload != null
|
||||
&& payload.TryGetValue(DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase, out var raw)
|
||||
&& int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var phase)
|
||||
&& phase >= RealmJoinInfo.PhaseRunningThresholdMin);
|
||||
|
||||
if (leftBlank && Interlocked.Exchange(ref _realmJoinStarted, 1) == 0)
|
||||
ThreadPool.QueueUserWorkItem(_ => SafeRun(OnRealmJoinStarted, "RealmJoin hand-over"));
|
||||
}
|
||||
|
||||
private bool IsEngagedOrEngaging()
|
||||
private void SafeRun(Action work, string what)
|
||||
{
|
||||
lock (_gate) { return _state == HoldState.Engaging || _state == HoldState.Engaged; }
|
||||
try { work(); }
|
||||
catch (Exception ex) { _logger.Error($"UserEspKeepAwakeHost: {what} failed.", ex); }
|
||||
}
|
||||
|
||||
private bool ProbeAccountSetupProgress()
|
||||
@@ -224,12 +287,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
private void SafeRelease(string reason, EventSeverity severity)
|
||||
{
|
||||
try { Release(reason, severity, emit: true); }
|
||||
catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: release failed.", ex); }
|
||||
}
|
||||
|
||||
/// <summary>Engage the hold exactly once (Idle → Engaging → Engaged). No-op from any other state.</summary>
|
||||
private void Engage()
|
||||
{
|
||||
@@ -239,6 +296,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state != HoldState.Idle) return;
|
||||
if (UserEspPageClosedEarlier())
|
||||
{
|
||||
// Typically after an agent restart: the AccountSetup phase reached the rail
|
||||
// again, but the user-ESP page of this enrollment is already gone.
|
||||
_state = HoldState.Released;
|
||||
_logger.Info("UserEspKeepAwakeHost: the user-ESP page already closed in this enrollment — keep-awake not engaged.");
|
||||
return;
|
||||
}
|
||||
if (Volatile.Read(ref _realmJoinStarted) == 1)
|
||||
{
|
||||
// Only after an agent restart: RealmJoin deploys (or is done) before the
|
||||
// AccountSetup phase is seen again — nothing left to hand over.
|
||||
_state = HoldState.Released;
|
||||
_logger.Info("UserEspKeepAwakeHost: RealmJoin has already started its deployment — keep-awake not engaged.");
|
||||
return;
|
||||
}
|
||||
// L16 (delta review 2026-07-02): claim the transition, but run the controller's
|
||||
// Engage OUTSIDE the lock — it can block up to ~5 s on the keep-awake thread's
|
||||
// ready-wait, and holding _gate across that stalled a concurrent Stop→Release.
|
||||
@@ -272,8 +345,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
|
||||
_activeCapMinutes = capMinutes;
|
||||
// One-shot backstop (NOT periodic): fire once after the cap, then release.
|
||||
_safetyCapTimer = new Timer(OnSafetyCapElapsed, null, cap, Timeout.InfiniteTimeSpan);
|
||||
// One-shot backstop (NOT periodic). The page may have closed while engaging — then
|
||||
// the RealmJoin wait is the bound already.
|
||||
ArmCapTimer(_awaitingRealmJoin ? _realmJoinStartWait : cap);
|
||||
_state = HoldState.Engaged;
|
||||
}
|
||||
|
||||
@@ -281,7 +355,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
["osAccepted"] = osAccepted ? "true" : "false",
|
||||
["scope"] = "system+display",
|
||||
["capMinutes"] = capMinutes.ToString(),
|
||||
["capMinutes"] = capMinutes.ToString(CultureInfo.InvariantCulture),
|
||||
["capSource"] = capSource,
|
||||
});
|
||||
}
|
||||
@@ -296,41 +370,151 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The user-ESP page closed. The fact is latched for later agent runs first. Without
|
||||
/// RealmJoin, or with RealmJoin already past Blank, the hold ends here. With RealmJoin still
|
||||
/// Blank it waits for the hand-over: the ESP-sized cap gives way to the RealmJoin wait.
|
||||
/// </summary>
|
||||
private void OnUserEspPageClosed()
|
||||
{
|
||||
LatchUserEspPageClosed();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state != HoldState.Engaging && _state != HoldState.Engaged) return;
|
||||
if (_awaitingRealmJoin) return; // a repeated exit signal
|
||||
|
||||
if (Volatile.Read(ref _realmJoinDetected) == 1 && Volatile.Read(ref _realmJoinStarted) == 0)
|
||||
{
|
||||
_awaitingRealmJoin = true;
|
||||
_userEspExitedUtc = _clock.UtcNow;
|
||||
// While Engaging, Engage arms the wait when it completes.
|
||||
if (_state == HoldState.Engaged) ArmCapTimer(_realmJoinStartWait);
|
||||
_logger.Info($"UserEspKeepAwakeHost: user-ESP page closed before RealmJoin started its deployment — holding keep-awake until it starts (max {_realmJoinStartWait.TotalMinutes:0} min).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Release(ReasonUserEspExited, EventSeverity.Info);
|
||||
}
|
||||
|
||||
// MarkSucceeded persists at once (fail-soft): the page is gone for this enrollment.
|
||||
private void LatchUserEspPageClosed()
|
||||
{
|
||||
if (_startupGate == null || Interlocked.Exchange(ref _pageClosedLatched, 1) == 1) return;
|
||||
try { _startupGate.MarkSucceeded(UserEspPageClosedKey); }
|
||||
catch (Exception ex) { _logger.Debug($"UserEspKeepAwakeHost: page-closed latch write threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// In-memory lookup (no I/O) — safe under _gate.
|
||||
private bool UserEspPageClosedEarlier()
|
||||
{
|
||||
if (_startupGate == null) return false;
|
||||
try { return _startupGate.AlreadySucceeded(UserEspPageClosedKey); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"UserEspKeepAwakeHost: page-closed latch read threw: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>RealmJoin left Blank — it keeps the device awake from here.</summary>
|
||||
private void OnRealmJoinStarted()
|
||||
{
|
||||
Dictionary<string, string>? extra = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == HoldState.Released) return;
|
||||
if (_state == HoldState.Idle)
|
||||
{
|
||||
// Only after an agent restart (RealmJoin starts after the user-ESP page closed):
|
||||
// no hold to hand over, and none to take up later in this process.
|
||||
_state = HoldState.Released;
|
||||
_logger.Info("UserEspKeepAwakeHost: RealmJoin has already started its deployment — keep-awake will not engage.");
|
||||
return;
|
||||
}
|
||||
if (_awaitingRealmJoin)
|
||||
{
|
||||
var waited = Math.Max(0, (_clock.UtcNow - _userEspExitedUtc).TotalSeconds);
|
||||
extra = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["waitedSeconds"] = ((long)waited).ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Release(ReasonRealmJoinStarted, EventSeverity.Info, extra);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release the hold exactly once (Idle/Engaging/Engaged → Released). Idempotent — a second
|
||||
/// call (or a competing completion / cap / stop trigger) is a no-op. A release that lands
|
||||
/// while an Engage is mid-flight (Engaging) wins: the engage path detects the final state
|
||||
/// after its blocking controller call and rolls the OS hold back (L16).
|
||||
/// </summary>
|
||||
private void Release(string reason, EventSeverity severity, bool emit)
|
||||
private void Release(string reason, EventSeverity severity, IReadOnlyDictionary<string, string>? extra = null)
|
||||
{
|
||||
bool wasEngaged;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == HoldState.Released) return;
|
||||
DisarmSafetyCap();
|
||||
DisarmCapTimer();
|
||||
wasEngaged = _controller.Release();
|
||||
_state = HoldState.Released;
|
||||
}
|
||||
|
||||
if (wasEngaged && emit)
|
||||
if (!wasEngaged) return;
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal) { ["reason"] = reason };
|
||||
if (extra != null)
|
||||
{
|
||||
Emit(Constants.EventTypes.KeepAwakeReleased, severity, new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["reason"] = reason,
|
||||
});
|
||||
foreach (var kv in extra) data[kv.Key] = kv.Value;
|
||||
}
|
||||
Emit(Constants.EventTypes.KeepAwakeReleased, severity, data);
|
||||
}
|
||||
|
||||
private void OnSafetyCapElapsed(object? state)
|
||||
private void OnCapTimerElapsed(object? timerState)
|
||||
{
|
||||
_logger.Warning($"UserEspKeepAwakeHost: safety cap ({_activeCapMinutes} min) elapsed without User-ESP completion — releasing keep-awake.");
|
||||
SafeRelease(ReasonSafetyCap, EventSeverity.Warning);
|
||||
string reason;
|
||||
EventSeverity severity;
|
||||
Dictionary<string, string>? extra = null;
|
||||
lock (_gate)
|
||||
{
|
||||
// A timer replaced at the page exit can still fire once — only the current one counts.
|
||||
if (!(timerState is int generation) || generation != _capGeneration || _state == HoldState.Released) return;
|
||||
|
||||
if (_awaitingRealmJoin)
|
||||
{
|
||||
reason = ReasonRealmJoinNotStarted;
|
||||
severity = EventSeverity.Info;
|
||||
extra = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["waitMinutes"] = ((int)Math.Round(_realmJoinStartWait.TotalMinutes)).ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
_logger.Info($"UserEspKeepAwakeHost: RealmJoin did not start within {_realmJoinStartWait.TotalMinutes:0} min after the user-ESP page closed — releasing keep-awake.");
|
||||
}
|
||||
else
|
||||
{
|
||||
reason = ReasonSafetyCap;
|
||||
severity = EventSeverity.Warning;
|
||||
_logger.Warning($"UserEspKeepAwakeHost: safety cap ({_activeCapMinutes} min) elapsed without User-ESP completion — releasing keep-awake.");
|
||||
}
|
||||
}
|
||||
|
||||
SafeRun(() => Release(reason, severity, extra), "release");
|
||||
}
|
||||
|
||||
// Must be called under _gate.
|
||||
private void DisarmSafetyCap()
|
||||
private void ArmCapTimer(TimeSpan dueIn)
|
||||
{
|
||||
DisarmCapTimer();
|
||||
_safetyCapTimer = new Timer(OnCapTimerElapsed, _capGeneration, dueIn, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
// Must be called under _gate.
|
||||
private void DisarmCapTimer()
|
||||
{
|
||||
_capGeneration++;
|
||||
var timer = _safetyCapTimer;
|
||||
_safetyCapTimer = null;
|
||||
try { timer?.Dispose(); } catch { }
|
||||
@@ -369,7 +553,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
// Release synchronously on teardown. If a completion/cap release already won, this is a
|
||||
// no-op (state already Released). Emit is best-effort — the ingress may already be down.
|
||||
Release(ReasonHostStop, EventSeverity.Info, emit: true);
|
||||
Release(ReasonHostStop, EventSeverity.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -378,5 +562,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
Stop();
|
||||
try { _controller.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
// Test seam — the page closed while RealmJoin was still Blank and the hold now waits for it.
|
||||
internal bool AwaitingRealmJoinForTest
|
||||
{
|
||||
get { lock (_gate) { return _awaitingRealmJoin; } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -324,7 +324,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// DeliveryOptimizationCollector, …) before the diagnostics ZIP is built so
|
||||
// late `performance_snapshot` / `agent_metrics_snapshot` events don't slip in
|
||||
// after `diagnostics_collecting` and the snapshot captured in the package
|
||||
// matches the timeline. Best-effort — never blocks termination.
|
||||
// matches the timeline. Best-effort, and bounded per host by the orchestrator:
|
||||
// a collector that hangs is left behind, so the upload and the cleanup below
|
||||
// still run.
|
||||
StopPeripheralCollectorsBestEffort();
|
||||
|
||||
RunUploadDiagnosticsWithEvents(args);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Makes the agent log say which way uploads go and what happened to it. Something on the device
|
||||
/// can change the proxy settings of the agent's account; .NET follows at once, and from then on
|
||||
/// no channel may reach the backend, so the log on the device is the only witness.
|
||||
/// <para>
|
||||
/// Compares with what held at the start instead of judging a value: "no proxy" is the right
|
||||
/// state on one network and the broken one on the next. Reads on failures and on registry
|
||||
/// changes, never by polling. Nothing here throws into the upload path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ProxyObserver
|
||||
{
|
||||
internal const string RouteUnknown = "unknown";
|
||||
|
||||
private static readonly TimeSpan DefaultRouteBound = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly Func<ProxySettings> _readSettings;
|
||||
private readonly Func<string> _readRoute;
|
||||
private readonly IClock _clock;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly TimeSpan _routeBound;
|
||||
private readonly object _sync = new object();
|
||||
|
||||
private ProxySettings? _current;
|
||||
private string _lastRoute = RouteUnknown;
|
||||
private Task<string>? _routeRead;
|
||||
private DateTime? _uploadChangeAtUtc;
|
||||
private string? _uploadChange;
|
||||
private DateTime? _failingSinceUtc;
|
||||
private int _failedAttempts;
|
||||
private DateTime? _lastSuccessUtc;
|
||||
|
||||
/// <summary>Settings now, what changed, route now. Raised outside the lock, on the caller's thread.</summary>
|
||||
internal event Action<ProxySettings, string, string>? SettingsChanged;
|
||||
|
||||
public ProxyObserver(string baseUrl, AgentLogger logger, IClock clock)
|
||||
: this(
|
||||
ProxySettingsReader.Read,
|
||||
RouteReaderFor(baseUrl),
|
||||
logger,
|
||||
clock,
|
||||
DefaultRouteBound)
|
||||
{
|
||||
}
|
||||
|
||||
internal ProxyObserver(
|
||||
Func<ProxySettings> readSettings,
|
||||
Func<string> readRoute,
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
TimeSpan routeBound)
|
||||
{
|
||||
_readSettings = readSettings ?? throw new ArgumentNullException(nameof(readSettings));
|
||||
_readRoute = readRoute ?? throw new ArgumentNullException(nameof(readRoute));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_routeBound = routeBound;
|
||||
}
|
||||
|
||||
private static Func<string> RouteReaderFor(string baseUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var target)) return () => RouteUnknown;
|
||||
return () => ProxySettingsReader.ReadRoute(target);
|
||||
}
|
||||
|
||||
/// <summary>Logs the state to compare against. Runs before the first request of the agent.</summary>
|
||||
public void CaptureBaseline()
|
||||
{
|
||||
var settings = ReadSettingsSafe();
|
||||
var route = ReadRouteBounded();
|
||||
lock (_sync)
|
||||
{
|
||||
if (_current == null && !settings.IsUnreadable) _current = settings;
|
||||
_lastRoute = route;
|
||||
}
|
||||
// Same text on a rebuild of the clients after an endpoint migration: the repeat goes to Trace.
|
||||
_logger.Info($"ProxyObserver: start - {settings.DescribeAll()}; route to backend: {route}", repeatKey: "ProxyObserver.baseline");
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the settings and reports a change of content once, whoever notices it first.</summary>
|
||||
public void CheckForChange()
|
||||
{
|
||||
var settings = ReadSettingsSafe();
|
||||
if (settings.IsUnreadable) return;
|
||||
|
||||
ProxySettings previous;
|
||||
var affectsUploads = false;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_current == null)
|
||||
{
|
||||
_current = settings;
|
||||
return;
|
||||
}
|
||||
if (_current.Equals(settings)) return;
|
||||
|
||||
previous = _current;
|
||||
_current = settings;
|
||||
if (settings.DiffersForUploadsFrom(previous))
|
||||
{
|
||||
affectsUploads = true;
|
||||
_uploadChangeAtUtc = _clock.UtcNow;
|
||||
_uploadChange = settings.DescribeUploadChangeFrom(previous);
|
||||
}
|
||||
}
|
||||
|
||||
var route = ReadRouteBounded();
|
||||
string routeBefore;
|
||||
lock (_sync)
|
||||
{
|
||||
routeBefore = _lastRoute;
|
||||
_lastRoute = route;
|
||||
}
|
||||
|
||||
var what = settings.DescribeChangeFrom(previous);
|
||||
_logger.Warning(affectsUploads
|
||||
? $"ProxyObserver: proxy settings changed - {what}; route to backend: {(routeBefore == route ? route : $"{routeBefore} -> {route}")}. " +
|
||||
"The agent's uploads follow these settings. Check with: bitsadmin /util /getieproxy localsystem"
|
||||
: $"ProxyObserver: proxy settings changed - {what}. The agent's uploads do not use WinHTTP, services that rely on its default do.");
|
||||
|
||||
try
|
||||
{
|
||||
SettingsChanged?.Invoke(settings, what, route);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ProxyObserver: change handler threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The next hop to the backend: <c>direct</c>, <c>host:port</c> or <c>unknown</c>.</summary>
|
||||
public string RouteText() => ReadRouteBounded();
|
||||
|
||||
/// <summary>
|
||||
/// An upload did not reach the backend. The first of a series says what the reader needs to
|
||||
/// know: when it began and whether the proxy settings moved before that.
|
||||
/// </summary>
|
||||
public void OnSendFailed(TimeSpan attemptDuration)
|
||||
{
|
||||
// Also catches a change the registry watcher did not report.
|
||||
CheckForChange();
|
||||
|
||||
DateTime since;
|
||||
DateTime? lastSuccess;
|
||||
DateTime? changedAt;
|
||||
string? change;
|
||||
string? current;
|
||||
lock (_sync)
|
||||
{
|
||||
_failedAttempts++;
|
||||
if (_failingSinceUtc != null) return;
|
||||
|
||||
since = _clock.UtcNow - attemptDuration;
|
||||
_failingSinceUtc = since;
|
||||
lastSuccess = _lastSuccessUtc;
|
||||
changedAt = _uploadChangeAtUtc;
|
||||
change = _uploadChange;
|
||||
current = _current?.Describe();
|
||||
}
|
||||
|
||||
var route = ReadRouteBounded();
|
||||
var lastUpload = lastSuccess == null ? "No successful upload yet." : $"Last successful upload {Time(lastSuccess.Value)}.";
|
||||
|
||||
if (changedAt != null && (lastSuccess == null || changedAt > lastSuccess))
|
||||
{
|
||||
_logger.Warning(
|
||||
$"ProxyObserver: uploads are failing since {Time(since)}, {Span(since - changedAt.Value)} after the proxy settings changed at {Time(changedAt.Value)} ({change}). " +
|
||||
$"Route to backend: {route}. {lastUpload}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var unchangedSince = changedAt == null ? "start" : "the last successful upload";
|
||||
_logger.Warning(
|
||||
$"ProxyObserver: uploads are failing since {Time(since)}. Proxy settings of this account unchanged since {unchangedSince}: {current ?? RouteUnknown}. " +
|
||||
$"Route to backend: {route}. {lastUpload}");
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSendSucceeded()
|
||||
{
|
||||
DateTime since;
|
||||
int attempts;
|
||||
lock (_sync)
|
||||
{
|
||||
_lastSuccessUtc = _clock.UtcNow;
|
||||
if (_failingSinceUtc == null) return;
|
||||
|
||||
since = _failingSinceUtc.Value;
|
||||
attempts = _failedAttempts;
|
||||
_failingSinceUtc = null;
|
||||
_failedAttempts = 0;
|
||||
}
|
||||
|
||||
CheckForChange();
|
||||
_logger.Info($"ProxyObserver: uploads work again after {attempts} failed attempt(s) over {Span(_clock.UtcNow - since)}; route to backend: {ReadRouteBounded()}.");
|
||||
}
|
||||
|
||||
/// <summary>The end of the log is read first, so a run that ends while failing says so there.</summary>
|
||||
public void LogOpenFailures()
|
||||
{
|
||||
DateTime since;
|
||||
int attempts;
|
||||
DateTime? lastSuccess;
|
||||
DateTime? changedAt;
|
||||
string? change;
|
||||
string? current;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_failingSinceUtc == null) return;
|
||||
|
||||
since = _failingSinceUtc.Value;
|
||||
attempts = _failedAttempts;
|
||||
lastSuccess = _lastSuccessUtc;
|
||||
changedAt = _uploadChangeAtUtc;
|
||||
change = _uploadChange;
|
||||
current = _current?.Describe();
|
||||
}
|
||||
|
||||
var lastUpload = lastSuccess == null ? "no successful upload" : $"last success {Time(lastSuccess.Value)}";
|
||||
var settings = changedAt == null
|
||||
? $"Proxy settings of this account unchanged since start: {current ?? RouteUnknown}."
|
||||
: $"Proxy settings changed at {Time(changedAt.Value)} ({change}).";
|
||||
_logger.Warning(
|
||||
$"ProxyObserver: stopping with uploads still failing: {attempts} failed attempt(s) since {Time(since)}, {lastUpload}, route to backend: {ReadRouteBounded()}. {settings}");
|
||||
}
|
||||
|
||||
private ProxySettings ReadSettingsSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = _readSettings();
|
||||
if (settings.IsUnreadable) _logger.Debug($"ProxyObserver: proxy settings {settings.Describe()}", repeatKey: "ProxyObserver.unreadable");
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ProxyObserver: reading the proxy settings threw: {ex.Message}", repeatKey: "ProxyObserver.readThrew");
|
||||
return ProxySettings.Unreadable(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-detect can hang and must hold up neither the start nor an upload. A read still under
|
||||
/// way is joined, so a hanging one does not pile up pool threads.
|
||||
/// </summary>
|
||||
private string ReadRouteBounded()
|
||||
{
|
||||
Task<string> read;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_routeRead == null || _routeRead.IsCompleted) _routeRead = Task.Run(ReadRouteSafe);
|
||||
read = _routeRead;
|
||||
}
|
||||
return read.Wait(_routeBound) ? read.Result : RouteUnknown;
|
||||
}
|
||||
|
||||
private string ReadRouteSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _readRoute();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ProxyObserver: reading the route threw: {ex.Message}", repeatKey: "ProxyObserver.routeThrew");
|
||||
return RouteUnknown;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Time(DateTime utc) => utc.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Span(TimeSpan span)
|
||||
{
|
||||
var seconds = Math.Max(0, (long)span.TotalSeconds);
|
||||
return seconds < 120 ? $"{seconds} s" : $"{seconds / 60} min";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// The proxy settings Windows holds for the account this process runs as (what
|
||||
/// <c>bitsadmin /util /getieproxy localsystem</c> shows for SYSTEM and what .NET uses), plus the
|
||||
/// WinHTTP proxy (<c>netsh winhttp show proxy</c>). Compared by content, because WinINET rewrites
|
||||
/// its settings blob without changing anything. The values end up in logs and events, so
|
||||
/// credentials and PAC query strings are kept out on the way in.
|
||||
/// </summary>
|
||||
internal sealed class ProxySettings : IEquatable<ProxySettings>
|
||||
{
|
||||
internal const string CredentialsHidden = "(value with credentials, not shown)";
|
||||
|
||||
public bool AutoDetect { get; }
|
||||
public string PacUrl { get; }
|
||||
public string Proxy { get; }
|
||||
public string Bypass { get; }
|
||||
public string WinHttpProxy { get; }
|
||||
|
||||
/// <summary>Win32 error of a failed read; null when the settings were read.</summary>
|
||||
public int? ReadError { get; }
|
||||
|
||||
public bool IsUnreadable => ReadError != null;
|
||||
|
||||
public ProxySettings(bool autoDetect, string pacUrl, string proxy, string bypass, string winHttpProxy)
|
||||
{
|
||||
AutoDetect = autoDetect;
|
||||
PacUrl = SanitizeUrl(pacUrl);
|
||||
Proxy = HideCredentials(proxy);
|
||||
Bypass = EmptyToNull(bypass);
|
||||
WinHttpProxy = HideCredentials(winHttpProxy);
|
||||
}
|
||||
|
||||
private ProxySettings(int readError)
|
||||
{
|
||||
ReadError = readError;
|
||||
}
|
||||
|
||||
public static ProxySettings Unreadable(int win32Error) => new ProxySettings(win32Error);
|
||||
|
||||
/// <summary>Kept in step with ANALYZE-CORR-003, which matches <c>Proxy|PAC</c>.</summary>
|
||||
public string ProxyType =>
|
||||
IsUnreadable ? "Unknown"
|
||||
: Proxy != null ? "Proxy"
|
||||
: PacUrl != null ? "PAC"
|
||||
: AutoDetect ? "AutoDetect"
|
||||
: "Direct";
|
||||
|
||||
public string Describe()
|
||||
{
|
||||
if (IsUnreadable) return $"unreadable (Win32 error {ReadError})";
|
||||
|
||||
var parts = new List<string>(3);
|
||||
if (AutoDetect) parts.Add("auto-detect");
|
||||
if (PacUrl != null) parts.Add($"PAC {PacUrl}");
|
||||
if (Proxy != null) parts.Add($"proxy {Proxy}");
|
||||
return parts.Count == 0 ? "no proxy" : string.Join(", ", parts);
|
||||
}
|
||||
|
||||
public string DescribeWinHttp() => WinHttpProxy == null ? "direct" : $"proxy {WinHttpProxy}";
|
||||
|
||||
public string DescribeAll() => $"IE settings of this account: {Describe()}; WinHTTP: {DescribeWinHttp()}";
|
||||
|
||||
/// <summary>The account's own settings, which the agent's uploads follow; WinHTTP is apart.</summary>
|
||||
public bool DiffersForUploadsFrom(ProxySettings other) =>
|
||||
AutoDetect != other.AutoDetect
|
||||
|| PacUrl != other.PacUrl
|
||||
|| Proxy != other.Proxy
|
||||
|| Bypass != other.Bypass;
|
||||
|
||||
public string DescribeUploadChangeFrom(ProxySettings previous) =>
|
||||
Describe() == previous.Describe()
|
||||
? "IE settings of this account: proxy bypass list changed"
|
||||
: $"IE settings of this account: {previous.Describe()} -> {Describe()}";
|
||||
|
||||
public string DescribeChangeFrom(ProxySettings previous)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
if (DiffersForUploadsFrom(previous)) parts.Add(DescribeUploadChangeFrom(previous));
|
||||
if (WinHttpProxy != previous.WinHttpProxy) parts.Add($"WinHTTP: {previous.DescribeWinHttp()} -> {DescribeWinHttp()}");
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
/// <summary>Payload of the <c>proxy_configuration</c> event.</summary>
|
||||
public Dictionary<string, object> ToEventData() => new Dictionary<string, object>
|
||||
{
|
||||
{ "proxyType", ProxyType },
|
||||
{ "autoDetect", AutoDetect },
|
||||
{ "proxyEnabled", Proxy != null },
|
||||
{ "proxyServer", Proxy },
|
||||
{ "proxyOverride", Bypass },
|
||||
{ "autoConfigUrl", PacUrl },
|
||||
{ "winHttpProxyConfigured", WinHttpProxy != null },
|
||||
{ "winHttpProxy", WinHttpProxy },
|
||||
};
|
||||
|
||||
public bool Equals(ProxySettings other) =>
|
||||
other != null
|
||||
&& ReadError == other.ReadError
|
||||
&& !DiffersForUploadsFrom(other)
|
||||
&& WinHttpProxy == other.WinHttpProxy;
|
||||
|
||||
public override bool Equals(object obj) => Equals(obj as ProxySettings);
|
||||
|
||||
public override int GetHashCode() => (AutoDetect, PacUrl, Proxy, Bypass, WinHttpProxy, ReadError).GetHashCode();
|
||||
|
||||
private static string EmptyToNull(string value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
/// <summary>Scheme, host, port and path. A query string can carry a key; an unparsable URL is not repeated.</summary>
|
||||
private static string SanitizeUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return null;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || string.IsNullOrEmpty(uri.Host)) return "(unparsable URL)";
|
||||
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
|
||||
return $"{uri.Scheme}://{uri.Host}{port}{uri.AbsolutePath}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A proxy string has no defined form for credentials and a password can hold <c>=</c>,
|
||||
/// <c>;</c> or <c>://</c>, so a value with an <c>@</c> is replaced as a whole, not taken apart.
|
||||
/// </summary>
|
||||
private static string HideCredentials(string proxy)
|
||||
{
|
||||
if (string.IsNullOrEmpty(proxy)) return null;
|
||||
return proxy.IndexOf('@') < 0 ? proxy : CredentialsHidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport
|
||||
{
|
||||
internal static class ProxySettingsReader
|
||||
{
|
||||
public const string RouteDirect = "direct";
|
||||
|
||||
/// <summary>Reads the registry through WinHTTP, no network. Does not throw on a failed read.</summary>
|
||||
public static ProxySettings Read()
|
||||
{
|
||||
var ie = new WinHttpNativeMethods.WINHTTP_CURRENT_USER_IE_PROXY_CONFIG();
|
||||
var winHttp = new WinHttpNativeMethods.WINHTTP_PROXY_INFO();
|
||||
try
|
||||
{
|
||||
if (!WinHttpNativeMethods.WinHttpGetIEProxyConfigForCurrentUser(ref ie))
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
// The account has never had settings written, so nothing configures a proxy.
|
||||
if (error != WinHttpNativeMethods.ERROR_FILE_NOT_FOUND) return ProxySettings.Unreadable(error);
|
||||
}
|
||||
|
||||
if (!WinHttpNativeMethods.WinHttpGetDefaultProxyConfiguration(ref winHttp))
|
||||
{
|
||||
return ProxySettings.Unreadable(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
return new ProxySettings(
|
||||
autoDetect: ie.fAutoDetect,
|
||||
pacUrl: Marshal.PtrToStringUni(ie.lpszAutoConfigUrl),
|
||||
proxy: Marshal.PtrToStringUni(ie.lpszProxy),
|
||||
bypass: Marshal.PtrToStringUni(ie.lpszProxyBypass),
|
||||
winHttpProxy: winHttp.dwAccessType == WinHttpNativeMethods.WINHTTP_ACCESS_TYPE_NAMED_PROXY
|
||||
? Marshal.PtrToStringUni(winHttp.lpszProxy)
|
||||
: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Free(ie.lpszAutoConfigUrl);
|
||||
Free(ie.lpszProxy);
|
||||
Free(ie.lpszProxyBypass);
|
||||
Free(winHttp.lpszProxy);
|
||||
Free(winHttp.lpszProxyBypass);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The next hop .NET chooses for <paramref name="target"/>: the same decision an
|
||||
/// <c>HttpClientHandler</c> with default settings takes, PAC and auto-detect evaluated.
|
||||
/// Auto-detect can take seconds on a network without WPAD.
|
||||
/// </summary>
|
||||
public static string ReadRoute(Uri target)
|
||||
{
|
||||
var hop = WebRequest.DefaultWebProxy?.GetProxy(target);
|
||||
if (hop == null || string.Equals(hop.Host, target.Host, StringComparison.OrdinalIgnoreCase)) return RouteDirect;
|
||||
return hop.IsDefaultPort ? hop.Host : $"{hop.Host}:{hop.Port}";
|
||||
}
|
||||
|
||||
private static void Free(IntPtr memory)
|
||||
{
|
||||
if (memory != IntPtr.Zero) WinHttpNativeMethods.GlobalFree(memory);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
private readonly Logging.AgentLogger? _logger;
|
||||
private readonly ServerCertificateObserver? _serverCertificates;
|
||||
private readonly NetworkMetrics? _networkMetrics;
|
||||
private readonly ProxyObserver? _proxy;
|
||||
|
||||
/// <param name="serverCertificates">
|
||||
/// Observer installed on the <paramref name="httpClient"/>'s handler; its issuer becomes
|
||||
@@ -60,6 +61,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// <param name="networkMetrics">
|
||||
/// Counts 2xx answers without the correlation echo (<c>agent_metrics_snapshot</c>). Null = not counted.
|
||||
/// </param>
|
||||
/// <param name="proxy">
|
||||
/// Names the route in the failure lines and hears how each upload ended. Null = neither.
|
||||
/// </param>
|
||||
public BackendTelemetryUploader(
|
||||
HttpClient httpClient,
|
||||
string baseUrl,
|
||||
@@ -72,7 +76,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
AuthFailureTracker? authFailureTracker = null,
|
||||
Logging.AgentLogger? logger = null,
|
||||
ServerCertificateObserver? serverCertificates = null,
|
||||
NetworkMetrics? networkMetrics = null)
|
||||
NetworkMetrics? networkMetrics = null,
|
||||
ProxyObserver? proxy = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
if (string.IsNullOrEmpty(baseUrl)) throw new ArgumentException("BaseUrl is mandatory.", nameof(baseUrl));
|
||||
@@ -89,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
_logger = logger;
|
||||
_serverCertificates = serverCertificates;
|
||||
_networkMetrics = networkMetrics;
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
public async Task<UploadResult> UploadBatchAsync(
|
||||
@@ -153,7 +159,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger?.Warning($"BackendTelemetryUploader: ingest TIMEOUT after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {ex.Message}");
|
||||
_logger?.Warning($"BackendTelemetryUploader: ingest TIMEOUT after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}{RouteSuffix()}): {ex.Message}");
|
||||
_proxy?.OnSendFailed(sw.Elapsed);
|
||||
// HttpClient timeout (not caller cancellation) surfaces as TaskCanceledException
|
||||
// with cancellationToken.IsCancellationRequested == false.
|
||||
return UploadResult.Transient($"timeout: {ex.Message}");
|
||||
@@ -162,7 +169,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
sw.Stop();
|
||||
var cause = TransportErrorCodes.DescribeSendFailure(ex);
|
||||
_logger?.Warning($"BackendTelemetryUploader: ingest NETWORK fail after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {cause}");
|
||||
_logger?.Warning($"BackendTelemetryUploader: ingest NETWORK fail after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}{RouteSuffix()}): {cause}");
|
||||
_proxy?.OnSendFailed(sw.Elapsed);
|
||||
// DNS failure, socket error, etc. — network-layer is retryable.
|
||||
return UploadResult.Transient($"network: {cause}");
|
||||
}
|
||||
@@ -175,6 +183,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
if (result.Success)
|
||||
{
|
||||
_logger?.Verbose($"BackendTelemetryUploader: ingest OK (corr={correlationId}, items={items.Count}, firstId={items[0].TelemetryItemId}, lastId={items[items.Count - 1].TelemetryItemId}, bytes={compressedBody.Length}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}).");
|
||||
_proxy?.OnSendSucceeded();
|
||||
}
|
||||
else if (result.IsTransient)
|
||||
{
|
||||
@@ -189,7 +198,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
$"BackendTelemetryUploader: ingest INTERCEPTED — the answer did not come from the backend " +
|
||||
$"(corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={interception.StatusCode}, " +
|
||||
$"reason={interception.ForeignReason}, pageTitle=\"{interception.PageTitle ?? "(none)"}\", " +
|
||||
$"serverCertIssuer=\"{interception.ServerCertIssuer ?? "(unknown)"}\").");
|
||||
$"serverCertIssuer=\"{interception.ServerCertIssuer ?? "(unknown)"}\"{RouteSuffix()}).");
|
||||
_proxy?.OnSendFailed(sw.Elapsed);
|
||||
}
|
||||
else if (result.IsSessionGone)
|
||||
{
|
||||
@@ -205,6 +215,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
}
|
||||
|
||||
private string RouteSuffix() => _proxy == null ? string.Empty : $", route={_proxy.RouteText()}";
|
||||
|
||||
private async Task<UploadResult> MapResponseAsync(HttpResponseMessage response, string correlationId)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
|
||||
|
||||
@@ -99,7 +99,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// Measured, not guessed: the reducer snapshot is rewritten on every decision step,
|
||||
// so its mtime is the last moment the previous run is known to have been alive —
|
||||
// and unlike LastBootUtc it exists for every exit type, exception_crash included.
|
||||
previousRunLastAliveUtc: DefaultComponentFactory.ReadPreviousRunLastAliveUtc(stateSubdir));
|
||||
previousRunLastAliveUtc: DefaultComponentFactory.ReadPreviousRunLastAliveUtc(stateSubdir),
|
||||
proxyObserver: auth.ProxyObserver);
|
||||
|
||||
var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection<string>)remoteConfig.WhiteGloveSealingPatternIds
|
||||
?? Array.Empty<string>();
|
||||
|
||||
@@ -52,6 +52,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// outbound call (legacy BackendApiClient calls + dominant /api/agent/telemetry POSTs).
|
||||
var networkMetrics = new NetworkMetrics();
|
||||
|
||||
// Before the first request, so a registration that fails has the route in the log too.
|
||||
var proxyObserver = new ProxyObserver(agentConfig.ApiBaseUrl, logger, SystemClock.Instance);
|
||||
proxyObserver.CaptureBaseline();
|
||||
|
||||
var hardware = HardwareInfo.GetHardwareInfo(logger);
|
||||
|
||||
// Resolve the MDM client cert when cert-mode is on. Cert-missing is non-fatal here:
|
||||
@@ -150,6 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
emergencyReporter: emergencyReporter,
|
||||
authFailureTracker: authFailureTracker,
|
||||
serverCertificates: serverCertificates,
|
||||
proxyObserver: proxyObserver,
|
||||
clientCertificateThumbprint: clientCertificate?.Thumbprint);
|
||||
}
|
||||
|
||||
@@ -201,7 +206,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
authFailureTracker: auth.AuthFailureTracker,
|
||||
logger: logger, // Plan §5 Fix 5 — upload-cadence logging
|
||||
serverCertificates: auth.ServerCertificates,
|
||||
networkMetrics: auth.NetworkMetrics);
|
||||
networkMetrics: auth.NetworkMetrics,
|
||||
proxy: auth.ProxyObserver);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -291,6 +297,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// </summary>
|
||||
public ServerCertificateObserver ServerCertificates { get; }
|
||||
|
||||
/// <summary>Shared by the telemetry uploader built in Phase 5 and the collector host.</summary>
|
||||
public ProxyObserver ProxyObserver { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Thumbprint of the client certificate this run uses; null without one. Kept beside a
|
||||
/// failed run's record so the next successful start can tell whether the certificate changed.
|
||||
@@ -308,6 +317,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
EmergencyReporter emergencyReporter,
|
||||
AuthFailureTracker authFailureTracker,
|
||||
ServerCertificateObserver serverCertificates,
|
||||
ProxyObserver proxyObserver,
|
||||
string clientCertificateThumbprint = null)
|
||||
{
|
||||
BackendApiClient = backendApiClient;
|
||||
@@ -320,6 +330,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
EmergencyReporter = emergencyReporter;
|
||||
AuthFailureTracker = authFailureTracker;
|
||||
ServerCertificates = serverCertificates;
|
||||
ProxyObserver = proxyObserver;
|
||||
ClientCertificateThumbprint = clientCertificateThumbprint;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -13,9 +13,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The size limits of a tenant-authored analyze rule (D-410): at most 20 conditions and 10
|
||||
/// preconditions, and every value a regex or not_regex operator compares — condition value,
|
||||
/// filter value, event A filter value, precondition value — at most 2,000 characters and
|
||||
/// parseable. One implementation feeds the dry-run validation and every write path (create,
|
||||
/// update, template copy); the boundary itself is always allowed.
|
||||
/// </summary>
|
||||
public class AnalyzeRuleShapeLimitsTests
|
||||
{
|
||||
private const string TenantId = "tenant-limits";
|
||||
|
||||
[Fact]
|
||||
public void A_rule_at_every_limit_passes()
|
||||
{
|
||||
var rule = Rule(AnalyzeRuleLimits.MaxConditions, AnalyzeRuleLimits.MaxPreconditions);
|
||||
var atLimit = new string('a', AnalyzeRuleLimits.MaxRegexLength);
|
||||
rule.Conditions[0].Operator = "regex";
|
||||
rule.Conditions[0].Value = atLimit;
|
||||
rule.Conditions[1].FilterField = "f";
|
||||
rule.Conditions[1].FilterOperator = "not_regex";
|
||||
rule.Conditions[1].FilterValue = atLimit;
|
||||
rule.Conditions[2].EventAFilterOperator = "REGEX";
|
||||
rule.Conditions[2].EventAFilterValue = atLimit;
|
||||
rule.Preconditions[0].Operator = "regex";
|
||||
rule.Preconditions[0].Value = atLimit;
|
||||
|
||||
Assert.Empty(AnalyzeRuleService.ShapeLimitErrors(rule));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_condition_or_precondition_over_the_limit_is_named()
|
||||
{
|
||||
var errors = AnalyzeRuleService.ShapeLimitErrors(Rule(AnalyzeRuleLimits.MaxConditions + 1, AnalyzeRuleLimits.MaxPreconditions + 1));
|
||||
|
||||
Assert.Contains(errors, e => e.Contains("at most 20 conditions (this one has 21)"));
|
||||
Assert.Contains(errors, e => e.Contains("at most 10 preconditions (this one has 11)"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("conditions[0].value")]
|
||||
[InlineData("conditions[0].filterValue")]
|
||||
[InlineData("conditions[0].eventAFilterValue")]
|
||||
[InlineData("preconditions[0].value")]
|
||||
public void A_regex_over_the_length_limit_is_rejected_wherever_it_sits(string field)
|
||||
{
|
||||
var rule = Rule(1, 1);
|
||||
Place(rule, field, "regex", new string('a', AnalyzeRuleLimits.MaxRegexLength + 1));
|
||||
|
||||
var error = Assert.Single(AnalyzeRuleService.ShapeLimitErrors(rule));
|
||||
Assert.StartsWith($"{field}: a regex may be at most 2000 characters (this one has 2001)", error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("conditions[0].filterValue")]
|
||||
[InlineData("conditions[0].eventAFilterValue")]
|
||||
[InlineData("preconditions[0].value")]
|
||||
public void An_unparseable_regex_is_rejected_beyond_the_condition_value(string field)
|
||||
{
|
||||
var rule = Rule(1, 1);
|
||||
Place(rule, field, "not_regex", "([unclosed");
|
||||
|
||||
var error = Assert.Single(AnalyzeRuleService.ShapeLimitErrors(rule));
|
||||
Assert.StartsWith($"{field}: value is not a valid regex", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_long_value_of_any_other_operator_is_not_limited()
|
||||
{
|
||||
var rule = Rule(1, 0);
|
||||
rule.Conditions[0].Operator = "not_in";
|
||||
rule.Conditions[0].Value = string.Join(",", Enumerable.Range(0, 1000).Select(i => $"app-{i:D5}"));
|
||||
|
||||
Assert.Empty(AnalyzeRuleService.ShapeLimitErrors(rule));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_dry_run_validation_carries_the_limits()
|
||||
{
|
||||
var rule = Rule(AnalyzeRuleLimits.MaxConditions + 1, 0);
|
||||
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("at most 20 conditions"));
|
||||
}
|
||||
|
||||
// ===== write paths =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_rejects_an_oversized_rule_before_anything_is_stored()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateRuleAsync(TenantId, Rule(AnalyzeRuleLimits.MaxConditions + 1, 0)));
|
||||
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_of_a_custom_rule_rejects_an_oversized_regex_before_anything_is_stored()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var rule = Rule(1, 0);
|
||||
Place(rule, "conditions[0].value", "not_regex", new string('a', AnalyzeRuleLimits.MaxRegexLength + 1));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Template_copy_rejects_an_oversized_regex_from_its_variable()
|
||||
{
|
||||
// ANALYZE-SEC-006 shape: the template variable IS the tenant's not_regex allow-list.
|
||||
var template = Rule(1, 0);
|
||||
template.RuleId = "ANALYZE-SEC-006";
|
||||
template.IsBuiltIn = true;
|
||||
template.Conditions[0].Operator = "not_regex";
|
||||
template.TemplateVariables = new List<TemplateVariable> { new() { Name = "allowList", ConditionIndex = 0, Field = "value" } };
|
||||
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { template });
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateFromTemplateAsync(TenantId, "ANALYZE-SEC-006",
|
||||
new Dictionary<string, string> { ["allowList"] = new string('a', AnalyzeRuleLimits.MaxRegexLength + 1) }));
|
||||
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), TenantId), Times.Never);
|
||||
}
|
||||
|
||||
// ===== fixtures =====
|
||||
|
||||
private static AnalyzeRule Rule(int conditions, int preconditions) => new()
|
||||
{
|
||||
RuleId = "CUSTOM-LIMITS",
|
||||
IsBuiltIn = false, // the model defaults to a built-in rule
|
||||
Title = "limits",
|
||||
Severity = "warning",
|
||||
Category = "test",
|
||||
BaseConfidence = 80,
|
||||
ConfidenceThreshold = 40,
|
||||
Conditions = Enumerable.Range(0, conditions).Select(i => new RuleCondition
|
||||
{
|
||||
Signal = $"s{i}", Source = "event_data", EventType = "app_install_failed", DataField = "errorCode",
|
||||
Operator = "equals", Value = "1", Required = true,
|
||||
}).ToList(),
|
||||
Preconditions = Enumerable.Range(0, preconditions).Select(_ => new RulePrecondition
|
||||
{
|
||||
Source = "event_data", EventType = "os_info", DataField = "edition", Operator = "equals", Value = "Enterprise",
|
||||
}).ToList(),
|
||||
};
|
||||
|
||||
private static void Place(AnalyzeRule rule, string field, string op, string value)
|
||||
{
|
||||
switch (field)
|
||||
{
|
||||
case "conditions[0].value":
|
||||
rule.Conditions[0].Operator = op;
|
||||
rule.Conditions[0].Value = value;
|
||||
break;
|
||||
case "conditions[0].filterValue":
|
||||
rule.Conditions[0].FilterField = "f";
|
||||
rule.Conditions[0].FilterOperator = op;
|
||||
rule.Conditions[0].FilterValue = value;
|
||||
break;
|
||||
case "conditions[0].eventAFilterValue":
|
||||
rule.Conditions[0].EventAFilterField = "f";
|
||||
rule.Conditions[0].EventAFilterOperator = op;
|
||||
rule.Conditions[0].EventAFilterValue = value;
|
||||
break;
|
||||
default:
|
||||
rule.Preconditions[0].Operator = op;
|
||||
rule.Preconditions[0].Value = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -72,7 +73,8 @@ public class AppHomingServiceTests
|
||||
Mock.Of<System.Net.Http.IHttpClientFactory>(),
|
||||
cache,
|
||||
_registry,
|
||||
_tenantConfigMock.Object)
|
||||
_tenantConfigMock.Object,
|
||||
Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
_detectorMock = new Mock<IGraphFeatureDetector>();
|
||||
_maintenanceMock = new Mock<IMaintenanceRepository>();
|
||||
@@ -287,6 +289,32 @@ public class AppHomingServiceTests
|
||||
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_succeeds_when_the_budget_cuts_the_retries_of_a_missing_legacy_service_principal()
|
||||
{
|
||||
// The access check runs on 4 s and the first retry waits 5 s, so the answer arrives as
|
||||
// transient. The Entra code still says the SP is gone: nothing left to lose.
|
||||
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole)));
|
||||
SetupLegacyToken(GraphTokenResult.TransientFailure(
|
||||
new EntraTokenRejection(401, EntraTokenRejection.ServicePrincipalMissingCode, "no service principal")));
|
||||
|
||||
Assert.True((await _sut.ProbePrimaryConsentAsync(TenantId)).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_stays_transient_when_the_budget_cuts_the_retries_of_any_other_legacy_rejection()
|
||||
{
|
||||
// Any other refusal proves nothing about the roles the tenant would lose with the flip.
|
||||
SetupProbe(GraphTokenResult.Success(Jwt(ValidationRole)));
|
||||
SetupLegacyToken(GraphTokenResult.TransientFailure(
|
||||
new EntraTokenRejection(400, "AADSTS53003", "blocked by Conditional Access")));
|
||||
|
||||
var probe = await _sut.ProbePrimaryConsentAsync(TenantId);
|
||||
|
||||
Assert.False(probe.Succeeded);
|
||||
Assert.True(probe.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_never_mints_a_legacy_token_when_the_primary_token_fails()
|
||||
{
|
||||
@@ -349,6 +377,43 @@ public class AppHomingServiceTests
|
||||
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_clears_the_graph_access_rejection_of_the_app_the_tenant_leaves()
|
||||
{
|
||||
_store.Row!.GraphAccessRejectedSince = DateTime.UtcNow.AddHours(-3);
|
||||
_store.Row.GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode;
|
||||
_store.Row.GraphAccessRejectionConfirmedAt = DateTime.UtcNow.AddHours(-2);
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome.Outcome);
|
||||
Assert.Null(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectedCode);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
// The confirmed episode ends visibly: the ops trail never keeps an open rejection.
|
||||
var closed = Assert.Single(_savedOpsEvents, e => e.EventType == OpsEventTypes.GraphAccessRecovered);
|
||||
Assert.Contains("app-homing-flip", closed.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_clears_the_addon_episodes_and_closes_only_the_confirmed_ones()
|
||||
{
|
||||
_store.Row!.IntuneDeviceBindingPermissionMissingSince = DateTime.UtcNow.AddHours(-3);
|
||||
_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt = DateTime.UtcNow.AddHours(-2);
|
||||
_store.Row.CloudPcPermissionMissingSince = DateTime.UtcNow.AddMinutes(-5);
|
||||
|
||||
await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.Null(_store.Row.CloudPcPermissionMissingSince);
|
||||
var resolved = Assert.Single(_savedOpsEvents, e => e.EventType == OpsEventTypes.AddOnPermissionResolved);
|
||||
Assert.Contains(GraphFeatureCatalog.FeatureIntuneDeviceBinding, resolved.Details);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedByAppHomingFlip, resolved.Details);
|
||||
// An unconfirmed rejection was never shown, so it ends without a trace.
|
||||
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == OpsEventTypes.GraphAccessRecovered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_does_not_flip_on_permanent_probe_failure()
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -71,7 +72,7 @@ public class AppInsightsQueryFunctionTests
|
||||
"rows":[["2026-09-04T10:00:00Z",42,"{\"TenantId\":\"t1\"}",null]]}]}
|
||||
""";
|
||||
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
|
||||
|
||||
Assert.Null(partial);
|
||||
var table = Assert.Single(tables);
|
||||
@@ -97,7 +98,7 @@ public class AppInsightsQueryFunctionTests
|
||||
"error":{"code":"PartialError","message":"There were some errors when processing your query.","innererror":{"code":"E_QUERY_RESULT_SET_TOO_LARGE","message":"Query result set has exceeded the internal record count limit 500000."}}}
|
||||
""";
|
||||
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
|
||||
|
||||
Assert.Single(tables);
|
||||
Assert.Equal("E_QUERY_RESULT_SET_TOO_LARGE: Query result set has exceeded the internal record count limit 500000.", partial);
|
||||
@@ -106,7 +107,7 @@ public class AppInsightsQueryFunctionTests
|
||||
[Fact]
|
||||
public void Empty_or_shapeless_body_yields_no_tables_and_no_partial()
|
||||
{
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody("{}");
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody("{}");
|
||||
Assert.Empty(tables);
|
||||
Assert.Null(partial);
|
||||
}
|
||||
@@ -115,16 +116,16 @@ public class AppInsightsQueryFunctionTests
|
||||
[Fact]
|
||||
public void Error_extraction_prefers_innererror_and_tolerates_non_json()
|
||||
{
|
||||
var (msg, code) = AppInsightsQueryFunction.ExtractError(
|
||||
var (msg, code) = LogQueryExecutor.ExtractError(
|
||||
"""{"error":{"message":"The request had some invalid properties","code":"BadArgumentError","innererror":{"code":"SyntaxError","message":"Query could not be parsed at 'foo'"}}}""");
|
||||
Assert.Equal("SyntaxError", code);
|
||||
Assert.Equal("Query could not be parsed at 'foo'", msg);
|
||||
|
||||
var (outerMsg, outerCode) = AppInsightsQueryFunction.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
|
||||
var (outerMsg, outerCode) = LogQueryExecutor.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
|
||||
Assert.Equal("InsufficientAccessError", outerCode);
|
||||
Assert.Equal("Forbidden", outerMsg);
|
||||
|
||||
var (plainMsg, plainCode) = AppInsightsQueryFunction.ExtractError("<html>502 Bad Gateway</html>");
|
||||
var (plainMsg, plainCode) = LogQueryExecutor.ExtractError("<html>502 Bad Gateway</html>");
|
||||
Assert.Null(plainCode);
|
||||
Assert.Equal("Telemetry store query failed", plainMsg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A radar pass reads one tenant at a time so its memory follows the largest tenant, and it never
|
||||
/// evaluates an empty list: re-arm would read that as "version drained" and delete open episodes.
|
||||
/// </summary>
|
||||
public class AppVersionRadarPassTests
|
||||
{
|
||||
private static List<AppInstallSummary> Rows(string tenantId, int count)
|
||||
{
|
||||
var rows = new List<AppInstallSummary>();
|
||||
for (var i = 0; i < count; i++)
|
||||
rows.Add(new AppInstallSummary { TenantId = tenantId });
|
||||
return rows;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_tenant_is_finished_before_the_next_one_is_loaded()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
|
||||
await MaintenanceService.RunAppVersionRadarPassAsync(
|
||||
new[] { "a", "b" },
|
||||
tenantId =>
|
||||
{
|
||||
calls.Add($"load {tenantId}");
|
||||
return Task.FromResult(Rows(tenantId, 2));
|
||||
},
|
||||
(tenantId, summaries) =>
|
||||
{
|
||||
calls.Add($"evaluate {tenantId}");
|
||||
Assert.All(summaries, s => Assert.Equal(tenantId, s.TenantId));
|
||||
return Task.FromResult((0, 0, 0));
|
||||
},
|
||||
(_, ex) => throw ex);
|
||||
|
||||
Assert.Equal(new[] { "load a", "evaluate a", "load b", "evaluate b" }, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_without_rows_is_not_evaluated()
|
||||
{
|
||||
var evaluated = new List<string>();
|
||||
|
||||
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
|
||||
new[] { "empty", "full" },
|
||||
tenantId => Task.FromResult(tenantId == "empty" ? new List<AppInstallSummary>() : Rows(tenantId, 3)),
|
||||
(tenantId, _) =>
|
||||
{
|
||||
evaluated.Add(tenantId);
|
||||
return Task.FromResult((0, 0, 0));
|
||||
},
|
||||
(_, ex) => throw ex);
|
||||
|
||||
Assert.Equal(new[] { "full" }, evaluated);
|
||||
Assert.Equal(1, pass.Tenants);
|
||||
Assert.Equal(3, pass.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_tenant_does_not_cost_the_others_their_pass()
|
||||
{
|
||||
var failed = new List<string>();
|
||||
|
||||
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
|
||||
new[] { "load-fails", "evaluate-fails", "ok" },
|
||||
tenantId => tenantId == "load-fails"
|
||||
? throw new InvalidOperationException("load")
|
||||
: Task.FromResult(Rows(tenantId, 1)),
|
||||
(tenantId, _) => tenantId == "evaluate-fails"
|
||||
? throw new InvalidOperationException("evaluate")
|
||||
: Task.FromResult((1, 2, 3)),
|
||||
(tenantId, _) => failed.Add(tenantId));
|
||||
|
||||
Assert.Equal(new[] { "load-fails", "evaluate-fails" }, failed);
|
||||
Assert.Equal((1, 2, 3), (pass.Fired, pass.Refreshed, pass.Rearmed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_totals_add_up_over_the_tenants()
|
||||
{
|
||||
var pass = await MaintenanceService.RunAppVersionRadarPassAsync(
|
||||
new[] { "a", "b" },
|
||||
tenantId => Task.FromResult(Rows(tenantId, 2)),
|
||||
(_, _) => Task.FromResult((1, 2, 3)),
|
||||
(_, ex) => throw ex);
|
||||
|
||||
Assert.Equal((2, 4L, 2, 4, 6), pass);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,6 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<McpUserService> _mcpUserMock;
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<OpsEventService> _opsEventsMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
@@ -84,10 +83,6 @@ public class AuthFunctionSideEffectTests
|
||||
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_telegramMock = new Mock<TelegramNotificationService>(
|
||||
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
_globalNotificationMock = new Mock<GlobalNotificationService>(
|
||||
notificationRepo, new FakeSignalRNotificationService(), Mock.Of<ILogger<GlobalNotificationService>>())
|
||||
{ CallBase = false };
|
||||
@@ -117,7 +112,6 @@ public class AuthFunctionSideEffectTests
|
||||
tenantAdmins ?? _tenantAdminsMock.Object,
|
||||
_metricsRepoMock.Object,
|
||||
preview ?? _previewMock.Object,
|
||||
_telegramMock.Object,
|
||||
_globalNotificationMock.Object,
|
||||
_mcpUserMock.Object,
|
||||
_autoApproveEnqueuerMock.Object,
|
||||
@@ -136,9 +130,6 @@ public class AuthFunctionSideEffectTests
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
_store = _tenantConfigMock.StubWrites(TenantId, row: null);
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_globalNotificationMock
|
||||
.Setup(x => x.CreateNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
|
||||
@@ -190,7 +181,7 @@ public class AuthFunctionSideEffectTests
|
||||
return BuildRequest(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")));
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal)
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal, RequestContext? requestContext = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
@@ -199,9 +190,11 @@ public class AuthFunctionSideEffectTests
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
|
||||
if (requestContext != null)
|
||||
items[RequestContext.ItemsKey] = requestContext;
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(
|
||||
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
@@ -233,7 +226,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -261,8 +254,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Null(written.DpaAcceptedBy);
|
||||
Assert.Null(written.DpaAcceptedAt);
|
||||
Assert.Equal("auth", source);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record next to the ping
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Tenant Signup",
|
||||
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
|
||||
@@ -350,7 +342,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
@@ -371,7 +363,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Equal("first@contoso.com", _store.Row!.OnboardedBy);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
@@ -389,7 +381,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleNewTenantDomainAsync(DefaultConfig(), TenantId, Upn);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -624,7 +616,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -650,16 +642,17 @@ public class AuthFunctionSideEffectTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_TelegramFailure_DoesNotThrow()
|
||||
public async Task HandleNewTenantDomain_BellFailure_DoesNotThrow()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = null!;
|
||||
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ThrowsAsync(new HttpRequestException("Telegram unreachable"));
|
||||
_globalNotificationMock
|
||||
.Setup(x => x.CreateNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new HttpRequestException("Notification store unreachable"));
|
||||
|
||||
// Should not throw — Telegram is fire-and-forget
|
||||
// Should not throw — the bell is fire-and-forget
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
// The write still landed before the fire-and-forget
|
||||
@@ -1102,4 +1095,96 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, push.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")].Status);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser under view-as: a Global Admin reads another tenant as its member
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const string ViewedTenantId = "cccccccc-1111-2222-3333-444444444444";
|
||||
|
||||
/// <summary>The context PolicyEnforcementMiddleware builds for an admitted view-as request.</summary>
|
||||
private static RequestContext ViewingAs(string role) => new()
|
||||
{
|
||||
TenantId = ViewedTenantId,
|
||||
TargetTenantId = ViewedTenantId,
|
||||
UserPrincipalName = Upn,
|
||||
UserRole = role,
|
||||
IsTenantAdmin = role == Constants.TenantRoles.Admin,
|
||||
ViewAs = new ViewAsContext(TenantId, role),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_ViewAs_AnswersAsTheViewedTenantsMember_WithoutAnySignInSideEffect()
|
||||
{
|
||||
// The viewed tenant asks for the Terms + DPA; the caller's home row still lacks its domain — exactly the
|
||||
// state in which an ordinary sign-in would seed it, onboard, and record a login.
|
||||
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
|
||||
viewed.DomainName = "fabrikam.example";
|
||||
viewed.CompanyName = "Fabrikam";
|
||||
viewed.DpaAcceptancePending = true;
|
||||
var home = DefaultConfig();
|
||||
home.DomainName = null!;
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((home, true));
|
||||
var viewedStore = _tenantConfigMock.StubWrites(ViewedTenantId, row: viewed);
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(true);
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
|
||||
var seen = new DateTime(2026, 11, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen(platformUtc: seen));
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Viewer));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await ReadBodyAsync(response);
|
||||
Assert.Contains($"\"tenantId\":\"{ViewedTenantId}\"", body);
|
||||
Assert.Contains("\"isGlobalAdmin\":false", body);
|
||||
Assert.Contains("\"isGlobalReader\":false", body);
|
||||
Assert.Contains("\"isDelegated\":false", body);
|
||||
Assert.Contains("\"role\":\"Viewer\"", body);
|
||||
Assert.Contains("\"isTenantAdmin\":false", body);
|
||||
Assert.Contains("\"dpaAcceptancePending\":true", body);
|
||||
Assert.Contains("\"whatsNewSeenPlatformUtc\":\"2026-11-01T00:00:00Z\"", body);
|
||||
Assert.Contains(
|
||||
$"\"viewAs\":{{\"tenantId\":\"{ViewedTenantId}\",\"tenantName\":\"Fabrikam\",\"role\":\"Viewer\",\"homeTenantId\":\"{TenantId}\"}}",
|
||||
body);
|
||||
|
||||
// No sign-in side effect in either tenant, and no role lookup of the caller at all.
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Empty(viewedStore.Writes);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_tenantAdminsMock.Verify(x => x.GetMembershipSnapshotAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_metricsRepoMock.Verify(x => x.RecordUserLoginAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_globalAdminMock.Verify(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_ViewAs_AnInactiveTenant_ShowsItsActivationGate()
|
||||
{
|
||||
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
|
||||
viewed.DomainName = "fabrikam.example";
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((DefaultConfig(), true));
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(false);
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
|
||||
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen());
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Admin));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
// What the tenant's own admin gets while the tenant waits for activation — the Global Admin bypass is gone.
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Contains("PendingActivation", await ReadBodyAsync(response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,39 @@ public class AuthFunctionTests
|
||||
Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_MemberWithARoleGetsTheCompanyName()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = " Contoso Ltd. ";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: ViewerRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Contoso Ltd.", (string)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_RolelessMemberGetsNoCompanyName()
|
||||
{
|
||||
// Without a role the caller sees only the Progress Portal; the config GET would refuse them.
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = "Contoso Ltd.";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null((string?)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("platform", "platform")]
|
||||
[InlineData("Platform", "platform")]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -69,7 +70,7 @@ public class AutopilotDeviceValidatorTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
@@ -95,7 +96,7 @@ public class AutopilotDeviceValidatorTests
|
||||
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("ap-1", result.AutopilotDeviceId);
|
||||
Assert.Equal("ap-1", result.Registration!.DeviceId);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
|
||||
tokens.Verify(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
@@ -168,7 +169,7 @@ public class AutopilotDeviceValidatorTests
|
||||
var result = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("ap-1", result.AutopilotDeviceId);
|
||||
Assert.Equal("ap-1", result.Registration!.DeviceId);
|
||||
Assert.Equal(2, handler.Requests.Count);
|
||||
}
|
||||
|
||||
@@ -201,7 +202,7 @@ public class AutopilotDeviceValidatorTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
CancellationToken observed = default;
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
@@ -213,4 +214,66 @@ public class AutopilotDeviceValidatorTests
|
||||
|
||||
Assert.True(observed.CanBeCanceled);
|
||||
}
|
||||
|
||||
// ── Registration fields the session keeps ────────────────────────────────
|
||||
|
||||
private const string Upn = "first.last@example.invalid";
|
||||
|
||||
private static JsonElement Device(string json) => JsonSerializer.Deserialize<JsonElement>(json);
|
||||
|
||||
[Fact]
|
||||
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
|
||||
{
|
||||
var device = Device(
|
||||
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
|
||||
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
|
||||
|
||||
var registration = AutopilotDeviceValidator.ReadRegistration(device);
|
||||
|
||||
Assert.Equal("ap-1", registration.DeviceId);
|
||||
Assert.Equal("TEST", registration.GroupTag);
|
||||
Assert.Equal("PO-7", registration.PurchaseOrderId);
|
||||
Assert.Equal("LAB-01", registration.DisplayName);
|
||||
Assert.True(registration.UserAssigned);
|
||||
var kept = Newtonsoft.Json.JsonConvert.SerializeObject(registration);
|
||||
Assert.DoesNotContain(Upn, kept);
|
||||
Assert.DoesNotContain("First Last", kept);
|
||||
Assert.DoesNotContain("AAAAA-BBBBB", kept);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
|
||||
{
|
||||
var device = Device(
|
||||
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
|
||||
|
||||
var registration = AutopilotDeviceValidator.ReadRegistration(device);
|
||||
|
||||
Assert.Null(registration.GroupTag);
|
||||
Assert.Null(registration.PurchaseOrderId);
|
||||
Assert.Null(registration.DisplayName);
|
||||
Assert.False(registration.UserAssigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadRegistration_CutsOverlongText()
|
||||
{
|
||||
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
|
||||
var device = Device($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
|
||||
|
||||
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CachedValidation_CarriesTheRegistration()
|
||||
{
|
||||
var (sut, handler, _) = BuildSut(Json(
|
||||
"{\"value\":[{\"id\":\"ap-1\",\"serialNumber\":\"PF3XKQ7\",\"groupTag\":\"TEST\"}]}"));
|
||||
|
||||
await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
var cached = await sut.ValidateAutopilotDeviceAsync(TenantId, Serial, null, CancellationToken.None);
|
||||
|
||||
Assert.Single(handler.Requests);
|
||||
Assert.Equal("TEST", cached.Registration!.GroupTag);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -13,10 +13,10 @@
|
||||
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
|
||||
which would otherwise surface NU1903/NU1904 advisories here. -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
|
||||
<PackageReference Include="Moq" Version="4.21.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -181,6 +181,71 @@ 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_SetupGuideMarkers_NoSnapshot()
|
||||
{
|
||||
// The setup guide's markers would otherwise push real settings out of the two backup slots.
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.SetupGuideSettingsReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideHardwareReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "setup-guide", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AddOnPermissionState_NoSnapshot()
|
||||
{
|
||||
// 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()
|
||||
{
|
||||
|
||||
@@ -772,6 +772,35 @@ public class ConfigWireParityTests
|
||||
|
||||
// ---- UpdateTenantConfiguration -------------------------------------------------------
|
||||
|
||||
// ---- RecordSetupGuideStep ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetupGuideStateResponse_matches_the_marker_shape()
|
||||
{
|
||||
DateTime? settingsReviewedAt = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
|
||||
DateTime? hardwareReviewedAt = settingsReviewedAt.Value.AddMinutes(3);
|
||||
DateTime? dismissedAt = null;
|
||||
string? dismissedBy = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
settingsReviewedAt,
|
||||
hardwareReviewedAt,
|
||||
dismissedAt,
|
||||
dismissedBy
|
||||
},
|
||||
new SetupGuideStateResponse
|
||||
{
|
||||
Success = true,
|
||||
SettingsReviewedAt = settingsReviewedAt,
|
||||
HardwareReviewedAt = hardwareReviewedAt,
|
||||
DismissedAt = null,
|
||||
DismissedBy = null
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.DependencyCollector;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Credential URLs never reach a dependency row (K20, D-424): inside a request of a marked client the row
|
||||
/// keeps scheme + host and the method, whatever host the URL names; outside it nothing changes; the mark
|
||||
/// does not leak into the caller's flow; the real collector pipeline carries the mark to the processor;
|
||||
/// and both credential clients (webhooks, push) carry the mark.
|
||||
/// </summary>
|
||||
public class CredentialUrlRedactionProcessorTests
|
||||
{
|
||||
private sealed class CapturingProcessor : ITelemetryProcessor
|
||||
{
|
||||
public List<ITelemetry> Received { get; } = new();
|
||||
public void Process(ITelemetry item) => Received.Add(item);
|
||||
}
|
||||
|
||||
private sealed class CapturingChannel : ITelemetryChannel
|
||||
{
|
||||
private readonly List<ITelemetry> _items = new();
|
||||
public IReadOnlyList<ITelemetry> Items { get { lock (_items) return _items.ToList(); } }
|
||||
public bool? DeveloperMode { get; set; }
|
||||
public string? EndpointAddress { get; set; }
|
||||
public void Send(ITelemetry item) { lock (_items) _items.Add(item); }
|
||||
public void Flush() { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Stands in for the collector: tracks a dependency row from inside the request's flow.</summary>
|
||||
private sealed class TrackingInnerHandler(ITelemetryProcessor processor, DependencyTelemetry row) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
processor.Process(row);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
}
|
||||
}
|
||||
|
||||
private static DependencyTelemetry Row(string url, string method = "POST")
|
||||
=> new() { Type = "Http", Data = url, Name = $"{method} {new Uri(url).AbsolutePath}", Target = new Uri(url).Authority };
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
[InlineData("https://discord.com/api/webhooks/123456/discord-token", "https://discord.com")]
|
||||
[InlineData("https://hooks.slack.com/services/T000/B000/slack-secret", "https://hooks.slack.com")]
|
||||
[InlineData("https://default00000000.00.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/abc/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers&sv=1.0&sig=signature", "https://default00000000.00.environment.api.powerplatform.com")]
|
||||
[InlineData("https://tickets.example.test/hooks/intake?apikey=generic-secret", "https://tickets.example.test")]
|
||||
public void Redact_keeps_scheme_host_and_method_only(string url, string expected)
|
||||
{
|
||||
var dependency = Row(url);
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_of_an_unparseable_url_keeps_only_the_target_host()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "not a url /api/webhooks/1/token", Name = "POST /api/webhooks/1/token", Target = "discord.com" };
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("discord.com", dependency.Data);
|
||||
Assert.Equal("POST discord.com", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_outside_a_marked_request_are_untouched()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var dependency = Row("https://graph.microsoft.com/v1.0/devices?$filter=x", "GET");
|
||||
|
||||
processor.Process(dependency);
|
||||
|
||||
Assert.Same(dependency, Assert.Single(next.Received));
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices?$filter=x", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_mark_covers_the_request_and_ends_with_it()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var inside = Row("https://discord.com/api/webhooks/123456/discord-token");
|
||||
using var client = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler
|
||||
{
|
||||
InnerHandler = new TrackingInnerHandler(processor, inside),
|
||||
});
|
||||
|
||||
await client.PostAsync("https://discord.com/api/webhooks/123456/discord-token", new StringContent("{}"));
|
||||
var after = Row("https://graph.microsoft.com/v1.0/devices", "GET");
|
||||
processor.Process(after);
|
||||
|
||||
Assert.Equal("https://discord.com", inside.Data);
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices", after.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_real_collector_pipeline_carries_the_mark_to_the_processor()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
using var configuration = new TelemetryConfiguration
|
||||
{
|
||||
TelemetryChannel = channel,
|
||||
ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000",
|
||||
};
|
||||
configuration.TelemetryProcessorChainBuilder.Use(next => new CredentialUrlRedactionProcessor(next)).Build();
|
||||
using var collector = new DependencyTrackingTelemetryModule();
|
||||
collector.Initialize(configuration);
|
||||
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var server = AnswerNoContentAsync(listener, connections: 2);
|
||||
var secretPath = $"http://127.0.0.1:{port}/api/webhooks/123456/discord-token";
|
||||
|
||||
using (var marked = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler { InnerHandler = new SocketsHttpHandler() }))
|
||||
(await marked.PostAsync(secretPath, new StringContent("{}"))).Dispose();
|
||||
using (var plain = new HttpClient(new SocketsHttpHandler()))
|
||||
(await plain.PostAsync(secretPath + "?unmarked=1", new StringContent("{}"))).Dispose();
|
||||
await server;
|
||||
|
||||
var rows = channel.Items.OfType<DependencyTelemetry>().Where(d => d.Target?.EndsWith(":" + port) == true).ToList();
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Contains(rows, d => d.Data == "http://127.0.0.1" && d.Name == "POST http://127.0.0.1");
|
||||
Assert.Contains(rows, d => d.Data == secretPath + "?unmarked=1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(WebhookNotificationService))]
|
||||
[InlineData(PushHttpClientRegistration.ClientName)]
|
||||
public void Both_credential_clients_carry_the_mark(string clientName)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ResiliencePolicies>();
|
||||
services.AddWebhookNotificationHttpClient();
|
||||
services.AddPushSender();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var handlers = new List<HttpMessageHandler>();
|
||||
for (HttpMessageHandler? handler = provider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(clientName);
|
||||
handler != null;
|
||||
handler = (handler as DelegatingHandler)?.InnerHandler)
|
||||
{
|
||||
handlers.Add(handler);
|
||||
}
|
||||
|
||||
Assert.Single(handlers.OfType<CredentialUrlRedactionProcessor.MarkingHandler>());
|
||||
}
|
||||
|
||||
/// <summary>Answers each of <paramref name="connections"/> requests with 204 and closes the connection.</summary>
|
||||
private static async Task AnswerNoContentAsync(TcpListener listener, int connections)
|
||||
{
|
||||
for (var i = 0; i < connections; i++)
|
||||
{
|
||||
using var socket = await listener.AcceptTcpClientAsync();
|
||||
var stream = socket.GetStream();
|
||||
var buffer = new byte[8192];
|
||||
var received = new StringBuilder();
|
||||
while (!received.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer);
|
||||
if (read == 0) break;
|
||||
received.Append(Encoding.ASCII.GetString(buffer, 0, read));
|
||||
}
|
||||
// The request body ("{}") follows the headers; the 204 does not wait for it.
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ public class DerivedWriterLivenessTests
|
||||
configService: null!,
|
||||
channelDispatcher: null!,
|
||||
GuardFor(row),
|
||||
stopReporter: null!,
|
||||
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
|
||||
|
||||
await handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
|
||||
@@ -116,6 +117,7 @@ public class DerivedWriterLivenessTests
|
||||
ruleService: null!, Mock.Of<IRuleRepository>(), Mock.Of<ISessionRepository>(), Mock.Of<IMetricsRepository>(),
|
||||
signalRNotification: null!, configService: null!, channelDispatcher: null!,
|
||||
new SessionDeletionGuard(reader.Object, NullLogger<SessionDeletionGuard>.Instance),
|
||||
stopReporter: null!,
|
||||
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<Azure.RequestFailedException>(() => handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
|
||||
|
||||
@@ -101,9 +101,8 @@ public class DeviceAssociationValidatorTests
|
||||
Assert.Equal("preassociated", result.AssociationState);
|
||||
Assert.Equal("00000000-0000-0000-0000-000000000000", result.DevicePreparationPolicyId);
|
||||
Assert.Equal("admin@contoso.com", result.PreAssociatedByUserPrincipalName);
|
||||
// Newtonsoft.Json yields "" for JSON null when calling .ToString() on the JToken;
|
||||
// downstream telemetry uses IsNullOrEmpty so both null and "" surface as "absent".
|
||||
Assert.True(string.IsNullOrEmpty(result.AssignedToUserPrincipalName));
|
||||
// JSON null is absence (D-420); the former Newtonsoft reader turned it into "".
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
Assert.NotNull(result.PreAssociationDateTime);
|
||||
Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel)
|
||||
}
|
||||
|
||||
@@ -640,4 +640,116 @@ public class DistressValidationTests
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), ReportDistressFunction.MaxTimestampFuture);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ResolveErrorType — the agent reports every backend 403 as DeviceNotRegistered; the
|
||||
// hardware whitelist runs before the registration lookup, so a 403 for hardware outside
|
||||
// the whitelist is stored as HardwareNotAllowed.
|
||||
// =========================================================================
|
||||
|
||||
private const string ResolveTenant = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
private static TenantConfiguration ValidatingTenant(Action<TenantConfiguration>? change = null)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(ResolveTenant);
|
||||
config.ValidateAutopilotDevice = true; // CreateDefault starts with every validation off
|
||||
change?.Invoke(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static DistressErrorType Resolve(
|
||||
TenantConfiguration config, string? manufacturer = "VMware, Inc.", string? model = "VMware20,1",
|
||||
DistressErrorType reported = DistressErrorType.DeviceNotRegistered, int? http = 403,
|
||||
DistressCertSourceState? certState = null)
|
||||
=> ReportDistressFunction.ResolveErrorType(reported, http, manufacturer, model, certState, config);
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(ValidatingTenant()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ModelOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ModelWhitelist = "Latitude*");
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config, "Dell Inc.", "OptiPlex 7090"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell Inc.", "Latitude 5440")]
|
||||
[InlineData("LENOVO", "21HD")] // wildcards match case-insensitively
|
||||
[InlineData("HP", "EliteBook 840 G10")]
|
||||
[InlineData("Microsoft Corporation", "Surface Laptop 5")]
|
||||
public void ResolveErrorType_WhitelistedHardware_StaysDeviceNotRegistered(string manufacturer, string model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerAddedToTheWhitelist_StaysDeviceNotRegistered()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ManufacturerWhitelist += ",VMware*");
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "VMware20,1")]
|
||||
[InlineData("VMware, Inc.", null)]
|
||||
[InlineData("", "")]
|
||||
public void ResolveErrorType_WithoutManufacturerOrModel_StaysAsReported(string? manufacturer, string? model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_SuspendedTenant_StaysAsReported()
|
||||
{
|
||||
// The validator refuses a suspended tenant before it looks at the hardware.
|
||||
var config = ValidatingTenant(c => c.Disabled = true);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TenantWithoutValidation_StaysAsReported()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ValidateAutopilotDevice = false);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TestTenantWithoutValidationButInsecureRequests_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c =>
|
||||
{
|
||||
c.ValidateAutopilotDevice = false;
|
||||
c.AllowInsecureAgentRequests = true;
|
||||
});
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_NoCertificateInStore_StaysAsReported()
|
||||
{
|
||||
// Without a certificate the agent authenticates with a bootstrap token, which skips the
|
||||
// hardware check.
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered,
|
||||
Resolve(ValidatingTenant(), certState: DistressCertSourceState.NoCertInStore));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DistressErrorType.AuthCertificateRejected, 401)]
|
||||
[InlineData(DistressErrorType.TpmPssUnsupported, 403)]
|
||||
[InlineData(DistressErrorType.ClientCertificateNotReceived, 403)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, 401)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, null)]
|
||||
public void ResolveErrorType_OtherTypesAndStatusCodes_StayAsReported(DistressErrorType reported, int? http)
|
||||
{
|
||||
Assert.Equal(reported, Resolve(ValidatingTenant(), reported: reported, http: http));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -30,8 +30,8 @@ public class GetHardwareRejectedFunctionTests
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("HardwareNotAllowed", "Dell", "Latitude", "SN1", T0),
|
||||
MakeEntry("CertificateExpired", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("Timeout", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
MakeEntry("DeviceNotRegistered", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, totalRawReports) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
@@ -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,7 +59,16 @@ 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",
|
||||
// Non-sensitive by review: booleans only; who hid the guide and when stays in the
|
||||
// admin-gated config. hasEnrollmentSessions is absent here: unknown → omitted.
|
||||
"setupGuideDismissed",
|
||||
"setupGuideHardwareDone",
|
||||
"setupGuideSettingsDone",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
|
||||
@@ -65,6 +78,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()
|
||||
{
|
||||
@@ -396,4 +525,53 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
|
||||
Assert.DoesNotContain("Secret Holdings", json);
|
||||
}
|
||||
|
||||
// ── setup guide ──
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NewTenantHasEveryStepOpen()
|
||||
{
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(TenantConfiguration.CreateDefault("t"), Now, hasEnrollmentSessions: false);
|
||||
|
||||
Assert.False(payload.SetupGuideSettingsDone);
|
||||
Assert.False(payload.SetupGuideHardwareDone);
|
||||
Assert.False(payload.SetupGuideDismissed);
|
||||
Assert.False(payload.HasEnrollmentSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_ReflectsMarkersAndDismissal()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideSettingsReviewedAt = Now;
|
||||
config.SetupGuideHardwareReviewedAt = Now;
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||
|
||||
Assert.True(payload.SetupGuideSettingsDone);
|
||||
Assert.True(payload.SetupGuideHardwareDone);
|
||||
Assert.True(payload.SetupGuideDismissed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_UnknownEnrollmentHistoryIsOmitted()
|
||||
{
|
||||
var element = Serialize(TenantConfiguration.CreateDefault("t"));
|
||||
|
||||
Assert.False(element.TryGetProperty("hasEnrollmentSessions", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NeverLeaksWhoDismissedIt()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "hidden.admin@contoso.com";
|
||||
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("hidden.admin", json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -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;
|
||||
}
|
||||
|
||||
+30
@@ -130,6 +130,36 @@ public class ScriptDisplayNameResolverTests
|
||||
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"id\":\"nameless\",\"displayName\":null}")]
|
||||
[InlineData("{\"id\":\"nameless\",\"displayName\":42}")]
|
||||
[InlineData("{\"id\":\"nameless\"}")]
|
||||
public async Task PerId_script_without_a_name_is_cached_and_not_fetched_again(string body)
|
||||
{
|
||||
// JSON null and a non-text name read as absent (D-420); the answer is still final, so the second view
|
||||
// is a cache hit instead of another Graph call.
|
||||
var fix = new Fixture();
|
||||
fix.GivenPermission();
|
||||
fix.Repo.Meta[(TenantId, ScriptKind.Platform)] = new ScriptNameCacheMeta
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Kind = ScriptKind.Platform,
|
||||
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
|
||||
};
|
||||
var r = new ScriptRef(ScriptKind.Platform, "nameless");
|
||||
fix.Http.When("deviceManagementScripts/nameless", HttpStatusCode.OK, body);
|
||||
|
||||
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
|
||||
|
||||
Assert.Null(first[r]);
|
||||
Assert.Null(second[r]);
|
||||
Assert.Single(fix.Http.Requests);
|
||||
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
|
||||
Assert.False(entry.IsNotFound);
|
||||
Assert.Null(entry.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_refs_returns_empty_dict()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
|
||||
/// <summary>What Entra's token endpoint answers on success, with the application roles of the tenant's service principal.</summary>
|
||||
internal static class TestGraphToken
|
||||
{
|
||||
public const string ValidationRole = "DeviceManagementServiceConfig.Read.All";
|
||||
|
||||
public static string Body(params string[] roles)
|
||||
{
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: "https://sts.windows.net/test",
|
||||
audience: "https://graph.microsoft.com",
|
||||
claims: roles.Select(r => new Claim("roles", r)),
|
||||
notBefore: null,
|
||||
expires: DateTime.UtcNow.AddHours(1));
|
||||
return $"{{\"access_token\":\"{new JwtSecurityTokenHandler().WriteToken(jwt)}\",\"expires_in\":3599}}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The contract of the Graph and Entra token-endpoint response parsers (D-420): what every realistic body,
|
||||
/// and every malformed one, yields. Written against the Newtonsoft parsers before their move to
|
||||
/// System.Text.Json and kept as their specification. Tests whose name ends in _D1 to _D7 pin a
|
||||
/// deliberate difference of that move (listed in D-420).
|
||||
/// </summary>
|
||||
public class GraphResponseParsingTests
|
||||
{
|
||||
private const string Serial = "PF3XKQ7";
|
||||
private const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
|
||||
|
||||
// ── GraphTokenService.ReadTokenResponse ─────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"access_token":"t","expires_in":3599}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t","expires_in":"3599"}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t","expires_in":" 3599 "}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t","expires_in":"+3599"}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t",/*c*/"expires_in":3599,}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t"}""", null)]
|
||||
[InlineData("""{"access_token":"t","expires_in":null}""", null)]
|
||||
public void Token_response_reads_the_token_and_its_lifetime(string body, long? expiresIn)
|
||||
{
|
||||
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
|
||||
Assert.Equal("t", token);
|
||||
Assert.Equal(expiresIn, lifetime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{"access_token":""}""")]
|
||||
[InlineData("""{"access_token":null,"expires_in":"abc"}""")]
|
||||
public void Token_response_without_a_token_yields_none_and_ignores_the_lifetime(string body)
|
||||
{
|
||||
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
|
||||
Assert.True(string.IsNullOrEmpty(token));
|
||||
Assert.Null(lifetime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<html>busy</html>")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("""{"access_token":"t"} x""")]
|
||||
public void Token_response_that_is_not_one_object_throws(string body)
|
||||
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse(body));
|
||||
|
||||
// D5: an expires_in that is not a whole number counts as absent; the token stays usable with the
|
||||
// default lifetime. Newtonsoft threw on the first five and coerced the last two (3600, 1).
|
||||
[Theory]
|
||||
[InlineData("\"abc\"")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("99999999999999999999")]
|
||||
[InlineData("3599.5")]
|
||||
[InlineData("true")]
|
||||
public void Token_response_with_an_unusable_lifetime_keeps_the_token_D5(string expiresIn)
|
||||
{
|
||||
var (token, lifetime) = GraphTokenService.ReadTokenResponse($$"""{"access_token":"t","expires_in":{{expiresIn}}}""");
|
||||
Assert.Equal("t", token);
|
||||
Assert.Null(lifetime);
|
||||
}
|
||||
|
||||
// ── EntraTokenRejection.Parse ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"error_codes":[7000229],"error_description":"AADSTS7000229: missing"}""", "AADSTS7000229")]
|
||||
[InlineData("""{"error_codes":["7000229",53003]}""", "AADSTS53003")]
|
||||
[InlineData("""{"error_codes":[7000229.0],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
|
||||
[InlineData("""{"error_codes":7000229,"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
|
||||
[InlineData("""{"error_codes":[],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
|
||||
[InlineData("""{"error_codes":[1],"error_codes":[7000229]}""", "AADSTS7000229")]
|
||||
[InlineData("""{"error_codes":[7000229,],/*c*/}""", "AADSTS7000229")]
|
||||
public void Entra_rejection_reads_the_first_integer_code_then_the_description(string body, string code)
|
||||
=> Assert.Equal(code, EntraTokenRejection.Parse(400, body).ErrorCode);
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("\"AADSTS7000229\"")]
|
||||
[InlineData("42")]
|
||||
[InlineData("[1,2]")]
|
||||
[InlineData("<html>busy</html>")]
|
||||
[InlineData("""{"error_description":null}""")]
|
||||
[InlineData("""{"error_description":42}""")]
|
||||
[InlineData("""{"error_codes":[7000229]} x""")]
|
||||
public void Entra_rejection_without_a_readable_object_keeps_only_the_status(string? body)
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, body);
|
||||
Assert.Equal(400, rejection.StatusCode);
|
||||
Assert.Null(rejection.ErrorCode);
|
||||
Assert.Null(rejection.Description);
|
||||
}
|
||||
|
||||
// D3: a description is kept as sent, even when it looks like a date (Newtonsoft dropped it).
|
||||
[Fact]
|
||||
public void Entra_rejection_keeps_a_date_shaped_description_D3()
|
||||
=> Assert.Equal("2026-11-01T08:00:00Z", EntraTokenRejection.Parse(400, """{"error_description":"2026-11-01T08:00:00Z"}""").Description);
|
||||
|
||||
// D6: an error code beyond Int64 is skipped like any other unusable entry (Newtonsoft threw).
|
||||
[Fact]
|
||||
public void Entra_rejection_skips_an_out_of_range_code_D6()
|
||||
=> Assert.Equal("AADSTS53003", EntraTokenRejection.Parse(400, """{"error_codes":[99999999999999999999,53003]}""").ErrorCode);
|
||||
|
||||
// ── AutopilotDeviceValidator.FindRegistration ───────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{"value":"oops"}""")]
|
||||
[InlineData("""{"value":[]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":null}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
|
||||
public void Autopilot_list_without_the_serial_is_not_registered(string body)
|
||||
=> Assert.Null(AutopilotDeviceValidator.FindRegistration(body, Serial));
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("""{"value":[]} x""")]
|
||||
public void Autopilot_list_that_is_not_one_object_throws(string body)
|
||||
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(body, Serial));
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[null,{"id":"ap-1","serialNumber":"PF3XKQ7"}]}""", Serial));
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_matches_the_trimmed_serial_case_insensitively_and_stops_there()
|
||||
{
|
||||
var registration = AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[{"id":"ap-1","serialNumber":" pf3xkq7 ","groupTag":" Sales ","purchaseOrderIdentifier":"PO-7","displayName":"KIOSK-1","userPrincipalName":"u@contoso.com"},null]}""",
|
||||
Serial);
|
||||
|
||||
Assert.NotNull(registration);
|
||||
Assert.Equal("ap-1", registration!.DeviceId);
|
||||
Assert.Equal("Sales", registration.GroupTag);
|
||||
Assert.Equal("PO-7", registration.PurchaseOrderId);
|
||||
Assert.Equal("KIOSK-1", registration.DisplayName);
|
||||
Assert.True(registration.UserAssigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_registration_drops_blank_and_non_text_values_and_cuts_long_text()
|
||||
{
|
||||
var longText = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 10);
|
||||
var registration = AutopilotDeviceValidator.FindRegistration(
|
||||
$$"""{"value":[{"id":" ","serialNumber":"PF3XKQ7","groupTag":true,"purchaseOrderIdentifier":{},"displayName":"{{longText}}","userPrincipalName":null}]}""",
|
||||
Serial);
|
||||
|
||||
Assert.NotNull(registration);
|
||||
Assert.Null(registration!.DeviceId);
|
||||
Assert.Null(registration.GroupTag);
|
||||
Assert.Null(registration.PurchaseOrderId);
|
||||
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, registration.DisplayName!.Length);
|
||||
Assert.False(registration.UserAssigned);
|
||||
}
|
||||
|
||||
// D4: a serial that is not text never matches (Newtonsoft matched through its text).
|
||||
[Fact]
|
||||
public void Autopilot_list_with_a_numeric_serial_does_not_match_D4()
|
||||
=> Assert.Null(AutopilotDeviceValidator.FindRegistration("""{"value":[{"id":"ap-1","serialNumber":12345}]}""", "12345"));
|
||||
|
||||
// D3: registration text is kept as sent, even when it looks like a date (Newtonsoft dropped it).
|
||||
[Fact]
|
||||
public void Autopilot_registration_keeps_date_shaped_text_D3()
|
||||
{
|
||||
var registration = AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7","groupTag":"2026-01-01T00:00:00Z","displayName":"/Date(1713096021000)/"}]}""",
|
||||
Serial);
|
||||
|
||||
Assert.Equal("2026-01-01T00:00:00Z", registration!.GroupTag);
|
||||
Assert.Equal("/Date(1713096021000)/", registration.DisplayName);
|
||||
}
|
||||
|
||||
// ── CorporateIdentifierValidator.ReadIdentityPage ───────────────────────────
|
||||
|
||||
private static readonly string CorporateId =
|
||||
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "")]
|
||||
[InlineData("null", "")]
|
||||
[InlineData("{}", "")]
|
||||
[InlineData("""{"value":null}""", "")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":""}""", "")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":null}""", "")]
|
||||
[InlineData("""{"value":{},"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""", "https://graph.microsoft.com/beta/next")]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"1234-5678"}]}""", "")]
|
||||
public void Corporate_page_without_the_identifier_yields_the_next_url(string body, string nextUrl)
|
||||
=> Assert.Equal((false, nextUrl), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}],"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""")]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"MANUFACTURERMODELSERIAL","importedDeviceIdentifier":"MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"}]}""")]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":null},{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""")]
|
||||
public void Corporate_page_with_the_identifier_is_found(string body)
|
||||
=> Assert.Equal((true, string.Empty), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[]")]
|
||||
public void Corporate_page_that_is_not_one_object_throws(string body)
|
||||
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
|
||||
|
||||
[Fact]
|
||||
public void Corporate_page_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => CorporateIdentifierValidator.ReadIdentityPage(
|
||||
"""{"value":[null,{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""",
|
||||
CorporateId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("5")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{}")]
|
||||
public void Corporate_page_with_a_non_text_next_link_never_yields_a_page_to_follow(string nextLink)
|
||||
{
|
||||
// The scan must stop as a failure: either the read throws or the url cannot be requested.
|
||||
try
|
||||
{
|
||||
var (found, nextUrl) = CorporateIdentifierValidator.ReadIdentityPage($$"""{"value":[],"@odata.nextLink":{{nextLink}}}""", CorporateId);
|
||||
Assert.False(found);
|
||||
Assert.False(string.IsNullOrEmpty(nextUrl));
|
||||
Assert.False(Uri.IsWellFormedUriString(nextUrl, UriKind.Absolute));
|
||||
}
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
|
||||
{
|
||||
// Throwing ends the scan as transient, like an unusable url does.
|
||||
}
|
||||
}
|
||||
|
||||
// D4: an identifier that is not text never matches (Newtonsoft matched an array through its text).
|
||||
[Fact]
|
||||
public void Corporate_page_with_an_array_identifier_does_not_match_D4()
|
||||
=> Assert.False(CorporateIdentifierValidator.ReadIdentityPage(
|
||||
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":["Microsoft Corporation,Virtual Machine,1234-5678"]}]}""",
|
||||
CorporateId).Found);
|
||||
|
||||
// ── DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse ─────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("{} x")]
|
||||
[InlineData("""{"value":null}""")]
|
||||
[InlineData("""{"value":{}}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
|
||||
public void Association_list_without_the_serial_is_not_found(string body)
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Association_list_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[null,{"serialNumber":"PF3XKQ7"}]}""", Serial));
|
||||
|
||||
[Fact]
|
||||
public void Association_list_reads_the_matching_device_and_stops_there()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":" pf3xkq7 ","associationState":"associated","devicePreparationPolicyId":"pol-1","preassociatedByUserPrincipalName":"a@contoso.com","assignedToUserPrincipalName":"u@contoso.com","managedDeviceId":"md-1","preassociationDateTime":"2026-05-08T12:16:49Z"},null]}""",
|
||||
Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("associated", result.AssociationState);
|
||||
Assert.Equal("pol-1", result.DevicePreparationPolicyId);
|
||||
Assert.Equal("a@contoso.com", result.PreAssociatedByUserPrincipalName);
|
||||
Assert.Equal("u@contoso.com", result.AssignedToUserPrincipalName);
|
||||
Assert.Equal("md-1", result.ManagedDeviceId);
|
||||
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc), result.PreAssociationDateTime);
|
||||
Assert.Equal(DateTimeKind.Utc, result.PreAssociationDateTime!.Value.Kind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7"}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":null}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":""}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"0001-01-01T00:00:00Z"}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"not-a-date"}]}""")]
|
||||
public void Association_date_that_is_unset_reads_as_null(string body)
|
||||
=> Assert.Null(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial).AssociationDateTime);
|
||||
|
||||
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void Association_fields_that_are_json_null_read_as_null_D1()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":"PF3XKQ7","associationState":null,"assignedToUserPrincipalName":null,"managedDeviceId":null}]}""",
|
||||
Serial);
|
||||
Assert.Null(result.AssociationState);
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
Assert.Null(result.ManagedDeviceId);
|
||||
}
|
||||
|
||||
// D4: text whose escapes are no valid UTF-16 reads as absent, in the serial of another entry and in a field
|
||||
// of the match, instead of failing the whole lookup (Newtonsoft read it into a broken string).
|
||||
[Fact]
|
||||
public void Association_text_with_a_broken_escape_reads_as_null_D4()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":"\ud800"},{"serialNumber":"PF3XKQ7","assignedToUserPrincipalName":"u\ud800"}]}""", Serial);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
}
|
||||
|
||||
// D2: the date keeps its fraction, in any culture (Newtonsoft cut it to whole seconds).
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("en-GB")]
|
||||
[InlineData("th-TH")]
|
||||
public void Association_date_keeps_its_fraction_in_any_culture_D2(string culture)
|
||||
{
|
||||
var date = InCulture(culture, () => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"2026-05-08T12:16:49.1234567Z"}]}""",
|
||||
Serial).PreAssociationDateTime);
|
||||
|
||||
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc).AddTicks(1234567), date);
|
||||
Assert.Equal(DateTimeKind.Utc, date!.Value.Kind);
|
||||
}
|
||||
|
||||
// ── CloudPcDeviceValidator.ParseCloudPcResponse ─────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("""{"value":[]} x""")]
|
||||
[InlineData("""{"value":null}""")]
|
||||
[InlineData("""{"value":[{"managedDeviceId":"other"}]}""")]
|
||||
public void CloudPc_list_without_the_device_is_not_found(string body)
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, IntuneId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => CloudPcDeviceValidator.ParseCloudPcResponse(
|
||||
$$"""{"value":[null,{"managedDeviceId":"{{IntuneId}}"}]}""", IntuneId));
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_reads_the_matching_cloud_pc()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
|
||||
$$"""{"value":[{"id":"cpc-1","managedDeviceId":" {{IntuneId.ToUpperInvariant()}} ","managedDeviceName":"CPC-ADM-1","servicePlanName":"Cloud PC Enterprise"},null]}""",
|
||||
IntuneId);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("cpc-1", result.CloudPcId);
|
||||
Assert.Equal("CPC-ADM-1", result.ManagedDeviceName);
|
||||
Assert.Equal("Cloud PC Enterprise", result.ServicePlanName);
|
||||
}
|
||||
|
||||
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void CloudPc_fields_that_are_json_null_read_as_null_D1()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
|
||||
$$"""{"value":[{"managedDeviceId":"{{IntuneId}}","id":null,"managedDeviceName":null,"servicePlanName":null}]}""", IntuneId);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.CloudPcId);
|
||||
Assert.Null(result.ManagedDeviceName);
|
||||
Assert.Null(result.ServicePlanName);
|
||||
}
|
||||
|
||||
// ── IntuneDeviceBindingValidator.ParseManagedDeviceResponse ─────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("""[{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"}]""")]
|
||||
[InlineData("""{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"} x""")]
|
||||
[InlineData("""{"id":null}""")]
|
||||
[InlineData("""{"id":42}""")]
|
||||
[InlineData("""{"id":"other"}""")]
|
||||
public void Managed_device_that_is_not_the_requested_one_is_not_found(string body)
|
||||
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, IntuneId).Outcome);
|
||||
|
||||
[Theory]
|
||||
[InlineData("\"managed\"", IntuneDeviceBindingOutcome.Match)]
|
||||
[InlineData("null", IntuneDeviceBindingOutcome.Match)]
|
||||
[InlineData("\"retirePending\"", IntuneDeviceBindingOutcome.NotManaged)]
|
||||
[InlineData("\"WIPEISSUED\"", IntuneDeviceBindingOutcome.NotManaged)]
|
||||
[InlineData("\"deletePending\"", IntuneDeviceBindingOutcome.NotManaged)]
|
||||
public void Managed_device_on_its_way_out_is_not_managed(string managementState, IntuneDeviceBindingOutcome outcome)
|
||||
=> Assert.Equal(outcome, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":" {{IntuneId.ToUpperInvariant()}} ","managementState":{{managementState}}}""", IntuneId).Outcome);
|
||||
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("false", false)]
|
||||
[InlineData("null", null)]
|
||||
[InlineData("\"true\"", null)]
|
||||
[InlineData("1", null)]
|
||||
public void Managed_device_registration_flag_is_strictly_boolean(string value, bool? expected)
|
||||
=> Assert.Equal(expected, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","azureADRegistered":{{value}}}""", IntuneId).AzureAdRegistered);
|
||||
|
||||
[Theory]
|
||||
[InlineData("null")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
public void Managed_device_telemetry_fields_that_are_blank_read_as_null(string value)
|
||||
{
|
||||
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","serialNumber":{{value}},"managedDeviceOwnerType":{{value}},"deviceEnrollmentType":{{value}},"enrolledDateTime":{{value}}}""",
|
||||
IntuneId);
|
||||
Assert.Null(result.SerialNumber);
|
||||
Assert.Null(result.OwnerType);
|
||||
Assert.Null(result.EnrollmentType);
|
||||
Assert.Null(result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_reads_its_fields()
|
||||
{
|
||||
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","deviceName":"DESKTOP-1","azureADDeviceId":"aad-1","managementState":"managed","serialNumber":"PF3XKQ7","managedDeviceOwnerType":"company","deviceEnrollmentType":"windowsAzureADJoin","enrolledDateTime":"2026-05-08T12:16:49Z"}""",
|
||||
IntuneId);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
|
||||
Assert.Equal("DESKTOP-1", result.DeviceName);
|
||||
Assert.Equal("aad-1", result.AzureAdDeviceId);
|
||||
Assert.Equal("PF3XKQ7", result.SerialNumber);
|
||||
Assert.Equal("company", result.OwnerType);
|
||||
Assert.Equal("windowsAzureADJoin", result.EnrollmentType);
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero), result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_unset_enrollment_date_reads_as_null()
|
||||
=> Assert.Null(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"0001-01-01T00:00:00Z"}""", IntuneId).EnrolledDateTime);
|
||||
|
||||
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void Managed_device_fields_that_are_json_null_read_as_null_D1()
|
||||
{
|
||||
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","deviceName":null,"azureADDeviceId":null,"managementState":null}""", IntuneId);
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
|
||||
Assert.Null(result.DeviceName);
|
||||
Assert.Null(result.AzureAdDeviceId);
|
||||
Assert.Null(result.ManagementState);
|
||||
}
|
||||
|
||||
// D2: the enrollment date keeps its fraction and its day and month in any culture (Newtonsoft cut the
|
||||
// fraction and swapped day and month on day-first cultures).
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("en-GB")]
|
||||
[InlineData("th-TH")]
|
||||
public void Managed_device_enrollment_date_is_the_same_in_any_culture_D2(string culture)
|
||||
{
|
||||
var date = InCulture(culture, () => IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"2026-05-08T12:16:49.1234567Z"}""", IntuneId).EnrolledDateTime);
|
||||
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), date);
|
||||
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
|
||||
}
|
||||
|
||||
// D4: an id that is not text never matches (Newtonsoft matched through its text).
|
||||
[Fact]
|
||||
public void Managed_device_with_a_numeric_id_is_not_found_D4()
|
||||
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound,
|
||||
IntuneDeviceBindingValidator.ParseManagedDeviceResponse("""{"id":12345}""", "12345").Outcome);
|
||||
|
||||
// ── Dates in any offset (new with System.Text.Json) ─────────────────────────
|
||||
|
||||
// Newtonsoft turned an offset date into machine-local time and read that back as UTC, so the instant
|
||||
// depended on the host's time zone; it was only right on a UTC host.
|
||||
[Theory]
|
||||
[InlineData("2026-05-08T14:16:49+02:00")]
|
||||
[InlineData("2026-05-08T07:16:49-05:00")]
|
||||
[InlineData("2026-05-08T12:16:49Z")]
|
||||
[InlineData("2026-05-08T12:16:49")]
|
||||
public void Dates_in_any_offset_read_as_the_same_utc_instant(string value)
|
||||
{
|
||||
var expected = new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc);
|
||||
|
||||
var association = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
$$"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"{{value}}"}]}""", Serial).PreAssociationDateTime;
|
||||
var enrolled = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"{{value}}"}""", IntuneId).EnrolledDateTime;
|
||||
|
||||
Assert.Equal(expected, association);
|
||||
Assert.Equal(DateTimeKind.Utc, association!.Value.Kind);
|
||||
Assert.Equal(new DateTimeOffset(expected), enrolled);
|
||||
Assert.Equal(TimeSpan.Zero, enrolled!.Value.Offset);
|
||||
}
|
||||
|
||||
// ── ScriptDisplayNameResolver.ReadScriptPage / ReadScriptDisplayName ────────
|
||||
|
||||
[Fact]
|
||||
public void Script_page_keeps_object_entries_with_an_id_and_the_next_url()
|
||||
{
|
||||
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(
|
||||
"""{"value":[1,"x",null,[],{"displayName":"no id"},{"id":null},{"id":""},{"id":"s1","displayName":"A","fileName":"a.ps1"},{"id":"s2","displayName":"B"}],"@odata.nextLink":"https://graph.microsoft.com/beta/p2"}""",
|
||||
withFileName: true);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
new ScriptDisplayNameResolver.ScriptListItem("s1", "A", "a.ps1"),
|
||||
new ScriptDisplayNameResolver.ScriptListItem("s2", "B", null),
|
||||
}, scripts);
|
||||
Assert.Equal("https://graph.microsoft.com/beta/p2", next);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"value":[{"id":"s1"}]}""")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":null}""")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":""}""")]
|
||||
[InlineData("""{"value":"oops"}""")]
|
||||
[InlineData("{}")]
|
||||
public void Script_page_without_a_next_link_ends_the_pull(string body)
|
||||
=> Assert.True(string.IsNullOrEmpty(ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false).NextUrl));
|
||||
|
||||
[Fact]
|
||||
public void Script_page_of_health_scripts_reads_no_file_name()
|
||||
=> Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
|
||||
"""{"value":[{"id":"s1","displayName":"A","fileName":"a.ps1"}]}""", withFileName: false).Scripts).FileName);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("not json")]
|
||||
public void Script_page_or_name_that_is_not_one_object_throws(string body)
|
||||
{
|
||||
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false));
|
||||
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptDisplayName(body));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"displayName":"Install agent"}""", "Install agent")]
|
||||
[InlineData("""{"id":"s1"}""", null)]
|
||||
public void Script_name_reads_the_display_name(string body, string? name)
|
||||
=> Assert.Equal(name, ScriptDisplayNameResolver.ReadScriptDisplayName(body));
|
||||
|
||||
// D1: a display name that is JSON null is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void Script_name_that_is_json_null_reads_as_null_D1()
|
||||
{
|
||||
Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":null}"""));
|
||||
Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
|
||||
"""{"value":[{"id":"s1","displayName":null}]}""", withFileName: false).Scripts).DisplayName);
|
||||
}
|
||||
|
||||
// D3: a display name is kept as sent in any culture, even when it looks like a date (Newtonsoft
|
||||
// rewrote it in the current culture, "1/1/2026 12:00:00 AM" in en-US).
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("th-TH")]
|
||||
public void Script_name_shaped_like_a_date_is_kept_as_sent_D3(string culture)
|
||||
=> Assert.Equal("2026-01-01T00:00:00Z", InCulture(culture, () =>
|
||||
ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":"2026-01-01T00:00:00Z"}""")));
|
||||
|
||||
// D4: a display name that is not text is absent (Newtonsoft read a number as its text).
|
||||
[Fact]
|
||||
public void Script_name_that_is_a_number_reads_as_null_D4()
|
||||
=> Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":42}"""));
|
||||
|
||||
// ── D7: a body cut off mid-object is unreadable ─────────────────────────────
|
||||
|
||||
// Newtonsoft's DeserializeObject read the part before the cut as if the open containers were closed, so a
|
||||
// cut-off list could still match. Every parser now answers it like any other unreadable body. HttpClient
|
||||
// throws on a premature end before a parser sees the body, so only a server-side cut can get here.
|
||||
|
||||
[Fact]
|
||||
public void Token_response_cut_off_throws_D7()
|
||||
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse("""{"access_token":"t","expires_in":3599"""));
|
||||
|
||||
[Fact]
|
||||
public void Entra_rejection_cut_off_keeps_only_the_status_D7()
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, """{"error_codes":[53003""");
|
||||
Assert.Null(rejection.ErrorCode);
|
||||
Assert.Null(rejection.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_cut_off_throws_D7()
|
||||
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7"}""", Serial));
|
||||
|
||||
[Fact]
|
||||
public void Corporate_page_cut_off_throws_D7()
|
||||
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(
|
||||
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}""",
|
||||
CorporateId));
|
||||
|
||||
[Fact]
|
||||
public void Association_list_cut_off_is_not_found_D7()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse("""{"value":[{"serialNumber":"PF3XKQ7"}""", Serial);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_cut_off_is_not_found_D7()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse($$"""{"value":[{"managedDeviceId":"{{IntuneId}}"}""", IntuneId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_cut_off_is_not_found_D7()
|
||||
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","azureADRegistered":true""", IntuneId).Outcome);
|
||||
|
||||
private static T InCulture<T>(string culture, Func<T> read)
|
||||
{
|
||||
var saved = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
||||
return read();
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public class GraphTokenServiceTests
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler)
|
||||
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler, IMemoryCache? cache = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
@@ -33,7 +33,7 @@ public class GraphTokenServiceTests
|
||||
})
|
||||
.Build();
|
||||
|
||||
var svc = NewService(config, new StubHttpClientFactory(handler));
|
||||
var svc = NewService(config, new StubHttpClientFactory(handler), cache: cache);
|
||||
|
||||
return (svc, handler);
|
||||
}
|
||||
@@ -43,16 +43,22 @@ public class GraphTokenServiceTests
|
||||
/// these tests ⇒ the registry always resolves the primary credentials and the tenant-config
|
||||
/// lookup is never consulted for the decision — a null-returning repo is fine.
|
||||
/// </summary>
|
||||
private static GraphTokenService NewService(IConfiguration config, System.Net.Http.IHttpClientFactory factory) =>
|
||||
private static GraphTokenService NewService(
|
||||
IConfiguration config,
|
||||
System.Net.Http.IHttpClientFactory factory,
|
||||
IGraphAccessHealthRecorder? accessHealth = null,
|
||||
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null,
|
||||
IMemoryCache? cache = null) =>
|
||||
new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
logger ?? NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
cache ?? new MemoryCache(new MemoryCacheOptions()),
|
||||
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
new MemoryCache(new MemoryCacheOptions())),
|
||||
accessHealth ?? Moq.Mock.Of<IGraphAccessHealthRecorder>());
|
||||
|
||||
private static int TokenPosts(StubHttpMessageHandler handler, string tenantId)
|
||||
=> handler.Requests.Count(u => u.Contains($"/{tenantId}/oauth2/v2.0/token"));
|
||||
@@ -126,6 +132,53 @@ public class GraphTokenServiceTests
|
||||
Assert.Equal(1, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
// The token stays cached for expires_in minus the 5-minute skew, clamped to 60 s .. 55 min. An absent
|
||||
// or unusable expires_in (D5 in D-420: text, a fraction, zero or less) falls back to the 35-minute
|
||||
// default, so 30 minutes; a value far beyond the cap is capped instead of overflowing.
|
||||
[Theory]
|
||||
[InlineData("3599", 3299)]
|
||||
[InlineData("\"3599\"", 3299)]
|
||||
[InlineData("7200", 3300)]
|
||||
[InlineData("120", 60)]
|
||||
[InlineData("null", 1800)]
|
||||
[InlineData("\"abc\"", 1800)]
|
||||
[InlineData("3599.5", 1800)]
|
||||
[InlineData("0", 1800)]
|
||||
[InlineData("-5", 1800)]
|
||||
[InlineData("9223372036854775807", 3300)]
|
||||
public async Task Token_cache_lifetime_follows_expires_in(string expiresIn, int seconds)
|
||||
{
|
||||
var cache = new RecordingMemoryCache();
|
||||
var (svc, _) = Create(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, $$"""{"access_token":"tok-A","expires_in":{{expiresIn}}}"""), cache);
|
||||
|
||||
Assert.Equal("tok-A", (await svc.GetAccessTokenAsync(TenantA)).AccessToken);
|
||||
|
||||
var entry = Assert.Single(cache.Entries, e => e.Key is string key && key.StartsWith($"graph-token:{TenantA}:", StringComparison.Ordinal));
|
||||
Assert.Equal(TimeSpan.FromSeconds(seconds), entry.AbsoluteExpirationRelativeToNow);
|
||||
}
|
||||
|
||||
/// <summary>A memory cache that keeps every entry it creates, so a test can read the lifetime the service chose.</summary>
|
||||
private sealed class RecordingMemoryCache : IMemoryCache
|
||||
{
|
||||
private readonly MemoryCache _inner = new(new MemoryCacheOptions());
|
||||
|
||||
public List<ICacheEntry> Entries { get; } = new();
|
||||
|
||||
public ICacheEntry CreateEntry(object key)
|
||||
{
|
||||
var entry = _inner.CreateEntry(key);
|
||||
Entries.Add(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public bool TryGetValue(object key, out object? value) => _inner.TryGetValue(key, out value);
|
||||
|
||||
public void Remove(object key) => _inner.Remove(key);
|
||||
|
||||
public void Dispose() => _inner.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateTenant_forces_fresh_token_on_next_call()
|
||||
{
|
||||
@@ -240,7 +293,8 @@ public class GraphTokenServiceTests
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
new MemoryCache(new MemoryCacheOptions())),
|
||||
Moq.Mock.Of<IGraphAccessHealthRecorder>());
|
||||
|
||||
var probe1 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
var probe2 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
@@ -260,6 +314,196 @@ public class GraphTokenServiceTests
|
||||
Assert.Equal(3, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
// ── Entra's refusal ─────────────────────────────────────────────────────
|
||||
|
||||
private const string MissingAppBody = "{\"error\":\"invalid_client\",\"error_description\":\"AADSTS7000229: missing service principal\",\"error_codes\":[7000229]}";
|
||||
private const string ConditionalAccessBody = "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: blocked\",\"error_codes\":[53003]}";
|
||||
|
||||
private static GraphTokenService ServiceFor(
|
||||
StubHttpMessageHandler handler,
|
||||
Moq.Mock<IGraphAccessHealthRecorder> accessHealth,
|
||||
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
return NewService(config, new StubHttpClientFactory(handler), accessHealth.Object, logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejection_survives_a_budget_that_ends_the_retries()
|
||||
{
|
||||
// The validators run on 8 s and the recheck on 3 s; the chain's first retry waits 5 s.
|
||||
var handler = new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.Unauthorized, MissingAppBody);
|
||||
var (svc, _) = Create(handler);
|
||||
using var budget = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
var result = await svc.GetAccessTokenAsync(TenantA, budget.Token);
|
||||
|
||||
Assert.Null(result.AccessToken);
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.NotNull(result.Rejection);
|
||||
Assert.Equal(401, result.Rejection!.StatusCode);
|
||||
Assert.True(result.Rejection.IsServicePrincipalMissing);
|
||||
Assert.Equal(1, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.TooManyRequests)]
|
||||
[InlineData(HttpStatusCode.RequestTimeout)]
|
||||
public async Task A_server_error_or_throttle_is_no_rejection(HttpStatusCode status)
|
||||
{
|
||||
var handler = new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", status, MissingAppBody);
|
||||
var (svc, _) = Create(handler);
|
||||
using var budget = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var result = await svc.GetAccessTokenAsync(TenantA, budget.Token);
|
||||
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.Null(result.Rejection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_validation_reports_a_rejection()
|
||||
{
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody), health);
|
||||
|
||||
var result = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
|
||||
Assert.False(result.IsTransient);
|
||||
health.Verify(h => h.RecordRejectedAsync(TenantA, Moq.It.Is<EntraTokenRejection>(r => r.ErrorCode == "AADSTS53003")), Moq.Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_portal_path_does_not_report_a_rejection()
|
||||
{
|
||||
// The consent flows see refusals as a matter of course while a consent propagates.
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody), health);
|
||||
|
||||
await svc.GetAccessTokenAsync(TenantA);
|
||||
|
||||
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_a_fresh_token_counts_as_proof_of_access()
|
||||
{
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body(TestGraphToken.ValidationRole)), health);
|
||||
|
||||
var minted = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
var cached = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
|
||||
Assert.True(minted.IsFreshlyMinted);
|
||||
Assert.False(cached.IsFreshlyMinted);
|
||||
// The roles travel along: they decide the add-on episodes.
|
||||
health.Verify(h => h.RecordMintedAsync(TenantA, Moq.It.Is<IReadOnlyCollection<string>>(r => r.Contains(TestGraphToken.ValidationRole))), Moq.Times.Once);
|
||||
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_without_a_role_is_no_proof_of_access()
|
||||
{
|
||||
// A sign-in consent re-created the removed enterprise application without any permission.
|
||||
// The notice must survive it: this is the moment an admin signs in to read it.
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
|
||||
|
||||
await svc.GetAccessTokenAsync(TenantA);
|
||||
|
||||
health.Verify(h => h.RecordMintedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<IReadOnlyCollection<string>>()), Moq.Times.Never);
|
||||
health.Verify(h => h.RecordRejectedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<EntraTokenRejection>()), Moq.Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_validation_counts_a_token_without_a_role_as_a_refusal()
|
||||
{
|
||||
// Graph refuses every call made with it, so the agent is locked out all the same.
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
|
||||
|
||||
var result = await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
|
||||
Assert.NotNull(result.AccessToken);
|
||||
health.Verify(h => h.RecordRejectedAsync(TenantA, EntraTokenRejection.NoApplicationRole), Moq.Times.Once);
|
||||
health.Verify(h => h.RecordMintedAsync(Moq.It.IsAny<string>(), Moq.It.IsAny<IReadOnlyCollection<string>>()), Moq.Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Agent_validation_does_not_judge_a_token_from_the_cache()
|
||||
{
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, TestGraphToken.Body()), health);
|
||||
|
||||
await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
await svc.GetAccessTokenForAgentValidationAsync(TenantA);
|
||||
|
||||
health.Verify(h => h.RecordRejectedAsync(TenantA, EntraTokenRejection.NoApplicationRole), Moq.Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_probe_under_explicit_credentials_reports_nothing()
|
||||
{
|
||||
// A token of the app the tenant is not homed on says nothing about the tenant's access.
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var registry = new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
foreach (var (status, body) in new[]
|
||||
{
|
||||
(HttpStatusCode.OK, TestGraphToken.Body(TestGraphToken.ValidationRole)),
|
||||
(HttpStatusCode.OK, TestGraphToken.Body()),
|
||||
(HttpStatusCode.BadRequest, ConditionalAccessBody),
|
||||
})
|
||||
{
|
||||
var health = new Moq.Mock<IGraphAccessHealthRecorder>();
|
||||
var svc = NewService(config, new StubHttpClientFactory(new StubHttpMessageHandler().When($"/{TenantA}/oauth2", status, body)), health.Object);
|
||||
|
||||
await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
|
||||
health.VerifyNoOtherCalls();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Entra_refusal_is_logged_once_per_window()
|
||||
{
|
||||
var logger = new Moq.Mock<Microsoft.Extensions.Logging.ILogger<GraphTokenService>>();
|
||||
var svc = ServiceFor(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.BadRequest, ConditionalAccessBody),
|
||||
new Moq.Mock<IGraphAccessHealthRecorder>(), logger.Object);
|
||||
|
||||
await svc.GetAccessTokenAsync(TenantA);
|
||||
await svc.GetAccessTokenAsync(TenantA);
|
||||
|
||||
logger.Verify(l => l.Log(
|
||||
Microsoft.Extensions.Logging.LogLevel.Warning,
|
||||
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
|
||||
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state != null && state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
|
||||
Moq.It.IsAny<Exception?>(),
|
||||
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cover for the group-tag search filter and for the device-snapshot path's in-memory twin of the
|
||||
/// OData push-down: a filter must answer the same on both search paths.
|
||||
/// </summary>
|
||||
public class GroupTagSearchFilterTests
|
||||
{
|
||||
private static SessionSummary Session(string? groupTag = null) => new()
|
||||
{
|
||||
SessionId = "s", TenantId = "t", AutopilotGroupTag = groupTag,
|
||||
Manufacturer = "Contoso", Model = "EliteBook 840", EnrollmentType = "v1",
|
||||
DeviceName = "LAB-01", OsBuild = "26100.1000", Status = SessionStatus.Failed,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("TEST", true)]
|
||||
[InlineData("test", true)] // Intune does not fix the casing of a tag
|
||||
[InlineData(" Test ", true)]
|
||||
[InlineData("TEST-LAB", false)] // exact, not prefix
|
||||
[InlineData(null, false)] // session without a recorded tag
|
||||
public void GroupTagFilter_IsCaseInsensitiveAndExact(string? sessionTag, bool expected)
|
||||
{
|
||||
var filter = new SessionSearchFilter { GroupTag = "Test" };
|
||||
Assert.Equal(expected, TableStorageService.MatchesGroupTag(Session(sessionTag), filter));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoGroupTagFilter_MatchesEverySession()
|
||||
{
|
||||
Assert.True(TableStorageService.MatchesGroupTag(Session(), new SessionSearchFilter()));
|
||||
Assert.True(TableStorageService.MatchesGroupTag(Session(), new SessionSearchFilter { GroupTag = " " }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Manufacturer", "Contoso", true)]
|
||||
[InlineData("Manufacturer", "contoso", false)] // OData eq is ordinal: the twin is too
|
||||
[InlineData("Model", "EliteBook 840", true)]
|
||||
[InlineData("Model", "EliteBook", false)]
|
||||
[InlineData("EnrollmentType", "v2", false)]
|
||||
[InlineData("DeviceName", "LAB", true)] // prefix, like the ge/lt range
|
||||
[InlineData("DeviceName", "PC", false)]
|
||||
[InlineData("OsBuild", "26100", true)]
|
||||
[InlineData("OsBuild", "22631", false)]
|
||||
[InlineData("Status", "Failed", true)]
|
||||
[InlineData("Status", "Succeeded", false)]
|
||||
public void DeviceSnapshotPath_AppliesThePushedDownColumns(string column, string value, bool expected)
|
||||
{
|
||||
var filter = column switch
|
||||
{
|
||||
"Manufacturer" => new SessionSearchFilter { Manufacturer = value },
|
||||
"Model" => new SessionSearchFilter { Model = value },
|
||||
"EnrollmentType" => new SessionSearchFilter { EnrollmentType = value },
|
||||
"DeviceName" => new SessionSearchFilter { DeviceName = value },
|
||||
"OsBuild" => new SessionSearchFilter { OsBuild = value },
|
||||
_ => new SessionSearchFilter { Status = value },
|
||||
};
|
||||
Assert.Equal(expected, TableStorageService.MatchesPushedDownColumns(Session(), filter));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_BindsTheGroupTag()
|
||||
{
|
||||
// A continuation token minted for one group tag must not be replayed against another.
|
||||
var a = SearchSessionsPagination.Fingerprint("tenant", "t", null, new SessionSearchFilter { GroupTag = "TEST" });
|
||||
var b = SearchSessionsPagination.Fingerprint("tenant", "t", null, new SessionSearchFilter { GroupTag = "PROD" });
|
||||
Assert.NotEqual(a, b);
|
||||
}
|
||||
}
|
||||
@@ -235,6 +235,26 @@ public class GrowthMetricsBuilderTests
|
||||
Assert.Null(a.MedianDaysToFirstEnrollment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_yesterday_is_aggregated_yesterdays_counters_count_as_activity_and_the_rows_end_the_day_before()
|
||||
{
|
||||
// Sessions yesterday after a 40-day pause; the first maintenance run after midnight has not written them yet.
|
||||
var configs = new[] { Config('a', Utc(6, 1, 9)) };
|
||||
var stats = new[] { Stats('a', 6, 5, Utc(10, 27, 22)) };
|
||||
var rows = new[] { Active('a', 2026, 9, 17) };
|
||||
|
||||
var early = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions, yesterdayAggregated: false);
|
||||
|
||||
Assert.Equal("2026-10-26", early.UsageDaysThrough);
|
||||
Assert.Equal(0, early.AllTenants.Dormant);
|
||||
Assert.Equal(1, early.AllTenants.Active7Days);
|
||||
|
||||
// Aggregated, yesterday has no row: the counters moved for an old session, which is no activity.
|
||||
var later = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions);
|
||||
Assert.Equal("2026-10-27", later.UsageDaysThrough);
|
||||
Assert.Equal(1, later.AllTenants.Dormant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_peak_needs_three_signups_and_twice_the_median_of_the_two_weeks_before()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The hardware whitelist matches tenant wildcards against device headers on every agent request
|
||||
/// without building a Regex (D-411): same results as the former regex construction for every shape
|
||||
/// an entry can take, linear work for the worst entry a tenant can write, and a device value longer
|
||||
/// than any real hardware string only meets "*".
|
||||
/// </summary>
|
||||
public class HardwareWhitelistValidatorTests
|
||||
{
|
||||
/// <summary>The construction this replaced, kept as the oracle: '*' → '.*', '?' → '.', the rest escaped.</summary>
|
||||
private static bool FormerRegexMatch(string text, string pattern)
|
||||
{
|
||||
if (pattern == "*")
|
||||
return true;
|
||||
var regex = "^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$";
|
||||
return Regex.IsMatch(text, regex, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell Inc.", "Dell*")]
|
||||
[InlineData("dell inc.", "DELL*")]
|
||||
[InlineData("Hewlett-Packard", "HP*")]
|
||||
[InlineData("HP", "HP*")]
|
||||
[InlineData("LENOVO", "Lenovo*")]
|
||||
[InlineData("Microsoft Corporation", "Microsoft Corporation")]
|
||||
[InlineData("Microsoft Corporation ", "Microsoft Corporation")]
|
||||
[InlineData("Microsoft Corp", "Microsoft Corporation")]
|
||||
[InlineData("ThinkPad T14 Gen 6", "*T14*")]
|
||||
[InlineData("ThinkPad T14 Gen 6", "*Gen ?")]
|
||||
[InlineData("ThinkPad T14 Gen 16", "*Gen ?")]
|
||||
[InlineData("ThinkPad T14 Gen 6", "ThinkPad*Gen*")]
|
||||
[InlineData("VMware20,1", "VMware??,?")]
|
||||
[InlineData("VMware20,1", "VMware?,?")]
|
||||
[InlineData("a.b", "a.b")]
|
||||
[InlineData("axb", "a.b")]
|
||||
[InlineData("f(x)", "f(x)")]
|
||||
[InlineData("f(x)", "f(*)")]
|
||||
[InlineData("a\\b", "a\\b")]
|
||||
[InlineData("a\\xyzb", "a\\*b")]
|
||||
[InlineData("a*b", "a\\*b")]
|
||||
[InlineData("[abc]", "[abc]")]
|
||||
[InlineData("a", "[abc]")]
|
||||
[InlineData("x+y", "x+y")]
|
||||
[InlineData("xxy", "x+y")]
|
||||
[InlineData("$1.00", "$1.00")]
|
||||
[InlineData("^start", "^start")]
|
||||
[InlineData("abc", "**")]
|
||||
[InlineData("abc", "*?*")]
|
||||
[InlineData("ab", "???")]
|
||||
[InlineData("abc", "???")]
|
||||
[InlineData("abcc", "*c")]
|
||||
[InlineData("ÄÖÜ GmbH", "äöü*")]
|
||||
[InlineData("straße", "STRASSE")]
|
||||
public void Matches_like_the_former_regex_construction(string value, string pattern)
|
||||
{
|
||||
Assert.Equal(FormerRegexMatch(value, pattern), HardwareWhitelistValidator.MatchesWildcard(value, pattern));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Case_folding_is_ordinal_the_one_deliberate_difference()
|
||||
{
|
||||
// The regex engine folded Turkish dotted 'İ' to 'i'; ordinal folding does not. SMBIOS strings are ASCII.
|
||||
Assert.True(FormerRegexMatch("İstanbul", "istanbul*"));
|
||||
Assert.False(HardwareWhitelistValidator.MatchesWildcard("İstanbul", "istanbul*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_worst_whitelist_a_tenant_can_write_costs_milliseconds_per_request()
|
||||
{
|
||||
// 200 entries of 128 characters, each a chain of wildcards that never matches, against the
|
||||
// longest value still compared (the cap) and a 32 KB header (past the cap).
|
||||
var entry = string.Concat(Enumerable.Repeat("*a", 63)) + "*b";
|
||||
var whitelist = Enumerable.Repeat(entry, 200).ToArray();
|
||||
var atCap = new string('a', HardwareWhitelistValidator.MaxMatchedValueLength);
|
||||
var huge = new string('a', 32 * 1024);
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
var atCapResult = HardwareWhitelistValidator.ValidateHardware(atCap, "Model", whitelist, new[] { "*" });
|
||||
var hugeResult = HardwareWhitelistValidator.ValidateHardware(huge, "Model", whitelist, new[] { "*" });
|
||||
clock.Stop();
|
||||
|
||||
Assert.False(atCapResult.IsValid);
|
||||
Assert.False(hugeResult.IsValid);
|
||||
// The former construction spent up to 200 × 100 ms per list on this.
|
||||
Assert.True(clock.ElapsedMilliseconds < 500, $"took {clock.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_value_longer_than_any_hardware_string_only_meets_the_star()
|
||||
{
|
||||
var longValue = "Dell" + new string('x', HardwareWhitelistValidator.MaxMatchedValueLength);
|
||||
|
||||
Assert.False(HardwareWhitelistValidator.MatchesWildcard(longValue, "Dell*"));
|
||||
Assert.True(HardwareWhitelistValidator.MatchesWildcard(longValue, "*"));
|
||||
|
||||
var rejected = HardwareWhitelistValidator.ValidateHardware(longValue, "Model", new[] { "Dell*" }, new[] { "*" });
|
||||
Assert.False(rejected.IsValid);
|
||||
Assert.True(rejected.ErrorMessage!.Length < HardwareWhitelistValidator.MaxMatchedValueLength + 64, "the rejection message carries a shortened value");
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,8 @@ public class InfrastructureWireParityTests
|
||||
mcpClientRegistrationEnabled = true,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen,
|
||||
dpaAcceptancePending = true
|
||||
dpaAcceptancePending = true,
|
||||
companyName = "Contoso Ltd."
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -74,7 +75,8 @@ public class InfrastructureWireParityTests
|
||||
McpClientRegistrationEnabled = true,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen,
|
||||
DpaAcceptancePending = true
|
||||
DpaAcceptancePending = true,
|
||||
CompanyName = "Contoso Ltd."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -124,32 +124,38 @@ public class IntuneDeviceBindingValidatorTests
|
||||
Assert.Null(result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
// -- ParseEnrolledDateTime --
|
||||
// -- enrolledDateTime --
|
||||
|
||||
private static DateTimeOffset? EnrolledDateTime(string jsonValue)
|
||||
=> IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{ "id": "{{DeviceId}}", "enrolledDateTime": {{jsonValue}} }""", DeviceId).EnrolledDateTime;
|
||||
|
||||
[Fact]
|
||||
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
|
||||
public void EnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
|
||||
{
|
||||
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
|
||||
// as a real date would report an object age of ~2000 years and poison the race analysis.
|
||||
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
|
||||
Assert.Null(EnrolledDateTime("\"0001-01-01T00:00:00Z\""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not-a-date")]
|
||||
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
|
||||
[InlineData("null")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
[InlineData("\"not-a-date\"")]
|
||||
[InlineData("20260824")]
|
||||
[InlineData("true")]
|
||||
public void EnrolledDateTime_UnusableValue_IsNull(string jsonValue)
|
||||
{
|
||||
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
|
||||
Assert.Null(EnrolledDateTime(jsonValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnrolledDateTime_NormalizesToUtc()
|
||||
public void EnrolledDateTime_NormalizesToUtc()
|
||||
{
|
||||
// A device object created minutes ago is the signature of an enrollment race, so the
|
||||
// instant has to be comparable regardless of the offset Graph reports it in.
|
||||
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
|
||||
var parsed = EnrolledDateTime("\"2026-08-24T17:20:00+02:00\"");
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user