mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-06 18:21:08 +00:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b06edb222 | |||
| 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 |
+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: 45a18760a0...dbacde8e0d
@@ -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:
|
||||
@@ -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.
|
||||
|
||||
@@ -24,7 +24,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -56,7 +56,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '.github/zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -145,10 +145,10 @@ jobs:
|
||||
# next-env.d.ts + .next type stubs are gitignored (Next-managed since
|
||||
# v16); typegen generates them so tsc sees the same types as a build.
|
||||
- name: Generate Next.js types
|
||||
run: npx next typegen
|
||||
run: npx --no -- next typegen
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: npx --no -- tsc --noEmit
|
||||
|
||||
# Errors fail outright, and so does ANY warning: the codebase is
|
||||
# warning-clean and must stay that way — fix new findings, never
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
# tsconfig.json covers sources, tests and scripts; the build emits via
|
||||
# tsconfig.build.json.
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: npx --no -- tsc --noEmit
|
||||
|
||||
# Same rule as the web job: any warning fails — fix new findings, never
|
||||
# raise the cap.
|
||||
|
||||
@@ -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/**'
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
name: Deploy Web (Static Web App)
|
||||
|
||||
# Build here, upload with the pinned SWA CLI. No third-party action sees the deploy token
|
||||
# (the CLI is Microsoft-published npm code at an exact version), the build runs on the same
|
||||
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
|
||||
# action's container, and the exported site is attested like the agent ZIP and the MSI.
|
||||
# Build here, upload with the SWA CLI from its own lockfile (tools/swa-cli). No
|
||||
# third-party action sees the deploy token, the build runs on the same path ci.yml tests
|
||||
# (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the action's container,
|
||||
# and the exported site is attested like the agent ZIP and the MSI.
|
||||
#
|
||||
# Three jobs, each holding only what it needs: build runs all third-party code without any
|
||||
# credential, deploy holds the SWA token alone, record attests, verifies and writes the
|
||||
# manifest. A failed record job is re-run on its own; it writes only once the live site
|
||||
# serves this commit.
|
||||
# Three jobs, each holding only what it needs: build runs the third-party build code without
|
||||
# any credential, deploy holds the SWA token alone and runs nothing but the locked CLI, record
|
||||
# attests, verifies and writes the manifest. A failed record job is re-run on its own; it
|
||||
# writes only once the live site serves this commit.
|
||||
#
|
||||
# PR preview environments were dropped with the action: their per-PR URLs are never
|
||||
# registered as redirect URIs, so nobody could log in on them anyway.
|
||||
@@ -19,6 +19,7 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- 'tools/swa-cli/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,9 +33,6 @@ permissions:
|
||||
# repository variable.
|
||||
env:
|
||||
WEB_DIR: src/Web/autopilot-monitor-web
|
||||
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
|
||||
# only thing that keeps the deploy step's code fixed. Bump deliberately.
|
||||
SWA_CLI_VERSION: 2.0.10
|
||||
|
||||
jobs:
|
||||
# Build zone: no login, no id-token. The one secret here is the Application Insights
|
||||
@@ -155,8 +153,9 @@ jobs:
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Holds the SWA deployment token and nothing else -- deliberately no id-token: the SWA CLI
|
||||
# is third-party npm code whose dependencies resolve at run time.
|
||||
# Holds the SWA deployment token and nothing else -- deliberately no id-token. The token alone
|
||||
# can replace the portal, so the SWA CLI, the one third-party code here, comes from a committed
|
||||
# lockfile: a version published after the last reviewed bump never runs next to it.
|
||||
deploy:
|
||||
name: Deploy to Static Web App
|
||||
needs: build
|
||||
@@ -164,11 +163,19 @@ jobs:
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
# For staticwebapp.config.json only.
|
||||
# For staticwebapp.config.json and the CLI's lockfile.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# npm ci, never npx or npm install: the lockfile pins every transitive package by hash.
|
||||
# No lifecycle scripts: keytar, the only package with one, serves the keychain, which
|
||||
# --no-use-keychain keeps unloaded. Before the artifact arrives, so nothing from the
|
||||
# build zone can touch the install.
|
||||
- name: Install the SWA CLI from its lockfile
|
||||
working-directory: tools/swa-cli
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
@@ -186,7 +193,7 @@ jobs:
|
||||
env:
|
||||
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
|
||||
tools/swa-cli/node_modules/.bin/swa deploy \
|
||||
--app-location "$WEB_DIR" \
|
||||
--output-location out \
|
||||
--swa-config-location "$WEB_DIR" \
|
||||
|
||||
@@ -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: 70adb72c87...4dde5f8a06
@@ -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>
|
||||
|
||||
+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)]
|
||||
|
||||
+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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+36
-10
@@ -114,6 +114,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// fire once; <see cref="RealmJoinPackageStarted"/> / <see cref="RealmJoinPackageCompleted"/>
|
||||
/// fire once per <c>(scope, packageId)</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="RealmJoinDetected"/> carries the phase RJ is in when the service key is first
|
||||
/// seen in this process — Blank (0) only while the value is not written yet. After an agent
|
||||
/// restart RJ may already be deploying: the engine's restart path and the keep-awake
|
||||
/// hand-over to RJ both read the phase from Detected, because no PhaseChanged follows when
|
||||
/// the phase does not change again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RealmJoinWatcher : IDisposable
|
||||
{
|
||||
@@ -127,6 +134,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal delegate bool KeyExistsDelegate(RegistryHive hive, string subPath);
|
||||
internal delegate RealmJoinVersionInfo ReadVersionInfoDelegate();
|
||||
internal delegate string? ReadUninstallDisplayVersionDelegate();
|
||||
internal delegate int? ReadDeploymentPhaseDelegate();
|
||||
|
||||
/// <summary>
|
||||
/// Debounce for the Uninstall-key watcher. Longer than <see cref="DebounceMilliseconds"/>:
|
||||
@@ -141,6 +149,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly KeyExistsDelegate _keyExists;
|
||||
private readonly ReadVersionInfoDelegate _readVersionInfo;
|
||||
private readonly ReadUninstallDisplayVersionDelegate _readUninstallDisplayVersion;
|
||||
private readonly ReadDeploymentPhaseDelegate _readDeploymentPhase;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
// Auto-update stage (armed once Detected fired, held until Stop).
|
||||
@@ -204,7 +213,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
TryReadPackageDelegate tryReadPackage,
|
||||
KeyExistsDelegate keyExists,
|
||||
ReadVersionInfoDelegate? readVersionInfo = null,
|
||||
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null)
|
||||
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null,
|
||||
ReadDeploymentPhaseDelegate? readDeploymentPhase = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_enumeratePackageIds = enumeratePackageIds ?? throw new ArgumentNullException(nameof(enumeratePackageIds));
|
||||
@@ -212,6 +222,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_keyExists = keyExists ?? throw new ArgumentNullException(nameof(keyExists));
|
||||
_readVersionInfo = readVersionInfo ?? RealmJoinInfo.TryReadRealmJoinVersionInfo;
|
||||
_readUninstallDisplayVersion = readUninstallDisplayVersion ?? RealmJoinInfo.TryReadUninstallDisplayVersion;
|
||||
_readDeploymentPhase = readDeploymentPhase ?? (() => RealmJoinInfo.TryReadDeploymentPhase(RegistryHive.LocalMachine));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -452,14 +463,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
// The appearance of Services\realmjoin is itself the RJ-detected signal —
|
||||
// surface it even if Parameters\DeploymentPhase isn't populated yet. The package
|
||||
// watchers (HKLM + HKU SOFTWARE\RealmJoin) intentionally stay disarmed here:
|
||||
// NotifyRealmJoinPresence arms them once DeploymentPhase >= PhaseRunningThresholdMin
|
||||
// is observed. Before that the same subtree may still receive in-flight IME
|
||||
// package writes that are not part of RJ's lifecycle.
|
||||
NotifyRealmJoinPresence(phase: null);
|
||||
CheckParameters();
|
||||
NotifyServiceStageAttached();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The appearance of Services\realmjoin is itself the RJ-detected signal — surfaced even
|
||||
/// if Parameters\DeploymentPhase isn't populated yet. One read feeds Detected, so it
|
||||
/// carries the phase RJ is in right now (see the class remarks). The package watchers
|
||||
/// (HKLM + HKU SOFTWARE\RealmJoin) stay disarmed below PhaseRunningThresholdMin: before
|
||||
/// that the same subtree may still receive in-flight IME package writes that are not
|
||||
/// part of RJ's lifecycle.
|
||||
/// </summary>
|
||||
private void NotifyServiceStageAttached()
|
||||
{
|
||||
int? phase = null;
|
||||
try { phase = _readDeploymentPhase(); }
|
||||
catch (Exception ex) { _logger.Warning($"RealmJoinWatcher: DeploymentPhase read at attach threw: {ex.Message}"); }
|
||||
NotifyRealmJoinPresence(phase);
|
||||
}
|
||||
|
||||
private void AttachMachineRealmJoinSubtreeWatcher()
|
||||
@@ -550,7 +570,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var phase = RealmJoinInfo.TryReadDeploymentPhase(RegistryHive.LocalMachine);
|
||||
var phase = _readDeploymentPhase();
|
||||
if (phase == null) return;
|
||||
NotifyRealmJoinPresence(phase);
|
||||
}
|
||||
@@ -580,6 +600,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
fireDetected = !_detectedFired;
|
||||
if (fireDetected) _detectedFired = true;
|
||||
|
||||
// Detected reports Blank (0) while the value is not written yet. That report is
|
||||
// the baseline, so a first real value of 100+ surfaces as PhaseChanged instead of
|
||||
// silently setting _lastPhase.
|
||||
if (fireDetected && !phase.HasValue && !_lastPhase.HasValue) _lastPhase = 0;
|
||||
|
||||
if (phase.HasValue)
|
||||
{
|
||||
if (_lastPhase.HasValue && _lastPhase.Value != phase.Value)
|
||||
@@ -926,6 +951,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
// Test seams — drive the read paths deterministically without touching the real registry.
|
||||
internal void TriggerCheckParametersFromTest() => CheckParameters();
|
||||
internal void TriggerServiceStageAttachedFromTest() => NotifyServiceStageAttached();
|
||||
internal void TriggerCheckMachinePackagesFromTest() => CheckMachinePackages();
|
||||
internal void TriggerCheckUserPackagesFromTest() => CheckUserPackages();
|
||||
|
||||
|
||||
@@ -356,28 +356,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
hosts.Add(aadJoinHost);
|
||||
aadJoinHostRef = aadJoinHost;
|
||||
|
||||
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
|
||||
// the DesktopArrival observer above already null-guards it, so the HKU watcher is
|
||||
// never armed and no RealmJoin signals are produced. The compile-time const
|
||||
// RealmJoinHost.RealmJoinTrackingEnabled remains a build-time master kill-switch;
|
||||
// the effective enable is (remote flag AND const).
|
||||
var analyzers = _remoteConfig.Analyzers ?? new AnalyzerConfiguration();
|
||||
if (analyzers.EnableRealmJoinWatcher && RealmJoinHost.RealmJoinTrackingEnabled)
|
||||
{
|
||||
realmJoinHost = new RealmJoinHost(logger, ingress, clock);
|
||||
hosts.Add(realmJoinHost);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"DefaultComponentFactory: RealmJoinHost not created (EnableRealmJoinWatcher={analyzers.EnableRealmJoinWatcher}, RealmJoinTrackingEnabled={RealmJoinHost.RealmJoinTrackingEnabled})");
|
||||
}
|
||||
|
||||
// Keep-awake during User-ESP — opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// KeepAwakeDuringUserEsp, default off). The host observes the signal rail and holds the
|
||||
// device awake (system + display) from AccountSetup entry until provisioning completes
|
||||
// (or a safety cap), so idle standby cannot stall app installs / account setup. Reboots
|
||||
// device awake (system + display) from AccountSetup entry until the user-ESP page
|
||||
// closes — with the RealmJoin Watcher on, until RealmJoin starts its deployment (D-413) —
|
||||
// or a safety cap, so idle standby cannot stall app installs / account setup. Reboots
|
||||
// are unaffected; the OS auto-clears the hold on process exit.
|
||||
// ORDER: before the RealmJoinHost — after an agent restart the watcher posts
|
||||
// realmjoin_detected from its own Start, and this host must already be subscribed.
|
||||
if (analyzers.KeepAwakeDuringUserEsp)
|
||||
{
|
||||
hosts.Add(new UserEspKeepAwakeHost(
|
||||
@@ -386,7 +374,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
logger: logger,
|
||||
// q8n: release on the user-ESP page exit, i.e. a Shell-Core 62407 that arrives
|
||||
// after the AccountSetup registry left notStarted — never on the handoff exit.
|
||||
accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress));
|
||||
accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress,
|
||||
// D-414: that exit is latched here, so a later agent run does not engage again.
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
|
||||
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
|
||||
// the DesktopArrival observer above already null-guards it, so the HKU watcher is
|
||||
// never armed and no RealmJoin signals are produced. The compile-time const
|
||||
// RealmJoinHost.RealmJoinTrackingEnabled remains a build-time master kill-switch;
|
||||
// the effective enable is (remote flag AND const).
|
||||
if (analyzers.EnableRealmJoinWatcher && RealmJoinHost.RealmJoinTrackingEnabled)
|
||||
{
|
||||
realmJoinHost = new RealmJoinHost(logger, ingress, clock);
|
||||
hosts.Add(realmJoinHost);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"DefaultComponentFactory: RealmJoinHost not created (EnableRealmJoinWatcher={analyzers.EnableRealmJoinWatcher}, RealmJoinTrackingEnabled={RealmJoinHost.RealmJoinTrackingEnabled})");
|
||||
}
|
||||
|
||||
// OOBE-console / Shift+F10 detection — opt-OUT per tenant (portal toggle →
|
||||
|
||||
+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; } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,49 @@ public class ConfigBackupHookTests
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_GraphAccessState_NoSnapshot()
|
||||
{
|
||||
// Token side effect: stamped and cleared without anyone changing the configuration.
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.GraphAccessRejectedSince = DateTime.UtcNow.AddMinutes(-20);
|
||||
incoming.GraphAccessRejectedCode = "AADSTS7000229";
|
||||
incoming.GraphAccessRejectionConfirmedAt = DateTime.UtcNow;
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "graph-access", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AddOnPermissionState_NoSnapshot()
|
||||
{
|
||||
// Agent and token side effect, like the Graph access state.
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.IntuneDeviceBindingPermissionMissingSince = DateTime.UtcNow.AddMinutes(-20);
|
||||
incoming.IntuneDeviceBindingPermissionMissingConfirmedAt = DateTime.UtcNow;
|
||||
incoming.CloudPcPermissionMissingSince = DateTime.UtcNow.AddMinutes(-20);
|
||||
incoming.CloudPcPermissionMissingConfirmedAt = DateTime.UtcNow;
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "graph-access", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoOpSave_IdenticalContent_NoSnapshot()
|
||||
{
|
||||
|
||||
@@ -5,9 +5,9 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cover for the ConnectionType predicate used on every search path that does NOT push the
|
||||
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
|
||||
/// unpaged scan. Sessions that predate the projection (null ConnectionType) must never match
|
||||
/// Cover for the ConnectionType predicate used on the search path that does NOT push the
|
||||
/// filter to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>). Sessions that
|
||||
/// predate the projection (null ConnectionType) must never match
|
||||
/// a set filter; an unset filter matches everything.
|
||||
/// </summary>
|
||||
public class ConnectionTypeFilterTests
|
||||
|
||||
@@ -65,7 +65,7 @@ public class CorporateIdentifierValidatorTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
|
||||
@@ -95,6 +95,7 @@ public class DerivedWriterLivenessTests
|
||||
configService: null!,
|
||||
channelDispatcher: null!,
|
||||
GuardFor(row),
|
||||
stopReporter: null!,
|
||||
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
|
||||
|
||||
await handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
|
||||
@@ -116,6 +117,7 @@ public class DerivedWriterLivenessTests
|
||||
ruleService: null!, Mock.Of<IRuleRepository>(), Mock.Of<ISessionRepository>(), Mock.Of<IMetricsRepository>(),
|
||||
signalRNotification: null!, configService: null!, channelDispatcher: null!,
|
||||
new SessionDeletionGuard(reader.Object, NullLogger<SessionDeletionGuard>.Instance),
|
||||
stopReporter: null!,
|
||||
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<Azure.RequestFailedException>(() => handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
|
||||
|
||||
@@ -101,9 +101,8 @@ public class DeviceAssociationValidatorTests
|
||||
Assert.Equal("preassociated", result.AssociationState);
|
||||
Assert.Equal("00000000-0000-0000-0000-000000000000", result.DevicePreparationPolicyId);
|
||||
Assert.Equal("admin@contoso.com", result.PreAssociatedByUserPrincipalName);
|
||||
// Newtonsoft.Json yields "" for JSON null when calling .ToString() on the JToken;
|
||||
// downstream telemetry uses IsNullOrEmpty so both null and "" surface as "absent".
|
||||
Assert.True(string.IsNullOrEmpty(result.AssignedToUserPrincipalName));
|
||||
// JSON null is absence (D-420); the former Newtonsoft reader turned it into "".
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
Assert.NotNull(result.PreAssociationDateTime);
|
||||
Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -12,8 +13,8 @@ using Moq;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path,
|
||||
/// never the bot), recipient validation, the Global-Admin gate, the rendered message, and the
|
||||
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path),
|
||||
/// recipient validation, the Global-Admin gate, the rendered message, and the
|
||||
/// send against a stubbed provider endpoint.
|
||||
/// </summary>
|
||||
public class EmailChannelProviderTests
|
||||
@@ -30,15 +31,6 @@ public class EmailChannelProviderTests
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
{
|
||||
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<IEmailChannelSender> EmailMock()
|
||||
{
|
||||
var mock = new Mock<IEmailChannelSender>();
|
||||
@@ -50,6 +42,9 @@ public class EmailChannelProviderTests
|
||||
private static NotificationChannel Channel(string? url, string id = "mail", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = Email, Url = url, Enabled = enabled };
|
||||
|
||||
private static NotificationChannel Webhook(WebhookProviderType provider, string url)
|
||||
=> new() { Id = provider.ToString(), Name = provider.ToString(), ProviderType = (int)provider, Url = url, Enabled = true };
|
||||
|
||||
private static NotificationAlert Alert() => new()
|
||||
{
|
||||
EventType = "PushDeliveryFailed",
|
||||
@@ -67,9 +62,8 @@ public class EmailChannelProviderTests
|
||||
public async Task Email_channel_reaches_the_email_sender_with_its_recipients_and_never_the_webhook_path()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var email = EmailMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var alert = Alert();
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid; two@example.invalid") }, alert, Platform);
|
||||
@@ -77,7 +71,6 @@ public class EmailChannelProviderTests
|
||||
email.Verify(e => e.SendAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -86,7 +79,7 @@ public class EmailChannelProviderTests
|
||||
var email = EmailMock();
|
||||
email.Setup(e => e.SendAlertAsync("ops@example.invalid", It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "Sent to 1 recipient(s)." });
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform);
|
||||
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
@@ -99,7 +92,7 @@ public class EmailChannelProviderTests
|
||||
[Fact]
|
||||
public async Task Dispatcher_without_an_email_transport_skips_the_channel_and_says_so_on_test()
|
||||
{
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>());
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid") }, Alert(), Platform); // no throw
|
||||
var result = await dispatcher.SendTestAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
@@ -108,6 +101,54 @@ public class EmailChannelProviderTests
|
||||
Assert.Contains("not registered", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Webhook_channel_reaches_the_webhook_path_and_never_the_email_sender()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var email = EmailMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var alert = Alert();
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, Platform);
|
||||
|
||||
webhook.Verify(w => w.SendAsync("https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_list_each_channel_reaches_its_own_transport()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var email = EmailMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var alert = Alert();
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel("ops@example.invalid"),
|
||||
Webhook(WebhookProviderType.GenericJson, "https://sales.example/hook"),
|
||||
}, alert, Platform);
|
||||
|
||||
email.Verify(e => e.SendAlertAsync("ops@example.invalid", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync("https://sales.example/hook", WebhookProviderType.GenericJson, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Channels_without_a_destination_are_skipped_on_every_transport()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var email = EmailMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel(null), Webhook(WebhookProviderType.Slack, "") }, Alert(), Platform);
|
||||
|
||||
email.Verify(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Validation and gate ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
@@ -138,6 +179,10 @@ public class EmailChannelProviderTests
|
||||
|
||||
var bad = NotificationChannel.SerializeList(new[] { Channel("https://hooks.example.invalid/x") });
|
||||
Assert.Contains("not a valid e-mail address", TenantConfigValidation.ValidateNotificationChannels(bad));
|
||||
|
||||
// The branch is per provider: a recipient list on a webhook channel still meets the URL gate.
|
||||
var onWebhook = NotificationChannel.SerializeList(new[] { Webhook(WebhookProviderType.Slack, "ops@example.invalid") });
|
||||
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(onWebhook));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -146,14 +191,45 @@ public class EmailChannelProviderTests
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider(Email));
|
||||
var candidate = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: true));
|
||||
var refused = TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: false);
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: true));
|
||||
var refused = TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: false);
|
||||
Assert.Equal("E-mail channels can only be configured by a Global Administrator.", refused);
|
||||
|
||||
// A GA-created channel survives a tenant admin's unrelated save; retargeting it does not.
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, candidate, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, candidate, isGlobalAdmin: false));
|
||||
var retargeted = NotificationChannel.SerializeList(new[] { Channel("other@example.invalid") });
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, candidate, isGlobalAdmin: false));
|
||||
Assert.NotNull(TenantConfigValidation.ValidatePlatformProviderGate(retargeted, candidate, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_refuses_a_tenant_admin_who_flips_the_enabled_state()
|
||||
{
|
||||
var existing = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid", enabled: true) });
|
||||
var disabled = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid", enabled: false) });
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidatePlatformProviderGate(disabled, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_ignores_webhook_channels()
|
||||
{
|
||||
var candidate = NotificationChannel.SerializeList(new[] { Webhook(WebhookProviderType.GenericJson, "https://sales.example/hook") });
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(candidate, null, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_enforces_the_gate_for_every_client_write()
|
||||
{
|
||||
var candidate = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
NotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") }),
|
||||
};
|
||||
var existing = new TenantConfiguration { TenantId = "t1" };
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the AADSTS code out of Entra's token error body. The body below has the shape Entra
|
||||
/// really sends for a tenant whose enterprise application was removed; the ids are synthetic.
|
||||
/// </summary>
|
||||
public class EntraTokenRejectionTests
|
||||
{
|
||||
private const string MissingServicePrincipalBody =
|
||||
"{\"error\":\"invalid_client\"," +
|
||||
"\"error_description\":\"AADSTS7000229: The client application aaaaaaaa-0000-0000-0000-000000000001 is missing service principal in the tenant 11111111-1111-1111-1111-111111111111. See instructions here: https://go.microsoft.com/fwlink/?linkid=2225119\\r\\nTrace ID: 00000000-0000-0000-0000-000000000002\\r\\nCorrelation ID: 00000000-0000-0000-0000-000000000003\\r\\nTimestamp: 2026-11-01 08:00:00Z\"," +
|
||||
"\"error_codes\":[7000229]," +
|
||||
"\"timestamp\":\"2026-11-01 08:00:00Z\"," +
|
||||
"\"trace_id\":\"00000000-0000-0000-0000-000000000002\"," +
|
||||
"\"correlation_id\":\"00000000-0000-0000-0000-000000000003\"," +
|
||||
"\"error_uri\":\"https://login.microsoftonline.com/error?code=7000229\"}";
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_the_code_and_keeps_only_the_first_line_of_the_description()
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(401, MissingServicePrincipalBody);
|
||||
|
||||
Assert.Equal(401, rejection.StatusCode);
|
||||
Assert.Equal("AADSTS7000229", rejection.ErrorCode);
|
||||
Assert.True(rejection.IsServicePrincipalMissing);
|
||||
Assert.False(rejection.IsOurCredential);
|
||||
Assert.StartsWith("AADSTS7000229: The client application", rejection.Description);
|
||||
Assert.DoesNotContain("Trace ID", rejection.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_falls_back_to_the_description_without_error_codes()
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: Access has been blocked by Conditional Access policies.\"}");
|
||||
|
||||
Assert.Equal("AADSTS53003", rejection.ErrorCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("<html>Bad Request</html>")]
|
||||
[InlineData("[1,2]")]
|
||||
[InlineData("{\"error\":\"invalid_request\"}")]
|
||||
public void Parse_keeps_only_the_status_when_the_body_carries_no_code(string? body)
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, body);
|
||||
|
||||
Assert.Equal(400, rejection.StatusCode);
|
||||
Assert.Null(rejection.ErrorCode);
|
||||
Assert.Null(rejection.Description);
|
||||
Assert.False(rejection.IsServicePrincipalMissing);
|
||||
Assert.False(rejection.IsOurCredential);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_cuts_a_long_description()
|
||||
{
|
||||
var body = "{\"error_description\":\"AADSTS50000: " + new string('x', 1000) + "\"}";
|
||||
|
||||
Assert.Equal(300, EntraTokenRejection.Parse(400, body).Description!.Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AADSTS7000215", true)]
|
||||
[InlineData("AADSTS7000222", true)]
|
||||
[InlineData("AADSTS7000218", true)]
|
||||
[InlineData("aadsts7000222", true)]
|
||||
[InlineData("AADSTS7000229", false)]
|
||||
[InlineData("AADSTS7000112", false)]
|
||||
[InlineData("AADSTS53003", false)]
|
||||
public void IsOurCredential_only_blames_our_own_secret(string code, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, new EntraTokenRejection(401, code, null).IsOurCredential);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Feedback;
|
||||
@@ -19,8 +18,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// The rating bubble's answer route, driven through <c>ProcessSubmitAsync</c> (the HTTP shell only
|
||||
/// reads the body and maps the validation error). Pins that a recorded rating, and nothing else,
|
||||
/// raises <c>FeedbackRatingReceived</c>, the Global Admin bell and the Telegram ping, and that the
|
||||
/// alert carries neither the comment nor the sender.
|
||||
/// raises <c>FeedbackRatingReceived</c> and the Global Admin bell, and that the alert carries neither
|
||||
/// the comment nor the sender.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFunctionSubmitTests
|
||||
{
|
||||
@@ -34,7 +33,7 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
private static readonly string EntryPath = Constants.PortalUserFeedbackEntryPath(FeedbackId);
|
||||
|
||||
[Fact]
|
||||
public async Task Rating_IsStored_RaisesTheEvent_RingsTheBell_AndPings()
|
||||
public async Task Rating_IsStored_RaisesTheEvent_AndRingsTheBell()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
@@ -64,13 +63,12 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", EntryPath), Times.Once);
|
||||
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 4, Comment), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheAlert_CarriesNeitherCommentNorSender()
|
||||
{
|
||||
// Channels bound to FeedbackRatingReceived (Telegram, Slack, webhooks, e-mail) receive message
|
||||
// Channels bound to FeedbackRatingReceived (Slack, webhooks, e-mail) receive message
|
||||
// and details; the comment is read in the portal. Personal data must never ride along.
|
||||
var h = new Harness();
|
||||
|
||||
@@ -160,7 +158,6 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
Assert.Empty(h.OpsEvents);
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", It.IsAny<string>(), EntryPath), Times.Once);
|
||||
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 3, Comment), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -183,7 +180,6 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
public FakeFeedbackRepository Repo { get; }
|
||||
public List<OpsEventEntry> OpsEvents { get; } = new();
|
||||
public Mock<GlobalNotificationService> Bell { get; }
|
||||
public Mock<TelegramNotificationService> Telegram { get; }
|
||||
|
||||
public Harness(FeedbackEntry? current = null, bool configExists = true, bool opsEventThrows = false)
|
||||
{
|
||||
@@ -203,9 +199,6 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
Mock.Of<INotificationRepository>(), new FakeSignalRNotificationService(),
|
||||
Mock.Of<ILogger<GlobalNotificationService>>())
|
||||
{ CallBase = false };
|
||||
Telegram = new Mock<TelegramNotificationService>(
|
||||
new HttpClient(), configRepo, Mock.Of<ILogger<TelegramNotificationService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
@@ -216,7 +209,7 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
|
||||
Sut = new FeedbackFunction(
|
||||
NullLogger<FeedbackFunction>.Instance, tenantConfig.Object, adminConfig.Object, tenantAdmins.Object,
|
||||
Telegram.Object, Mock.Of<ISessionRepository>(), Repo,
|
||||
Mock.Of<ISessionRepository>(), Repo,
|
||||
BuildOpsEventService(adminConfig.Object, opsEventThrows), Bell.Object);
|
||||
}
|
||||
|
||||
@@ -228,8 +221,6 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
{
|
||||
Bell.Verify(b => b.CreateNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>()), Times.Never);
|
||||
Telegram.Verify(t => t.SendFeedbackAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>A real OpsEventService over a capturing repository, so the actual record method runs.</summary>
|
||||
|
||||
@@ -23,7 +23,8 @@ public class FleetHealthPayloadTests
|
||||
string manufacturer = "Contoso",
|
||||
string model = "Laptop",
|
||||
string? failureReason = null,
|
||||
DateTime? startedAt = null)
|
||||
DateTime? startedAt = null,
|
||||
string? groupTag = null)
|
||||
=> new()
|
||||
{
|
||||
TenantId = Guid.NewGuid().ToString(),
|
||||
@@ -34,8 +35,106 @@ public class FleetHealthPayloadTests
|
||||
Model = model,
|
||||
FailureReason = failureReason!,
|
||||
StartedAt = startedAt ?? DateTime.UtcNow,
|
||||
AutopilotGroupTag = groupTag,
|
||||
};
|
||||
|
||||
private static FleetGroupTagFilter Filter(string? excludeGroupTags, bool excludeUntagged = false)
|
||||
{
|
||||
Assert.True(FleetGroupTagFilter.TryParse(excludeGroupTags, excludeUntagged ? "true" : null, out var filter, out _));
|
||||
return filter;
|
||||
}
|
||||
|
||||
// ── Group tag breakdown and view filter ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GroupTagHealth_IsEmpty_WhenNoSessionCarriesATag()
|
||||
{
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(
|
||||
new List<SessionSummary> { S(SessionStatus.Succeeded), S(SessionStatus.Failed) }, days: 7);
|
||||
|
||||
Assert.Empty(payload.GroupTagHealth);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroupTagHealth_GroupsWithoutCase_InTheMostFrequentSpelling_WithANoTagBucket()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Succeeded, groupTag: "TEST"),
|
||||
S(SessionStatus.Failed, groupTag: "TEST"),
|
||||
S(SessionStatus.Failed, groupTag: " test "),
|
||||
S(SessionStatus.Succeeded, groupTag: "PROD"),
|
||||
S(SessionStatus.InProgress),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7);
|
||||
|
||||
Assert.Equal(new[] { "TEST", "No group tag", "PROD" }.OrderBy(l => l), payload.GroupTagHealth.Select(g => g.Label).OrderBy(l => l));
|
||||
var test = payload.GroupTagHealth.Single(g => g.GroupTag == "TEST");
|
||||
Assert.Equal((3, 1, 2), (test.Total, test.Succeeded, test.Failed));
|
||||
Assert.Equal(string.Empty, payload.GroupTagHealth.Single(g => g.Label == "No group tag").GroupTag);
|
||||
Assert.Equal("TEST", payload.GroupTagHealth[0].GroupTag); // by volume
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_TakesExcludedTagsOutOfEveryFigure_ButKeepsThemFlaggedInTheBreakdown()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Failed, groupTag: "TEST", model: "Lab VM", failureReason: "lab"),
|
||||
S(SessionStatus.Failed, groupTag: "test", model: "Lab VM", failureReason: "lab"),
|
||||
S(SessionStatus.Succeeded, groupTag: "PROD"),
|
||||
S(SessionStatus.Succeeded),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7, Filter("Test"));
|
||||
|
||||
Assert.Equal(2, payload.ExcludedSessions);
|
||||
Assert.Equal(new[] { "Test" }, payload.ExcludedGroupTags);
|
||||
Assert.False(payload.ExcludeUntagged);
|
||||
Assert.Equal(2, payload.Stats.Total);
|
||||
Assert.Equal(100d, payload.Stats.SuccessRate);
|
||||
Assert.Empty(payload.FailureReasons);
|
||||
Assert.DoesNotContain(payload.ModelHealth, m => m.Model == "Lab VM");
|
||||
Assert.Equal(0, payload.DailyData.Sum(d => d.Failed));
|
||||
var excluded = Assert.Single(payload.GroupTagHealth, g => g.Excluded);
|
||||
Assert.Equal(("TEST", 2), (excluded.GroupTag, excluded.Total));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_ExcludeUntagged_TakesTheSessionsWithoutATagOut()
|
||||
{
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S(SessionStatus.Failed),
|
||||
S(SessionStatus.Succeeded, groupTag: "PROD"),
|
||||
};
|
||||
|
||||
var payload = MetricsMath.BuildFleetHealthPayload(sessions, days: 7, Filter(null, excludeUntagged: true));
|
||||
|
||||
Assert.Equal(1, payload.ExcludedSessions);
|
||||
Assert.True(payload.ExcludeUntagged);
|
||||
Assert.Equal(100d, payload.Stats.SuccessRate);
|
||||
Assert.True(payload.GroupTagHealth.Single(g => g.GroupTag.Length == 0).Excluded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TrimsDeduplicatesAndCapsTheList()
|
||||
{
|
||||
Assert.True(FleetGroupTagFilter.TryParse(" TEST , test,,LAB ", null, out var filter, out _));
|
||||
Assert.Equal(new[] { "TEST", "LAB" }, filter.ExcludedTags);
|
||||
Assert.True(filter.IsActive);
|
||||
|
||||
Assert.True(FleetGroupTagFilter.TryParse(null, null, out var none, out _));
|
||||
Assert.False(none.IsActive);
|
||||
|
||||
var tooMany = string.Join(",", Enumerable.Range(0, FleetGroupTagFilter.MaxExcludedTags + 1).Select(i => $"T{i}"));
|
||||
Assert.False(FleetGroupTagFilter.TryParse(tooMany, null, out _, out var error));
|
||||
Assert.Contains("at most", error);
|
||||
|
||||
Assert.False(FleetGroupTagFilter.TryParse(new string('x', 300), null, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyInput_YieldsZeroedStats_AndOneDailyPointPerDay()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -34,10 +35,13 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
// — and that forces a deliberate code review of whether the new field is non-sensitive.
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
// Non-sensitive by review: booleans only, the timestamps stay in the admin-gated config.
|
||||
"addOnPermissionMissingBlocksAll",
|
||||
// Non-sensitive by review: bool only, exposes no client ids (dual app-reg
|
||||
// self-service migration banner).
|
||||
"appHomingFunnelActive",
|
||||
"bootstrapTokenEnabled",
|
||||
"cloudPcPermissionMissing",
|
||||
// Non-sensitive by review: bool only ("is a company name set"), never the value
|
||||
// itself (admin-gated full config only).
|
||||
"companyNameSet",
|
||||
@@ -55,6 +59,10 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
"enableSoftwareInventoryAnalyzer",
|
||||
"entitlements",
|
||||
// Non-sensitive by review: booleans only; the Entra code stays in the admin-gated config.
|
||||
"graphAccessAppMissing",
|
||||
"graphAccessRejected",
|
||||
"intuneDeviceBindingPermissionMissing",
|
||||
"isTrial",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
@@ -65,6 +73,122 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
}, fieldNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_GraphAccess_SuspicionAloneShowsNothing()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
GraphAccessRejectedSince = Now.AddMinutes(-5),
|
||||
GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode,
|
||||
});
|
||||
|
||||
Assert.False(element.GetProperty("graphAccessRejected").GetBoolean());
|
||||
Assert.False(element.GetProperty("graphAccessAppMissing").GetBoolean());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EntraTokenRejection.ServicePrincipalMissingCode, true)]
|
||||
[InlineData("AADSTS53003", false)]
|
||||
[InlineData(null, false)]
|
||||
public void Payload_GraphAccess_ConfirmedShowsTheNotice_AndNamesOnlyTheMissingApp(string? code, bool appMissing)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
GraphAccessRejectedSince = Now.AddHours(-1),
|
||||
GraphAccessRejectedCode = code,
|
||||
GraphAccessRejectionConfirmedAt = Now.AddMinutes(-45),
|
||||
});
|
||||
|
||||
Assert.True(element.GetProperty("graphAccessRejected").GetBoolean());
|
||||
Assert.Equal(appMissing, element.GetProperty("graphAccessAppMissing").GetBoolean());
|
||||
Assert.DoesNotContain("AADSTS", element.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_GraphAccess_StaysSilentWithoutDeviceValidation()
|
||||
{
|
||||
// The switch being off has its own banner; the agent does not depend on Graph then.
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
GraphAccessRejectedSince = Now.AddHours(-1),
|
||||
GraphAccessRejectedCode = EntraTokenRejection.ServicePrincipalMissingCode,
|
||||
GraphAccessRejectionConfirmedAt = Now.AddMinutes(-45),
|
||||
});
|
||||
|
||||
Assert.False(element.GetProperty("graphAccessRejected").GetBoolean());
|
||||
Assert.False(element.GetProperty("graphAccessAppMissing").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_AddOn_SuspicionAloneShowsNothing()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
ValidateIntuneDeviceBinding = true,
|
||||
IntuneDeviceBindingPermissionMissingSince = Now.AddMinutes(-5),
|
||||
});
|
||||
|
||||
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
|
||||
Assert.False(element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_AddOn_ConfirmedShowsTheNotice_PerValidation()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
ValidateIntuneDeviceBinding = true,
|
||||
ValidateCloudPcDevice = true,
|
||||
CloudPcPermissionMissingSince = Now.AddHours(-1),
|
||||
CloudPcPermissionMissingConfirmedAt = Now.AddMinutes(-45),
|
||||
});
|
||||
|
||||
Assert.True(element.GetProperty("cloudPcPermissionMissing").GetBoolean());
|
||||
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
|
||||
// Autopilot still admits its devices.
|
||||
Assert.False(element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_AddOn_SwitchingTheValidationOffHidesTheNoticeAtOnce()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateAutopilotDevice = true,
|
||||
IntuneDeviceBindingPermissionMissingSince = Now.AddHours(-1),
|
||||
IntuneDeviceBindingPermissionMissingConfirmedAt = Now.AddMinutes(-45),
|
||||
});
|
||||
|
||||
Assert.False(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Only the binding is on: no device can be validated.
|
||||
[InlineData(false, false, true)]
|
||||
// The Cloud PC check is on and still works for Cloud PCs.
|
||||
[InlineData(true, false, false)]
|
||||
// Both are on and both are confirmed missing.
|
||||
[InlineData(true, true, true)]
|
||||
public void Payload_AddOn_BlocksAllOnlyWhenNoOtherValidationAdmits(bool cloudPcOn, bool cloudPcConfirmed, bool blocksAll)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
ValidateIntuneDeviceBinding = true,
|
||||
IntuneDeviceBindingPermissionMissingSince = Now.AddHours(-1),
|
||||
IntuneDeviceBindingPermissionMissingConfirmedAt = Now.AddMinutes(-45),
|
||||
ValidateCloudPcDevice = cloudPcOn,
|
||||
CloudPcPermissionMissingSince = cloudPcConfirmed ? Now.AddHours(-1) : null,
|
||||
CloudPcPermissionMissingConfirmedAt = cloudPcConfirmed ? Now.AddMinutes(-45) : null,
|
||||
});
|
||||
|
||||
Assert.True(element.GetProperty("intuneDeviceBindingPermissionMissing").GetBoolean());
|
||||
Assert.Equal(blocksAll, element.GetProperty("addOnPermissionMissingBlocksAll").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_PassesThroughExplicitFieldValues()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The two stages of a lost Graph access: one refusal only stamps a suspicion, a refusal at least
|
||||
/// 15 minutes later confirms it and alerts once, a freshly issued token ends the episode. Runs on
|
||||
/// the real configuration service over a stored row, so the storage round trip of the three fields
|
||||
/// and the decision on the fresh row are part of what is proven.
|
||||
/// </summary>
|
||||
public class GraphAccessHealthRecorderTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTimeOffset Start = new(2026, 11, 2, 8, 0, 0, TimeSpan.Zero);
|
||||
private static readonly EntraTokenRejection MissingApp = new(401, EntraTokenRejection.ServicePrincipalMissingCode, "missing");
|
||||
private static readonly EntraTokenRejection ConditionalAccess = new(400, "AADSTS53003", "blocked");
|
||||
private static readonly EntraTokenRejection ExpiredSecret = new(401, "AADSTS7000222", "expired");
|
||||
private static readonly string[] CoreRoles = { GraphAppPermissions.DeviceManagementServiceConfigReadAll };
|
||||
private static readonly string[] CoreAndBindingRoles =
|
||||
{ GraphAppPermissions.DeviceManagementServiceConfigReadAll, GraphAppPermissions.DeviceManagementManagedDevicesReadAll };
|
||||
private static readonly IReadOnlyList<AddOnValidation> Binding = new[] { AddOnValidation.IntuneDeviceBinding };
|
||||
|
||||
private readonly Mock<IConfigRepository> _repo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly List<OpsEventEntry> _events = new();
|
||||
private readonly TestTimeProvider _time = new(Start);
|
||||
private readonly GraphAccessHealthRecorder _sut;
|
||||
|
||||
public GraphAccessHealthRecorderTests()
|
||||
{
|
||||
_store = _repo.BackWithRow(ValidatedRow());
|
||||
_sut = NewInstance();
|
||||
}
|
||||
|
||||
private static TenantConfiguration ValidatedRow()
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.ValidateAutopilotDevice = true;
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>A backend instance of its own: its own config cache over the same stored row.</summary>
|
||||
private GraphAccessHealthRecorder NewInstance()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfig = new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => _events.Add(e))
|
||||
.Returns(Task.CompletedTask);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
_repo.Object, NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
var opsEvents = new OpsEventService(
|
||||
opsRepo.Object, NullLogger<OpsEventService>.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
|
||||
return new GraphAccessHealthRecorder(
|
||||
tenantConfig, new Lazy<OpsEventService>(() => opsEvents), NullLogger<GraphAccessHealthRecorder>.Instance, _time);
|
||||
}
|
||||
|
||||
private void Advance(TimeSpan by) => _time.SetUtcNow(_time.GetUtcNow() + by);
|
||||
|
||||
// ── Rejected: the two stages ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task First_rejection_only_stamps_the_suspicion()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Equal(Start.UtcDateTime, _store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Equal(EntraTokenRejection.ServicePrincipalMissingCode, _store.Row.GraphAccessRejectedCode);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Further_rejections_inside_the_window_write_nothing()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(TimeSpan.FromMinutes(14));
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Single(_store.Writes);
|
||||
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejection_after_the_window_confirms_and_alerts_once()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, ConditionalAccess);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(TimeSpan.FromMinutes(5));
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Equal(2, _store.Writes.Count);
|
||||
Assert.Equal(Start.UtcDateTime, _store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Equal(Start.UtcDateTime + GraphAccessHealthRecorder.ConfirmAfter, _store.Row.GraphAccessRejectionConfirmedAt);
|
||||
// The code of the latest refusal counts: it selects the text and the repair.
|
||||
Assert.Equal(EntraTokenRejection.ServicePrincipalMissingCode, _store.Row.GraphAccessRejectedCode);
|
||||
var alert = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRejected, alert.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Error, alert.Severity);
|
||||
Assert.Equal(TenantId, alert.TenantId);
|
||||
Assert.Contains(EntraTokenRejection.ServicePrincipalMissingCode, alert.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Across_instances_only_one_confirms_and_alerts()
|
||||
{
|
||||
var other = NewInstance();
|
||||
// The other instance holds a view from before the suspicion.
|
||||
await other.RecordRejectedAsync(TenantId, MissingApp);
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
await other.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Single(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_lost_write_alerts_nothing_and_the_next_rejection_decides_again()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
|
||||
_store.RejectWrites = true;
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
|
||||
_store.RejectWrites = false;
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Single(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_without_a_role_runs_through_the_same_two_stages()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, EntraTokenRejection.NoApplicationRole);
|
||||
Assert.Equal("NoApplicationRole", _store.Row!.GraphAccessRejectedCode);
|
||||
Assert.Empty(_events);
|
||||
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordRejectedAsync(TenantId, EntraTokenRejection.NoApplicationRole);
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_without_device_validation_stays_untouched()
|
||||
{
|
||||
_store.Row = TenantConfiguration.CreateDefault(TenantId);
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_without_a_row_stays_without_one()
|
||||
{
|
||||
_store.Row = null;
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
// ── Our own credentials ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Our_expired_secret_never_touches_the_tenant_and_alerts_once_per_hour()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
|
||||
Advance(TimeSpan.FromMinutes(59));
|
||||
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
var alert = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.GraphAppCredentialRejected, alert.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Critical, alert.Severity);
|
||||
|
||||
Advance(TimeSpan.FromMinutes(2));
|
||||
await _sut.RecordRejectedAsync(TenantId, ExpiredSecret);
|
||||
|
||||
Assert.Equal(2, _events.Count);
|
||||
}
|
||||
|
||||
// ── Minted ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task A_minted_token_writes_nothing_without_a_state()
|
||||
{
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_minted_token_clears_a_suspicion_silently()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.Null(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectedCode);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_minted_token_ends_a_confirmed_episode_with_one_recovery_event()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
_events.Clear();
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.Null(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectedCode);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
var recovery = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRecovered, recovery.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, recovery.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_a_recovery_a_new_episode_starts_at_the_first_stage()
|
||||
{
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Equal(_time.GetUtcNow().UtcDateTime, _store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
// ── Add-on validations: the same two stages per validation ─────────────
|
||||
|
||||
private void SwitchOn(bool binding = true, bool cloudPc = false, bool autopilot = true)
|
||||
{
|
||||
_store.Row!.ValidateAutopilotDevice = autopilot;
|
||||
_store.Row.ValidateIntuneDeviceBinding = binding;
|
||||
_store.Row.ValidateCloudPcDevice = cloudPc;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_device_kept_out_only_stamps_the_addon_suspicion()
|
||||
{
|
||||
SwitchOn();
|
||||
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
Assert.Equal(Start.UtcDateTime, _store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.Null(_store.Row.CloudPcPermissionMissingSince);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_device_kept_out_after_the_window_confirms_and_alerts_once()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(14));
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(1));
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(5));
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
Assert.Equal(2, _store.Writes.Count);
|
||||
Assert.Equal(Start.UtcDateTime + GraphAccessHealthRecorder.ConfirmAfter, _store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
var alert = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, alert.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, alert.Severity);
|
||||
Assert.Contains(GraphFeatureCatalog.FeatureIntuneDeviceBinding, alert.Details);
|
||||
Assert.Contains(GraphAppPermissions.DeviceManagementManagedDevicesReadAll, alert.Details);
|
||||
Assert.Contains("\"blocksAll\":false", alert.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_addon_runs_its_own_episode()
|
||||
{
|
||||
SwitchOn(binding: true, cloudPc: true);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(10));
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, new[] { AddOnValidation.CloudPc });
|
||||
Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, new[] { AddOnValidation.CloudPc, AddOnValidation.IntuneDeviceBinding });
|
||||
|
||||
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.NotNull(_store.Row.CloudPcPermissionMissingSince);
|
||||
Assert.Null(_store.Row.CloudPcPermissionMissingConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_switched_off_validation_is_never_stamped()
|
||||
{
|
||||
SwitchOn(binding: false);
|
||||
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_only_validation_reports_that_it_blocks_all()
|
||||
{
|
||||
SwitchOn(binding: true, autopilot: false);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
Assert.Contains("\"blocksAll\":true", Assert.Single(_events).Details);
|
||||
Assert.True(AddOnValidation.BlocksAllValidation(_store.Row!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_token_with_the_permission_ends_a_suspicion_silently()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
|
||||
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_token_with_the_permission_ends_a_confirmed_episode_with_one_event()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
_events.Clear();
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
|
||||
await _sut.RecordMintedAsync(TenantId, CoreAndBindingRoles);
|
||||
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
var resolved = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, resolved.Severity);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedByGrant, resolved.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_token_without_the_permission_confirms_only_after_the_window()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(TimeSpan.FromMinutes(5));
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
Assert.Single(_store.Writes);
|
||||
|
||||
Advance(TimeSpan.FromMinutes(10));
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fresh_token_without_an_addon_episode_writes_nothing()
|
||||
{
|
||||
SwitchOn();
|
||||
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Switching_the_validation_off_ends_a_confirmed_episode_once()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
Advance(GraphAccessHealthRecorder.ConfirmAfter);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
_events.Clear();
|
||||
_store.Row!.ValidateIntuneDeviceBinding = false;
|
||||
|
||||
// The maintenance run reads fresh, like another instance.
|
||||
var maintenance = NewInstance();
|
||||
await maintenance.ReconcileSwitchedOffAddOnsAsync(TenantId);
|
||||
await maintenance.ReconcileSwitchedOffAddOnsAsync(TenantId);
|
||||
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
var resolved = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedBySwitchOff, resolved.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reconciling_leaves_an_episode_alone_while_its_switch_is_on()
|
||||
{
|
||||
SwitchOn();
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
|
||||
await NewInstance().ReconcileSwitchedOffAddOnsAsync(TenantId);
|
||||
|
||||
Assert.Single(_store.Writes);
|
||||
Assert.NotNull(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
}
|
||||
|
||||
// ── Fail-soft: the agent route must not notice ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task A_storage_failure_never_reaches_the_caller()
|
||||
{
|
||||
_store.ThrowOnRead = new InvalidOperationException("storage down");
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
await _sut.RecordMintedAsync(TenantId, CoreRoles);
|
||||
await _sut.RecordAddOnPermissionMissingAsync(TenantId, Binding);
|
||||
await _sut.ReconcileSwitchedOffAddOnsAsync(TenantId);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_write_never_reaches_the_caller()
|
||||
{
|
||||
_store.ThrowOnWrite = new InvalidOperationException("storage down");
|
||||
|
||||
await _sut.RecordRejectedAsync(TenantId, MissingApp);
|
||||
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Tests.GraphResolution;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The maintenance run asks Entra for every tenant with a suspicion, so the state does not depend on
|
||||
/// device traffic. Runs the real token service and recorder; only Entra's answer is scripted.
|
||||
/// </summary>
|
||||
public class GraphAccessRecheckMaintenanceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OtherTenantId = "22222222-2222-2222-2222-222222222222";
|
||||
private const string MissingAppBody = "{\"error\":\"invalid_client\",\"error_description\":\"AADSTS7000229: missing service principal\",\"error_codes\":[7000229]}";
|
||||
private const string ConditionalAccessBody = "{\"error\":\"invalid_request\",\"error_description\":\"AADSTS53003: blocked\",\"error_codes\":[53003]}";
|
||||
private static readonly DateTimeOffset Now = new(2026, 11, 2, 8, 0, 0, TimeSpan.Zero);
|
||||
private static readonly string TokenBody = TestGraphToken.Body(TestGraphToken.ValidationRole);
|
||||
|
||||
private readonly ScriptedEntra _entra = new();
|
||||
private readonly Mock<IConfigRepository> _repo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly List<OpsEventEntry> _events = new();
|
||||
private readonly GraphTokenService _tokens;
|
||||
private readonly GraphAccessRecheckMaintenance _sut;
|
||||
|
||||
public GraphAccessRecheckMaintenanceTests()
|
||||
{
|
||||
_store = _repo.BackWithRow(ValidatedRow());
|
||||
_repo.Setup(r => r.GetAllTenantConfigurationsAsync())
|
||||
.ReturnsAsync(() => _store.Row == null
|
||||
? new List<TenantConfiguration>()
|
||||
: new List<TenantConfiguration> { ConfigRepoMockExtensions.Clone(_store.Row) });
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfig = new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => _events.Add(e))
|
||||
.Returns(Task.CompletedTask);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
_repo.Object, NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
var opsEvents = new OpsEventService(
|
||||
opsRepo.Object, NullLogger<OpsEventService>.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
var recorder = new GraphAccessHealthRecorder(
|
||||
tenantConfig, new Lazy<OpsEventService>(() => opsEvents), NullLogger<GraphAccessHealthRecorder>.Instance, new TestTimeProvider(Now));
|
||||
|
||||
_tokens = NewTokenService(new SingleHandlerFactory(_entra), recorder, tenantConfig);
|
||||
_sut = new GraphAccessRecheckMaintenance(_repo.Object, _tokens, recorder, NullLogger<GraphAccessRecheckMaintenance>.Instance);
|
||||
}
|
||||
|
||||
private static GraphTokenService NewTokenService(
|
||||
IHttpClientFactory factory, IGraphAccessHealthRecorder recorder, TenantConfigurationService tenantConfig)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
["EntraId:ClientSecret"] = "secret",
|
||||
}).Build();
|
||||
return new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance),
|
||||
tenantConfig,
|
||||
recorder);
|
||||
}
|
||||
|
||||
private static TenantConfiguration ValidatedRow()
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.ValidateAutopilotDevice = true;
|
||||
return row;
|
||||
}
|
||||
|
||||
private void Suspect(TimeSpan age, string code = EntraTokenRejection.ServicePrincipalMissingCode)
|
||||
{
|
||||
_store.Row!.GraphAccessRejectedSince = Now.UtcDateTime - age;
|
||||
_store.Row.GraphAccessRejectedCode = code;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirms_an_old_suspicion_without_device_traffic()
|
||||
{
|
||||
// A real 401 goes into the retry delay, which the budget cuts: the refusal must survive it.
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.Unauthorized, MissingAppBody);
|
||||
|
||||
Assert.Equal(1, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(Now.UtcDateTime, _store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_young_suspicion_stays_a_suspicion()
|
||||
{
|
||||
Suspect(TimeSpan.FromMinutes(10));
|
||||
_entra.Answer(HttpStatusCode.BadRequest, ConditionalAccessBody);
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.Null(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clears_the_notice_after_a_repair_made_directly_in_Entra()
|
||||
{
|
||||
Suspect(TimeSpan.FromHours(5));
|
||||
_store.Row!.GraphAccessRejectionConfirmedAt = Now.UtcDateTime - TimeSpan.FromHours(4);
|
||||
_entra.Answer(HttpStatusCode.OK, TokenBody);
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.Null(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRecovered, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_without_a_role_keeps_the_notice_up()
|
||||
{
|
||||
// A user's sign-in re-created the removed enterprise application without any permission.
|
||||
Suspect(TimeSpan.FromHours(5));
|
||||
_store.Row!.GraphAccessRejectionConfirmedAt = Now.UtcDateTime - TimeSpan.FromHours(4);
|
||||
_entra.Answer(HttpStatusCode.OK, TestGraphToken.Body());
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.NotNull(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_without_a_role_confirms_an_old_suspicion()
|
||||
{
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.OK, TestGraphToken.Body());
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Equal("NoApplicationRole", _store.Row.GraphAccessRejectedCode);
|
||||
Assert.Equal(OpsEventTypes.GraphAccessRejected, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cached_token_cannot_answer_the_question()
|
||||
{
|
||||
// The token this instance still holds stays valid after the enterprise app is gone.
|
||||
_entra.Answer(HttpStatusCode.OK, TokenBody);
|
||||
await _tokens.GetAccessTokenAsync(TenantId);
|
||||
Assert.Equal(1, _entra.Posts);
|
||||
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.BadRequest, ConditionalAccessBody);
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.Equal(2, _entra.Posts);
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectionConfirmedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_answer_from_Entra_decides_nothing()
|
||||
{
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.ServiceUnavailable, "{}");
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.NotNull(_store.Row!.GraphAccessRejectedSince);
|
||||
Assert.Null(_store.Row.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Empty(_events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_without_a_suspicion_is_not_asked()
|
||||
{
|
||||
Assert.Equal(0, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(0, _entra.Posts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tenant_that_switched_device_validation_off_is_not_asked()
|
||||
{
|
||||
Suspect(TimeSpan.FromMinutes(20));
|
||||
_store.Row!.ValidateAutopilotDevice = false;
|
||||
|
||||
Assert.Equal(0, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(0, _entra.Posts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_read_ends_the_run_quietly()
|
||||
{
|
||||
_repo.Setup(r => r.GetAllTenantConfigurationsAsync()).ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
|
||||
Assert.Equal(0, await _sut.RunAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_failing_tenant_does_not_stop_the_next()
|
||||
{
|
||||
// The stub throws on the unscripted first tenant, which the token service does not catch.
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var first = ValidatedRow();
|
||||
first.GraphAccessRejectedSince = Now.UtcDateTime;
|
||||
var second = ValidatedRow();
|
||||
second.TenantId = OtherTenantId;
|
||||
second.GraphAccessRejectedSince = Now.UtcDateTime;
|
||||
repo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(new List<TenantConfiguration> { first, second });
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var handler = new StubHttpMessageHandler().When($"/{OtherTenantId}/oauth2", HttpStatusCode.OK, TokenBody);
|
||||
var tenantConfig = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
var sut = new GraphAccessRecheckMaintenance(
|
||||
repo.Object, NewTokenService(new StubHttpClientFactory(handler), recorder.Object, tenantConfig), recorder.Object,
|
||||
NullLogger<GraphAccessRecheckMaintenance>.Instance);
|
||||
|
||||
Assert.Equal(2, await sut.RunAsync());
|
||||
|
||||
recorder.Verify(r => r.RecordMintedAsync(OtherTenantId, It.IsAny<IReadOnlyCollection<string>>()), Times.Once);
|
||||
}
|
||||
|
||||
// ── Add-on episodes ─────────────────────────────────────────────────────
|
||||
|
||||
private void AddOnSuspect(TimeSpan age, TimeSpan? confirmedAgo = null, bool switchedOn = true)
|
||||
{
|
||||
_store.Row!.ValidateIntuneDeviceBinding = switchedOn;
|
||||
_store.Row.IntuneDeviceBindingPermissionMissingSince = Now.UtcDateTime - age;
|
||||
if (confirmedAgo is { } ago)
|
||||
_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt = Now.UtcDateTime - ago;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirms_an_old_addon_suspicion_with_a_token_that_lacks_the_permission()
|
||||
{
|
||||
AddOnSuspect(TimeSpan.FromMinutes(20));
|
||||
_entra.Answer(HttpStatusCode.OK, TokenBody);
|
||||
|
||||
Assert.Equal(1, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(Now.UtcDateTime, _store.Row!.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionMissing, Assert.Single(_events).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ends_an_addon_episode_once_the_grant_arrives()
|
||||
{
|
||||
AddOnSuspect(TimeSpan.FromHours(5), confirmedAgo: TimeSpan.FromHours(4));
|
||||
_entra.Answer(HttpStatusCode.OK,
|
||||
TestGraphToken.Body(TestGraphToken.ValidationRole, GraphAppPermissions.DeviceManagementManagedDevicesReadAll));
|
||||
|
||||
await _sut.RunAsync();
|
||||
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(_store.Row.IntuneDeviceBindingPermissionMissingConfirmedAt);
|
||||
var resolved = Assert.Single(_events);
|
||||
Assert.Equal(OpsEventTypes.AddOnPermissionResolved, resolved.EventType);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedByGrant, resolved.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_switched_off_addon_ends_without_asking_Entra()
|
||||
{
|
||||
AddOnSuspect(TimeSpan.FromHours(5), confirmedAgo: TimeSpan.FromHours(4), switchedOn: false);
|
||||
|
||||
Assert.Equal(1, await _sut.RunAsync());
|
||||
|
||||
Assert.Equal(0, _entra.Posts);
|
||||
Assert.Null(_store.Row!.IntuneDeviceBindingPermissionMissingSince);
|
||||
var resolved = Assert.Single(_events);
|
||||
Assert.Contains(GraphAccessHealthRecorder.ResolvedBySwitchOff, resolved.Details);
|
||||
}
|
||||
|
||||
/// <summary>Entra's token endpoint with an answer that can change between calls.</summary>
|
||||
private sealed class ScriptedEntra : HttpMessageHandler
|
||||
{
|
||||
private HttpStatusCode _status = HttpStatusCode.OK;
|
||||
private string _body = "{}";
|
||||
|
||||
public int Posts { get; private set; }
|
||||
|
||||
public void Answer(HttpStatusCode status, string body)
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Posts++;
|
||||
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class GraphAuthFailureTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
return new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
}
|
||||
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
@@ -124,32 +124,38 @@ public class IntuneDeviceBindingValidatorTests
|
||||
Assert.Null(result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
// -- ParseEnrolledDateTime --
|
||||
// -- enrolledDateTime --
|
||||
|
||||
private static DateTimeOffset? EnrolledDateTime(string jsonValue)
|
||||
=> IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{ "id": "{{DeviceId}}", "enrolledDateTime": {{jsonValue}} }""", DeviceId).EnrolledDateTime;
|
||||
|
||||
[Fact]
|
||||
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
|
||||
public void EnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
|
||||
{
|
||||
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
|
||||
// as a real date would report an object age of ~2000 years and poison the race analysis.
|
||||
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
|
||||
Assert.Null(EnrolledDateTime("\"0001-01-01T00:00:00Z\""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not-a-date")]
|
||||
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
|
||||
[InlineData("null")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
[InlineData("\"not-a-date\"")]
|
||||
[InlineData("20260824")]
|
||||
[InlineData("true")]
|
||||
public void EnrolledDateTime_UnusableValue_IsNull(string jsonValue)
|
||||
{
|
||||
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
|
||||
Assert.Null(EnrolledDateTime(jsonValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnrolledDateTime_NormalizesToUtc()
|
||||
public void EnrolledDateTime_NormalizesToUtc()
|
||||
{
|
||||
// A device object created minutes ago is the signature of an enrollment race, so the
|
||||
// instant has to be comparable regardless of the offset Graph reports it in.
|
||||
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
|
||||
var parsed = EnrolledDateTime("\"2026-08-24T17:20:00+02:00\"");
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
|
||||
|
||||
@@ -79,7 +79,7 @@ public class IntuneEnrollmentValidationTests
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokens = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object)
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache, registry, tenantConfig.Object, Mock.Of<IGraphAccessHealthRecorder>())
|
||||
{ CallBase = false };
|
||||
tokens.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
@@ -290,7 +290,8 @@ public class IntuneEnrollmentValidationTests
|
||||
|
||||
// ------------------------------------------------------------------ SecurityValidator chain
|
||||
|
||||
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(TenantConfiguration config)
|
||||
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(
|
||||
TenantConfiguration config, IGraphAccessHealthRecorder? accessHealth = null, CloudPcDeviceValidator? cloudPc = null)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
@@ -318,7 +319,9 @@ public class IntuneEnrollmentValidationTests
|
||||
new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
|
||||
autopilotDeviceValidator: null!, corporateIdentifierValidator: null!,
|
||||
NullLogger.Instance,
|
||||
intuneDeviceBindingValidator: binding.Object);
|
||||
cloudPcDeviceValidator: cloudPc,
|
||||
intuneDeviceBindingValidator: binding.Object,
|
||||
accessHealth: accessHealth);
|
||||
return (validator, binding);
|
||||
}
|
||||
|
||||
@@ -407,6 +410,118 @@ public class IntuneEnrollmentValidationTests
|
||||
Assert.Equal(retryAfter, result.RetryAfterSeconds);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ missing add-on permission
|
||||
|
||||
private static Mock<CloudPcDeviceValidator> CloudPcAnswering(CloudPcValidationResult result)
|
||||
{
|
||||
var cloudPc = new Mock<CloudPcDeviceValidator>(
|
||||
NullLogger<CloudPcDeviceValidator>.Instance, Mock.Of<IHttpClientFactory>(),
|
||||
new MemoryCache(new MemoryCacheOptions()), null!)
|
||||
{ CallBase = false };
|
||||
cloudPc.Setup(c => c.ValidateCloudPcAsync(CertTenant, DeviceId, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
return cloudPc;
|
||||
}
|
||||
|
||||
private static readonly CloudPcValidationResult CloudPcPermissionMissing =
|
||||
new() { IsValid = false, PermissionMissing = true, ErrorMessage = "not permitted" };
|
||||
|
||||
private static Task<SecurityValidationResult> Validate(SecurityValidator validator, bool selfReportedCloudPc = false)
|
||||
=> validator.ValidateRequestAsync(CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant,
|
||||
selfReportedCloudPc: selfReportedCloudPc);
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_MissingBindingPermission_ReportsTheAddOn_AndKeepsTheResponse()
|
||||
{
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (withRecorder, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true }, recorder.Object);
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
|
||||
var (withoutRecorder, bindingWithout) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
|
||||
SetupAdmitting(bindingWithout, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
|
||||
|
||||
var reported = await Validate(withRecorder);
|
||||
var silent = await Validate(withoutRecorder);
|
||||
|
||||
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
|
||||
It.Is<IReadOnlyList<AddOnValidation>>(l => l.Count == 1 && l[0] == AddOnValidation.IntuneDeviceBinding)), Times.Once);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, reported.StatusCode);
|
||||
Assert.Equal(silent.StatusCode, reported.StatusCode);
|
||||
Assert.Equal(silent.RetryAfterSeconds, reported.RetryAfterSeconds);
|
||||
Assert.Equal(silent.Details, reported.Details);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(IntuneDeviceBindingOutcome.Match)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
|
||||
[InlineData(IntuneDeviceBindingOutcome.Transient)]
|
||||
public async Task Chain_OtherBindingOutcomes_ReportNothing(IntuneDeviceBindingOutcome outcome)
|
||||
{
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true }, recorder.Object);
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "x" });
|
||||
|
||||
await Validate(validator);
|
||||
|
||||
recorder.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)] // a laptop not (yet) in Autopilot or a Hyper-V test VM: the grant would not admit it either
|
||||
public async Task Chain_MissingCloudPcPermission_KeepsThe403_AndReportsOnlyASelfReportedCloudPc(bool selfReportedCloudPc)
|
||||
{
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (validator, _) = BuildChain(new TenantConfiguration { ValidateCloudPcDevice = true }, recorder.Object,
|
||||
CloudPcAnswering(CloudPcPermissionMissing).Object);
|
||||
|
||||
var result = await Validate(validator, selfReportedCloudPc);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
Assert.Contains("not permitted", result.Details);
|
||||
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
|
||||
It.Is<IReadOnlyList<AddOnValidation>>(l => l.Count == 1 && l[0] == AddOnValidation.CloudPc)),
|
||||
selfReportedCloudPc ? Times.Once() : Times.Never());
|
||||
recorder.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_MissingCloudPcPermission_ReportsNothing_WhenTheBindingAdmits()
|
||||
{
|
||||
// The device got in: the missing grant had no consequence.
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (validator, binding) = BuildChain(
|
||||
new TenantConfiguration { ValidateCloudPcDevice = true, ValidateIntuneDeviceBinding = true }, recorder.Object,
|
||||
CloudPcAnswering(CloudPcPermissionMissing).Object);
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.Match, IntuneDeviceId = DeviceId });
|
||||
|
||||
var result = await Validate(validator);
|
||||
|
||||
Assert.True(result.IsValid, result.ErrorMessage + " " + result.Details);
|
||||
recorder.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)] // a Hyper-V VM outside Autopilot: only the binding's grant would admit it
|
||||
public async Task Chain_BothPermissionsMissing_ReportsInChainOrder_CloudPcOnlyWhenSelfReported(bool selfReportedCloudPc)
|
||||
{
|
||||
var recorder = new Mock<IGraphAccessHealthRecorder>();
|
||||
var (validator, binding) = BuildChain(
|
||||
new TenantConfiguration { ValidateCloudPcDevice = true, ValidateIntuneDeviceBinding = true }, recorder.Object,
|
||||
CloudPcAnswering(CloudPcPermissionMissing).Object);
|
||||
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.PermissionMissing, ErrorMessage = "grant" });
|
||||
AddOnValidation[] expected = selfReportedCloudPc
|
||||
? [AddOnValidation.CloudPc, AddOnValidation.IntuneDeviceBinding]
|
||||
: [AddOnValidation.IntuneDeviceBinding];
|
||||
|
||||
var result = await Validate(validator, selfReportedCloudPc);
|
||||
|
||||
// The binding's missing grant keeps the device waiting rather than rejected (D-282).
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, result.StatusCode);
|
||||
recorder.Verify(r => r.RecordAddOnPermissionMissingAsync(CertTenant,
|
||||
It.Is<IReadOnlyList<AddOnValidation>>(l => l.SequenceEqual(expected))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chain_ToggleOff_NoAdmittingLookup_OnlyABackgroundObservation()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Graph and Entra token-endpoint response parsers as they read with Newtonsoft before D-420, copied
|
||||
/// verbatim as the reference for <see cref="UpstreamJsonDifferentialTests"/>. Temporary: removed together
|
||||
/// with that test once the System.Text.Json parsers have run cleanly in production (backlog).
|
||||
/// </summary>
|
||||
internal static class LegacyUpstreamParsers
|
||||
{
|
||||
public static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
|
||||
{
|
||||
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
var accessToken = tokenJson?["access_token"]?.ToString();
|
||||
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, (long?)tokenJson?["expires_in"]);
|
||||
}
|
||||
|
||||
public static EntraTokenRejection ParseEntraRejection(int statusCode, string? body)
|
||||
{
|
||||
JObject? json = null;
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try { json = JsonConvert.DeserializeObject<JToken>(body) as JObject; }
|
||||
catch (JsonException) { json = null; }
|
||||
}
|
||||
var description = (json?["error_description"] as JValue)?.Value as string;
|
||||
|
||||
string? code = null;
|
||||
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
|
||||
if (first != null)
|
||||
{
|
||||
code = "AADSTS" + first.Value<long>();
|
||||
}
|
||||
else
|
||||
{
|
||||
var start = description?.IndexOf("AADSTS", StringComparison.Ordinal) ?? -1;
|
||||
if (start >= 0)
|
||||
{
|
||||
var digits = new string(description!.Skip(start + "AADSTS".Length).TakeWhile(char.IsDigit).ToArray());
|
||||
code = digits.Length == 0 ? null : "AADSTS" + digits;
|
||||
}
|
||||
}
|
||||
|
||||
string? shortened = null;
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
var line = description.Split('\n')[0].Trim();
|
||||
shortened = line.Length <= 300 ? line : line.Substring(0, 300);
|
||||
}
|
||||
return new EntraTokenRejection(statusCode, code, shortened);
|
||||
}
|
||||
|
||||
public static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
|
||||
{
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
if (data?["value"] is not JArray devices || devices.Count == 0)
|
||||
return null;
|
||||
|
||||
var exactDevice = devices.FirstOrDefault(d => string.Equals(
|
||||
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
|
||||
if (exactDevice == null)
|
||||
return null;
|
||||
|
||||
return new AutopilotRegistration
|
||||
{
|
||||
DeviceId = RegistrationText(exactDevice["id"]),
|
||||
GroupTag = RegistrationText(exactDevice["groupTag"]),
|
||||
PurchaseOrderId = RegistrationText(exactDevice["purchaseOrderIdentifier"]),
|
||||
DisplayName = RegistrationText(exactDevice["displayName"]),
|
||||
UserAssigned = RegistrationText(exactDevice["userPrincipalName"]) != null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? RegistrationText(JToken? token)
|
||||
{
|
||||
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return null;
|
||||
return value!.Length > AutopilotDeviceValidator.MaxRegistrationFieldLength
|
||||
? value.Substring(0, AutopilotDeviceValidator.MaxRegistrationFieldLength)
|
||||
: value;
|
||||
}
|
||||
|
||||
public static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
|
||||
{
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
if (data?["value"] is JArray identities)
|
||||
{
|
||||
foreach (var identity in identities)
|
||||
{
|
||||
var type = identity?["importedDeviceIdentityType"]?.ToString();
|
||||
var value = identity?["importedDeviceIdentifier"]?.ToString();
|
||||
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
|
||||
&& value != null
|
||||
&& string.Equals(CorporateIdentifierValidator.NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
|
||||
{
|
||||
return (true, string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (false, data?["@odata.nextLink"]?.ToString() ?? string.Empty);
|
||||
}
|
||||
|
||||
public static DeviceAssociationResult ParseAssociation(string responseBody, string normalizedSerial)
|
||||
{
|
||||
var notFound = new DeviceAssociationResult
|
||||
{
|
||||
IsValid = false,
|
||||
SerialNumber = normalizedSerial,
|
||||
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
|
||||
};
|
||||
|
||||
JObject? data;
|
||||
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
|
||||
catch (JsonException) { return notFound; }
|
||||
|
||||
var devices = data?["value"] as JArray;
|
||||
if (devices == null || devices.Count == 0)
|
||||
return notFound;
|
||||
|
||||
var match = devices.FirstOrDefault(d => string.Equals(
|
||||
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
|
||||
if (match == null)
|
||||
return notFound;
|
||||
|
||||
return new DeviceAssociationResult
|
||||
{
|
||||
IsValid = true,
|
||||
SerialNumber = normalizedSerial,
|
||||
AssociationState = match["associationState"]?.ToString(),
|
||||
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
|
||||
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
|
||||
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
|
||||
PreAssociationDateTime = ParseAssociationDate(match["preassociationDateTime"]),
|
||||
AssociationDateTime = ParseAssociationDate(match["associationDateTime"]),
|
||||
ManagedDeviceId = match["managedDeviceId"]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime? ParseAssociationDate(JToken? token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null) return null;
|
||||
var s = token.ToString();
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
|
||||
return dt.Year <= 1 ? null : dt;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static CloudPcValidationResult ParseCloudPc(string responseBody, string normalizedId)
|
||||
{
|
||||
var notFound = new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
|
||||
};
|
||||
|
||||
JObject? data;
|
||||
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
|
||||
catch (JsonException) { return notFound; }
|
||||
|
||||
var cloudPcs = data?["value"] as JArray;
|
||||
if (cloudPcs == null || cloudPcs.Count == 0)
|
||||
return notFound;
|
||||
|
||||
var match = cloudPcs.FirstOrDefault(c => string.Equals(
|
||||
c?["managedDeviceId"]?.ToString()?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase));
|
||||
if (match == null)
|
||||
return notFound;
|
||||
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
CloudPcId = match["id"]?.ToString(),
|
||||
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
|
||||
ServicePlanName = match["servicePlanName"]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> LeavingManagementStates = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"retirePending", "retireIssued", "wipePending", "wipeIssued", "deletePending",
|
||||
};
|
||||
|
||||
public static IntuneDeviceBindingResult ParseManagedDevice(string responseBody, string normalizedId)
|
||||
{
|
||||
var notFound = new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = IntuneDeviceBindingOutcome.NotFound,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
|
||||
};
|
||||
|
||||
JObject? device;
|
||||
try { device = JsonConvert.DeserializeObject<JObject>(responseBody); }
|
||||
catch (JsonException) { return notFound; }
|
||||
|
||||
var returnedId = device?["id"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrEmpty(returnedId) || !string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
|
||||
return notFound;
|
||||
|
||||
var managementState = device?["managementState"]?.ToString();
|
||||
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
|
||||
|
||||
return new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
|
||||
IntuneDeviceId = normalizedId,
|
||||
DeviceName = device?["deviceName"]?.ToString(),
|
||||
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
|
||||
ManagementState = managementState,
|
||||
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
|
||||
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
|
||||
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
|
||||
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
|
||||
? device["azureADRegistered"]!.Value<bool>()
|
||||
: null,
|
||||
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
|
||||
ErrorMessage = leaving
|
||||
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseEnrolledDateTime(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
if (!DateTimeOffset.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
return null;
|
||||
return parsed.Year <= 1 ? null : parsed;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
public static (List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
|
||||
{
|
||||
var json = JObject.Parse(body);
|
||||
var values = json["value"] as JArray ?? new JArray();
|
||||
var scripts = new List<ScriptDisplayNameResolver.ScriptListItem>();
|
||||
foreach (var item in values.OfType<JObject>())
|
||||
{
|
||||
var id = item["id"]?.ToString();
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
scripts.Add(new ScriptDisplayNameResolver.ScriptListItem(id!, item["displayName"]?.ToString(), withFileName ? item["fileName"]?.ToString() : null));
|
||||
}
|
||||
return (scripts, json["@odata.nextLink"]?.ToString());
|
||||
}
|
||||
|
||||
public static string? ReadScriptDisplayName(string body) => JObject.Parse(body)["displayName"]?.ToString();
|
||||
}
|
||||
@@ -45,6 +45,24 @@ public class LiveStreamPrincipalTests
|
||||
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.NegotiatedUserIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiate_under_view_as_binds_the_sign_in_tenant_not_the_viewed_one()
|
||||
{
|
||||
// Removing a Global Admin cuts the connections bound to their sign-in tenant; a view-as tab must be among them.
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var req = BuildRequest(new RequestContext
|
||||
{
|
||||
TenantId = OtherTenantId,
|
||||
UserPrincipalName = OperatorUpn,
|
||||
ViewAs = new ViewAsContext(TenantId, AutopilotMonitor.Shared.Constants.TenantRoles.Admin),
|
||||
});
|
||||
|
||||
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, OperatorUpn) }, signalR.NegotiatedUserIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiate_without_a_principal_key_binds_no_user_id()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Maintenance;
|
||||
using Xunit;
|
||||
@@ -7,6 +8,18 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class MaintenanceStepTimerTests
|
||||
{
|
||||
private const long Mb = 1024 * 1024;
|
||||
|
||||
private static MaintenanceStepTimer TimerOver(params (long PeakMb, long AllocatedMb)[] readings)
|
||||
{
|
||||
var queue = new Queue<(long PeakMb, long AllocatedMb)>(readings);
|
||||
return new MaintenanceStepTimer(() =>
|
||||
{
|
||||
var (peakMb, allocatedMb) = queue.Dequeue();
|
||||
return new MaintenanceMemoryReading(peakMb * Mb, allocatedMb * Mb, 1740 * Mb, 2088 * Mb);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Records_every_step_in_order_and_returns_the_body_result()
|
||||
{
|
||||
@@ -30,4 +43,49 @@ public class MaintenanceStepTimerTests
|
||||
|
||||
Assert.True(timer.Steps.ContainsKey("boom"));
|
||||
}
|
||||
|
||||
// The memory columns have to name the step that set the peak: each step records the process
|
||||
// peak after it ran and only the allocation of its own span.
|
||||
[Fact]
|
||||
public async Task Each_step_reports_the_process_peak_after_it_and_its_own_allocation()
|
||||
{
|
||||
var timer = TimerOver((400, 100), (400, 150), (400, 150), (1900, 1450), (1900, 1450));
|
||||
|
||||
await timer.RunAsync("light", () => Task.CompletedTask);
|
||||
await timer.RunAsync<int>("heavy", () => Task.FromResult(7));
|
||||
|
||||
var report = timer.MemoryReport();
|
||||
Assert.Equal(400, report.StepsPeakMb["light"]);
|
||||
Assert.Equal(50, report.StepsAllocMb["light"]);
|
||||
Assert.Equal(1900, report.StepsPeakMb["heavy"]);
|
||||
Assert.Equal(1300, report.StepsAllocMb["heavy"]);
|
||||
Assert.Equal(1740, report.GcHeapLimitMb);
|
||||
Assert.Equal(2088, report.GcHighLoadMb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_step_still_reports_its_memory()
|
||||
{
|
||||
var timer = TimerOver((300, 10), (800, 60), (800, 60));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => timer.RunAsync("boom", () => throw new InvalidOperationException("x")));
|
||||
|
||||
var report = timer.MemoryReport();
|
||||
Assert.Equal(800, report.StepsPeakMb["boom"]);
|
||||
Assert.Equal(50, report.StepsAllocMb["boom"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_real_reading_sees_the_process_peak_and_the_gc_limits()
|
||||
{
|
||||
GC.Collect();
|
||||
|
||||
var reading = MaintenanceMemoryReading.Capture();
|
||||
|
||||
Assert.True(reading.PeakWorkingSetBytes > 0);
|
||||
Assert.True(reading.AllocatedBytes > 0);
|
||||
Assert.True(reading.GcHeapLimitBytes > 0);
|
||||
Assert.True(reading.GcHighLoadBytes > 0);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -96,7 +96,7 @@ public class McpOrganizationUsageAggregationTests
|
||||
Row(Member, "20260903", 7), // read window past today (range only)
|
||||
};
|
||||
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
var quota = McpQuotaNodes.BuildOrganizationQuota(
|
||||
rows, new McpQuotaService.TenantPlanLimits("pro", 1500, 15000), "20260902", "20260901");
|
||||
|
||||
Assert.Equal("pro", quota.TenantPlan);
|
||||
@@ -114,7 +114,7 @@ public class McpOrganizationUsageAggregationTests
|
||||
public void OrganizationQuota_PurchasedSlots_CarryTheBreakdown()
|
||||
{
|
||||
// 3 000 + 3 × 900 = 5 700 — the limits arrive grown; the slot fields let the page show the sum.
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
var quota = McpQuotaNodes.BuildOrganizationQuota(
|
||||
new[] { Row(Member, "20260902", 3) },
|
||||
new McpQuotaService.TenantPlanLimits("pro", 5700, 114000, PurchasedDelegatedSlots: 3, SlotTenantDailyLimit: 900, SlotTenantMonthlyLimit: 18000),
|
||||
"20260902", "20260901");
|
||||
@@ -145,7 +145,7 @@ public class McpOrganizationUsageAggregationTests
|
||||
[Fact]
|
||||
public void OrganizationQuota_LiftedWindows_StillReportTheCounters()
|
||||
{
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
var quota = McpQuotaNodes.BuildOrganizationQuota(
|
||||
new[] { Row(Member, "20260902", 3) }, new McpQuotaService.TenantPlanLimits("pro", 0, 0), "20260902", "20260901");
|
||||
|
||||
Assert.Equal(0, quota.DailyLimit);
|
||||
|
||||
@@ -96,6 +96,47 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Error);
|
||||
}
|
||||
|
||||
// ── ExceededAlertKey — one McpQuotaExceeded per account or organization, window and period ──
|
||||
|
||||
private const string OidA = "00000000-0000-0000-0000-00000000000a";
|
||||
private const string OidB = "00000000-0000-0000-0000-00000000000b";
|
||||
|
||||
private static McpQuotaDecision UserDailyBlock(DateTime now)
|
||||
=> McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
|
||||
dailyUsed: 50, monthlyUsed: 60, tenantDailyUsed: 60, tenantMonthlyUsed: 60, now);
|
||||
|
||||
[Fact]
|
||||
public void AlertKey_AccountWindow_IsPerAccountAndDay()
|
||||
{
|
||||
var key = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now));
|
||||
|
||||
Assert.Equal($"user:daily:{OidA}:20260903", key); // the period is the reset date
|
||||
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, UserDailyBlock(Now)));
|
||||
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now.AddDays(1))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertKey_OrganizationWindow_IsSharedByAllMembers()
|
||||
{
|
||||
// The organization's window is one budget: the refusal of any member raises one event for it.
|
||||
var tenantBlock = Blocked("community");
|
||||
|
||||
var a = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home.ToUpperInvariant(), tenantBlock);
|
||||
var b = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, tenantBlock);
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal($"tenant:daily:{Home}:20260903", a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlertKey_MonthlyWindow_IsItsOwnKey()
|
||||
{
|
||||
var monthly = McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
|
||||
dailyUsed: 10, monthlyUsed: 250, tenantDailyUsed: 10, tenantMonthlyUsed: 10, Now);
|
||||
|
||||
Assert.Equal($"user:monthly:{OidA}:20261001", McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, monthly));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserLevelBlock_ReportsTheUserWindow()
|
||||
{
|
||||
|
||||
@@ -130,7 +130,8 @@ public class McpQuotaServiceTests
|
||||
Func<string?, TenantEdition>? editionResolver = null,
|
||||
Func<string?, string?>? tenantPlanOverrideResolver = null,
|
||||
Func<string?, int>? purchasedSlotsResolver = null,
|
||||
Func<string?, EditionResolution>? resolutionResolver = null)
|
||||
Func<string?, EditionResolution>? resolutionResolver = null,
|
||||
TestTimeProvider? time = null)
|
||||
{
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny<string>()))
|
||||
@@ -162,9 +163,8 @@ public class McpQuotaServiceTests
|
||||
mcpUserService,
|
||||
adminConfigService,
|
||||
entitlements,
|
||||
cache,
|
||||
NullLogger<McpQuotaService>.Instance,
|
||||
new TestTimeProvider(Now));
|
||||
time ?? new TestTimeProvider(Now));
|
||||
}
|
||||
|
||||
private static Mock<IUserUsageRepository> UsageRepo(params (string Date, long Count)[] rows)
|
||||
@@ -197,7 +197,7 @@ public class McpQuotaServiceTests
|
||||
// 100 requests today (catalog Community fallback: 100/day) → blocked.
|
||||
var svc = Build(UsageRepo(("20260707", 100)));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("community", d.Plan);
|
||||
@@ -210,7 +210,7 @@ public class McpQuotaServiceTests
|
||||
{
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("pro", d.Plan);
|
||||
@@ -224,7 +224,7 @@ public class McpQuotaServiceTests
|
||||
// 2990 across the month + 20 today = 3010 ≥ 3000 monthly Community limit.
|
||||
var svc = Build(UsageRepo(("20260701", 1500), ("20260703", 1490), ("20260707", 20)));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
@@ -238,7 +238,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"community","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 5)), planDefinitionsJson: json);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal(5, d.DailyLimit);
|
||||
@@ -251,7 +251,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
@@ -266,7 +266,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power", bound: false);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("community", d.Plan);
|
||||
@@ -279,7 +279,7 @@ public class McpQuotaServiceTests
|
||||
// Override names a plan that exists nowhere → Community fallback limits.
|
||||
var svc = Build(UsageRepo(("20260707", 100)), mcpUserPlanOverride: "no-such-plan");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("no-such-plan", d.Plan);
|
||||
@@ -300,7 +300,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 1200)), planDefinitionsJson: MspPlanJson,
|
||||
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("msp", d.Plan);
|
||||
@@ -318,7 +318,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 2000)), planDefinitionsJson: json,
|
||||
edition: TenantEdition.Pro, mcpUserPlanOverride: "power", tenantPlanOverrideResolver: _ => "msp");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
@@ -335,7 +335,7 @@ public class McpQuotaServiceTests
|
||||
// falls back fail-closed (user: Community catalog; tenant: the EDITION's catalog windows).
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("msp", d.Plan);
|
||||
@@ -351,7 +351,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: MspPlanJson,
|
||||
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp", purchasedSlotsResolver: _ => 2);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal("msp", d.Plan);
|
||||
Assert.Equal(2100, d.DailyLimit); // 1500 + 2 × 300
|
||||
@@ -367,8 +367,8 @@ public class McpQuotaServiceTests
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(first.Allowed);
|
||||
Assert.True(second.Allowed);
|
||||
@@ -383,69 +383,144 @@ public class McpQuotaServiceTests
|
||||
var repo = UsageRepo(("20260707", 1));
|
||||
var svc = Build(repo);
|
||||
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
// ── Soft-boundary contract (Codex finding 2026-07-07) ─────────────────────────
|
||||
// The quota is DELIBERATELY soft: the per-user decision is cached for 60s, so an allowed
|
||||
// decision keeps admitting requests inside that window even after the fire-and-forget
|
||||
// increments push the stored counters past the limit. Overshoot is bounded (~TTL × request
|
||||
// rate per instance) — the same posture as the sliding-window rate limiter. These tests PIN
|
||||
// that contract; if strict limit+1 blocking is ever wanted, the decision cache must be
|
||||
// reworked and these tests consciously rewritten.
|
||||
// ── Boundary: exact per instance, soft across instances ──────────────────────
|
||||
// The counters are read from storage at most every 60 s, and every request the instance admits is
|
||||
// counted on them at once — so one instance blocks exactly at the limit, also when a tool call fans out
|
||||
// into parallel requests. What stays soft is the part of OTHER instances: they learn of these requests
|
||||
// only with their own next read.
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SoftBoundary_CachedAllowedDecision_KeepsAdmitting_WithinTtl()
|
||||
public async Task Check_SequentialRequests_BlockExactlyAtTheLimit_WithoutRereading()
|
||||
{
|
||||
// First read: one request below the daily limit → allowed, decision cached.
|
||||
var repo = UsageRepo(("20260707", 99));
|
||||
// Community catalog: 100/day. 98 served before → the 99th and 100th pass, the 101st is refused.
|
||||
var repo = UsageRepo(("20260707", 98));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
Assert.True(first.Allowed);
|
||||
var ninetyNinth = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var hundredth = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var hundredFirst = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
// The async increments land: stored counters are now OVER the limit — but the cached
|
||||
// allowed decision keeps admitting within the TTL, without re-reading storage.
|
||||
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<UserUsageRecord>
|
||||
{
|
||||
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
|
||||
});
|
||||
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(second.Allowed, "soft boundary: cached allowed decision persists within the TTL");
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
Assert.True(ninetyNinth.Allowed);
|
||||
Assert.Equal(98, ninetyNinth.DailyUsed); // the decision reports the count BEFORE this request
|
||||
Assert.True(hundredth.Allowed);
|
||||
Assert.False(hundredFirst.Allowed);
|
||||
Assert.Equal("daily", hundredFirst.Scope);
|
||||
Assert.Equal(100, hundredFirst.DailyUsed);
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SoftBoundary_AfterCacheEviction_OverLimitCounters_Block()
|
||||
public async Task ReadState_NeverCounts_OnlyAdmitDoes()
|
||||
{
|
||||
// Same scenario as above, but once the cached decision is gone (TTL expiry — simulated
|
||||
// via eviction), the next check reads the over-limit counters and blocks.
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
// The usage page shows the quota; viewing it is not an MCP request. Three reads leave the count at 99,
|
||||
// so the next real request still passes and only the one after it is refused.
|
||||
var svc = Build(UsageRepo(("20260707", 99)));
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var view = await svc.ReadStateAsync(Oid, Upn, TenantId);
|
||||
Assert.True(view.Allowed);
|
||||
Assert.Equal(99, view.DailyUsed);
|
||||
}
|
||||
|
||||
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.Equal(100, (await svc.ReadStateAsync(Oid, Upn, TenantId)).DailyUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_ParallelBurst_AdmitsExactlyUpToTheLimit()
|
||||
{
|
||||
// A tool call's parallel requests must not all pass on the same reading: 95 used, 20 at once → 5 pass.
|
||||
var repo = UsageRepo(("20260707", 95));
|
||||
var svc = Build(repo);
|
||||
|
||||
var decisions = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => svc.AdmitAsync(Oid, Upn, TenantId))));
|
||||
|
||||
Assert.Equal(5, decisions.Count(d => d.Allowed));
|
||||
// Single-flight: concurrent first checks share one counter, so storage was read once.
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TenantWindow_CountsEveryMemberOnThisInstance()
|
||||
{
|
||||
// Community organization window 300/day, 298 used by the organization. Member A and member B each get one
|
||||
// request through; the next one of A is refused at the organization level although A's own window is wide.
|
||||
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 298L) });
|
||||
repo.Setup(r => r.GetUsageByUserAsync(OtherOid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = OtherOid, Date = "20260707", RequestCount = 1 } });
|
||||
var svc = Build(repo);
|
||||
|
||||
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId)).Allowed);
|
||||
var third = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(third.Allowed);
|
||||
Assert.Equal("tenant", third.Level);
|
||||
Assert.Equal(300, third.TenantDailyUsed);
|
||||
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SecondInstance_LearnsOfTheFirstOnlyWithItsOwnRead()
|
||||
{
|
||||
// The part that stays soft, pinned: two instances share storage, not memory. Storage still says 99 (the
|
||||
// increments of instance A are on their way), so instance B admits one more on its own fresh reading.
|
||||
var repo = UsageRepo(("20260707", 99));
|
||||
var svc = Build(repo, cache: cache);
|
||||
var instanceA = Build(repo);
|
||||
var instanceB = Build(repo);
|
||||
|
||||
Assert.True((await svc.CheckAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.False((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.False((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_CounterExpiry_RereadsStorage()
|
||||
{
|
||||
// After 60 s the counter is read again, so increments of other instances count from then on.
|
||||
var time = new TestTimeProvider(Now);
|
||||
var repo = UsageRepo(("20260707", 10));
|
||||
var svc = Build(repo, time: time);
|
||||
|
||||
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<UserUsageRecord>
|
||||
{
|
||||
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
|
||||
});
|
||||
cache.Remove(McpQuotaService.UserCacheKey(Oid)); // stands in for the 60s TTL elapsing
|
||||
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = Oid, Date = "20260707", RequestCount = 150 } });
|
||||
time.SetUtcNow(Now.AddSeconds(61));
|
||||
|
||||
var decision = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var decision = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(decision.Allowed);
|
||||
Assert.Equal("daily", decision.Scope);
|
||||
Assert.Equal(150, decision.DailyUsed);
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_Midnight_StartsAFreshCounter_InsideTheSixtySeconds()
|
||||
{
|
||||
// A counter read at 23:59:50 never answers a check at 00:00:05: the day is part of the key.
|
||||
var lateEvening = new DateTime(2026, 7, 7, 23, 59, 50, DateTimeKind.Utc);
|
||||
var time = new TestTimeProvider(lateEvening);
|
||||
var repo = UsageRepo(("20260707", 100));
|
||||
var svc = Build(repo, time: time);
|
||||
|
||||
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
|
||||
time.SetUtcNow(lateEvening.AddSeconds(15));
|
||||
var afterMidnight = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(afterMidnight.Allowed);
|
||||
Assert.Equal(0, afterMidnight.DailyUsed);
|
||||
Assert.Equal(new DateTime(2026, 7, 9, 0, 0, 0, DateTimeKind.Utc), afterMidnight.ResetUtc);
|
||||
}
|
||||
|
||||
// ── Tenant (organization-wide) quota ─────────────────────────────────────────
|
||||
@@ -456,7 +531,7 @@ public class McpQuotaServiceTests
|
||||
// Community catalog tenant window = 300/day; other members burned it, this caller made 5 requests.
|
||||
var svc = Build(UsageRepo(new[] { ("20260707", 5L) }, tenantRows: new[] { ("20260707", 300L) }));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
@@ -474,7 +549,7 @@ public class McpQuotaServiceTests
|
||||
new[] { ("20260707", 1L) },
|
||||
tenantRows: new[] { ("20260701", 4000L), ("20260703", 4990L), ("20260707", 10L) }));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
@@ -492,7 +567,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 10L) }),
|
||||
planDefinitionsJson: json, mcpUserPlanOverride: "power");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
@@ -509,7 +584,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(5, d.DailyLimit);
|
||||
@@ -524,7 +599,7 @@ public class McpQuotaServiceTests
|
||||
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 999999L) });
|
||||
var svc = Build(repo, planDefinitionsJson: json);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(0, d.TenantDailyLimit);
|
||||
@@ -537,7 +612,7 @@ public class McpQuotaServiceTests
|
||||
var repo = UsageRepo(("20260707", 1));
|
||||
var svc = Build(repo);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, tenantId: null);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, tenantId: null);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
repo.Verify(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
@@ -551,8 +626,8 @@ public class McpQuotaServiceTests
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(first.Allowed);
|
||||
Assert.True(second.Allowed);
|
||||
@@ -573,7 +648,7 @@ public class McpQuotaServiceTests
|
||||
// Pro home with 3 purchased slots: 1000 + 3 × 300 per account, 3000 + 3 × 900 for the organization.
|
||||
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 3);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1900, d.DailyLimit);
|
||||
Assert.Equal(38000, d.MonthlyLimit);
|
||||
@@ -592,7 +667,7 @@ public class McpQuotaServiceTests
|
||||
// A Pro tenant on its two included slots (purchased = 0) keeps the plain Pro windows.
|
||||
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 0);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(3000, d.TenantDailyLimit);
|
||||
@@ -607,7 +682,7 @@ public class McpQuotaServiceTests
|
||||
var conferred = new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false);
|
||||
var svc = Build(UsageRepo(("20260707", 1)), resolutionResolver: _ => conferred, purchasedSlotsResolver: _ => 3);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal("pro", d.Plan);
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
@@ -625,7 +700,7 @@ public class McpQuotaServiceTests
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro,
|
||||
mcpUserPlanOverride: "power", purchasedSlotsResolver: _ => 2);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal("power", d.Plan);
|
||||
Assert.Equal(10600, d.DailyLimit);
|
||||
@@ -639,7 +714,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":0,"slotMonthlyRequestLimit":0,"slotTenantDailyRequestLimit":0,"slotTenantMonthlyRequestLimit":0,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 5);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(3000, d.TenantDailyLimit);
|
||||
@@ -653,7 +728,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":50,"slotMonthlyRequestLimit":500,"slotTenantDailyRequestLimit":100,"slotTenantMonthlyRequestLimit":1000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 2);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1100, d.DailyLimit);
|
||||
Assert.Equal(21000, d.MonthlyLimit);
|
||||
@@ -668,7 +743,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.Equal(1300, d.DailyLimit);
|
||||
Assert.Equal(3900, d.TenantDailyLimit);
|
||||
@@ -682,7 +757,7 @@ public class McpQuotaServiceTests
|
||||
var json = """[{"name":"pro","dailyRequestLimit":0,"monthlyRequestLimit":20000,"tenantDailyRequestLimit":0,"tenantMonthlyRequestLimit":60000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 30000)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 4);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(0, d.DailyLimit);
|
||||
@@ -705,10 +780,10 @@ public class McpQuotaServiceTests
|
||||
{
|
||||
// Pro 1000 + 1 × 300 = 1300: the 1300th request of the day is the first blocked one.
|
||||
var below = Build(UsageRepo(("20260707", 1299)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
|
||||
Assert.True((await below.CheckAsync(Oid, Upn, TenantId)).Allowed);
|
||||
Assert.True((await below.AdmitAsync(Oid, Upn, TenantId)).Allowed);
|
||||
|
||||
var at = Build(UsageRepo(("20260707", 1300)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
|
||||
var d = await at.CheckAsync(Oid, Upn, TenantId);
|
||||
var d = await at.AdmitAsync(Oid, Upn, TenantId);
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("daily", d.Scope);
|
||||
Assert.Equal("user", d.Level);
|
||||
@@ -726,8 +801,8 @@ public class McpQuotaServiceTests
|
||||
.ReturnsAsync(new List<UserUsageRecord>());
|
||||
var svc = Build(repo);
|
||||
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.CheckAsync(OtherOid, "bob@contoso.com", TenantId);
|
||||
await svc.AdmitAsync(Oid, Upn, TenantId);
|
||||
await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId);
|
||||
|
||||
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageOverviewService;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The pure fold behind GET global/metrics/mcp-usage/overview: which numbers come from the quota counters and
|
||||
/// which from the telemetry, how platform admins drop out (but never out of a tenant's real organization
|
||||
/// windows), what a call costs, MCP-only tools, the daily trend and the per-minute ranking.
|
||||
/// </summary>
|
||||
public class McpUsageOverviewAssemblyTests
|
||||
{
|
||||
private const string TenantA = "aaaaaaaa-0000-0000-0000-00000000000a";
|
||||
private const string TenantB = "bbbbbbbb-0000-0000-0000-00000000000b";
|
||||
private const string Alice = "alice@contoso.com";
|
||||
private const string App = "app:11111111-2222-3333-4444-555555555555";
|
||||
private const string Operator = "ops@fabrikam.com";
|
||||
private const string Bob = "bob@fabrikam.com";
|
||||
private const string Carol = "carol@contoso.com";
|
||||
|
||||
// Window 3 days (Nov 3–5); the month started Nov 1, so Nov 1 rows count for the quota, not for the window.
|
||||
private static readonly Dates ThreeDays = new("20261103", "20261105", "20261101");
|
||||
private static readonly DateTime At = new(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TenantUsageRecord Counter(string tenant, string oid, string upn, string day, long requests)
|
||||
=> new() { TenantId = tenant, UserId = oid, UserPrincipalName = upn, Date = day, RequestCount = requests, LastRequestAt = At };
|
||||
|
||||
private static readonly TenantUsageRecord[] Counters =
|
||||
{
|
||||
Counter(TenantA, "oid-alice", Alice, "20261101", 10),
|
||||
Counter(TenantA, "oid-alice", Alice, "20261104", 20),
|
||||
Counter(TenantA, "oid-alice", Alice, "20261105", 30),
|
||||
Counter(TenantA, "oid-app", App, "20261105", 5),
|
||||
Counter(TenantA, "oid-ops", Operator, "20261105", 100),
|
||||
Counter(TenantB, "oid-bob", Bob, "20261103", 7),
|
||||
};
|
||||
|
||||
private static CallRow Call(string day, string tool, string tenant, string principal, long calls, long served, long requests, string role)
|
||||
=> new(day, tool, tenant, principal, calls, served, requests, At, role);
|
||||
|
||||
private static readonly CallRow[] Calls =
|
||||
{
|
||||
Call("20261104", "get_session_summary", TenantA, Alice, 2, 2, 10, "Admin"),
|
||||
Call("20261105", "search_events", TenantA, Alice, 1, 1, 14, "Admin"),
|
||||
Call("20261105", "get_session_events", TenantA, Alice, 3, 3, 3, "Admin"),
|
||||
Call("20261105", "list_tenants", TenantA, Alice, 1, 1, 1, "Admin"),
|
||||
Call("20261105", "get_session_summary", TenantA, App, 1, 1, 5, "Viewer"),
|
||||
Call("20261105", "query_table", TenantA, Operator, 50, 50, 50, "GlobalAdmin"),
|
||||
Call("20261103", "search_sessions", TenantB, Bob, 2, 2, 7, "Admin"),
|
||||
Call("20261105", "get_time_attribution", TenantA, Carol, 4, 0, 0, "Admin"),
|
||||
};
|
||||
|
||||
private static McpQuotaDecision State(string plan, int daily, int monthly, long dailyUsed, long monthlyUsed)
|
||||
=> new() { Allowed = true, Plan = plan, DailyLimit = daily, MonthlyLimit = monthly, DailyUsed = dailyUsed, MonthlyUsed = monthlyUsed };
|
||||
|
||||
private static TelemetryResult Telemetry(bool available = true) => !available
|
||||
? new TelemetryResult(false, false, false, new(), new(), new(), new(), new(), new(), new ServerFacts(null, null))
|
||||
: new TelemetryResult(
|
||||
RequestsAvailable: true, McpLogAvailable: true, Partial: false,
|
||||
Calls: Calls.ToList(),
|
||||
Routes: new()
|
||||
{
|
||||
new("get_session_summary", "GET sessions/{id}/events", 3),
|
||||
new("get_session_summary", "GET sessions/{id}", 3),
|
||||
},
|
||||
Refusals: new()
|
||||
{
|
||||
new(RefusalQuota, "20261105", TenantA, Alice, "user", "daily", "community", 3, At, At.AddSeconds(9), 50, 50),
|
||||
new(RefusalMinute, "20261103", TenantB, Bob, "", "", "", 2, At, At, 120, 120),
|
||||
},
|
||||
Peaks: new()
|
||||
{
|
||||
new(PeakCalls, TenantA, Alice, 12, At),
|
||||
new(PeakRequests, TenantA, Alice, 40, At),
|
||||
new(PeakRequests, TenantB, Bob, 120, At),
|
||||
new(PeakCalls, TenantB, Bob, 5, At),
|
||||
},
|
||||
ToolQuality: new()
|
||||
{
|
||||
new("get_session_summary", 3, 0, 0, 0, 600, 19000.4),
|
||||
new("search_docs", 4, 1, 0, 0, 80, 5000),
|
||||
},
|
||||
McpRefusals: new() { new(Alice, 7, At) },
|
||||
ServerFacts: new ServerFacts(new DateTime(2026, 11, 4, 1, 0, 0, DateTimeKind.Utc), 60));
|
||||
|
||||
private static GetGlobalMcpUsageOverviewResponse Overview(bool includePlatformAdmins = false, bool telemetry = true)
|
||||
{
|
||||
var result = Telemetry(telemetry);
|
||||
var facts = new Dictionary<(string Tenant, string Principal), AccountFacts>
|
||||
{
|
||||
[(TenantA, Alice)] = new("oid-alice", null, State("community", 50, 250, 30, 60)),
|
||||
[(TenantA, App)] = new("oid-app", null, State("community", 50, 250, 5, 5)),
|
||||
[(TenantA, Operator)] = new("oid-ops", "GlobalAdmin", State("community", 50, 250, 100, 100)),
|
||||
[(TenantB, Bob)] = new("oid-bob", null, State("pro", 500, 6000, 0, 7)),
|
||||
};
|
||||
var limits = new Dictionary<string, McpQuotaService.TenantPlanLimits>
|
||||
{
|
||||
[TenantA] = new("community", 100, 250),
|
||||
[TenantB] = new("pro", 1500, 15000),
|
||||
};
|
||||
// TenantB has no readable configuration: no name.
|
||||
var names = new Dictionary<string, string> { [TenantA] = "contoso.com" };
|
||||
var minute = new Dictionary<(string Tenant, string Principal), int> { [(TenantA, Alice)] = 120, [(TenantB, Bob)] = 150 };
|
||||
|
||||
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, names, LatestRoles(result.Calls), minute, result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformAdmins_DropOutOfEveryNumber_ButNotOutOfTheOrganizationWindows()
|
||||
{
|
||||
var overview = Overview();
|
||||
var tenantA = overview.Tenants.Single(t => t.TenantId == TenantA);
|
||||
|
||||
Assert.DoesNotContain(tenantA.Accounts, a => a.Principal == Operator);
|
||||
Assert.DoesNotContain(overview.Tools, t => t.Tool == "query_table");
|
||||
Assert.Equal(55, tenantA.Requests); // alice 20 + 30, app 5 — the operator's 100 left out
|
||||
// The organization windows stay the tenant's real counters: today 30 + 5 + 100, the month 10 + 20 + 30 + 5 + 100.
|
||||
Assert.Equal(135, tenantA.Quota.DailyUsed);
|
||||
Assert.Equal(165, tenantA.Quota.MonthlyUsed);
|
||||
Assert.Equal("community", tenantA.Quota.TenantPlan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformAdmins_WhenIncluded_AreMarked()
|
||||
{
|
||||
var tenantA = Overview(includePlatformAdmins: true).Tenants.Single(t => t.TenantId == TenantA);
|
||||
var op = tenantA.Accounts.Single(a => a.Principal == Operator);
|
||||
|
||||
Assert.True(op.IsPlatformAdmin);
|
||||
Assert.Equal("GlobalAdmin", op.Role);
|
||||
Assert.Equal(155, tenantA.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accounts_RequestsComeFromTheCounters_CallsFromTheTelemetry()
|
||||
{
|
||||
var tenants = Overview().Tenants;
|
||||
Assert.Equal(new[] { TenantA, TenantB }, tenants.Select(t => t.TenantId)); // most calls first
|
||||
var accounts = tenants[0].Accounts;
|
||||
Assert.Equal(new[] { Alice, Carol, App }, accounts.Select(a => a.Principal));
|
||||
|
||||
var alice = accounts[0];
|
||||
Assert.Equal("oid-alice", alice.UserId);
|
||||
Assert.Equal(7, alice.Calls);
|
||||
Assert.Equal(50, alice.Requests); // Nov 1 lies before the window
|
||||
Assert.Equal("20261105", alice.PeakDay);
|
||||
Assert.Equal(30, alice.PeakDayRequests);
|
||||
Assert.Equal(1, alice.QuotaHits);
|
||||
Assert.Equal("Admin", alice.Role);
|
||||
Assert.Equal(new[] { ("get_session_events", 3L), ("get_session_summary", 2L), ("list_tenants", 1L) },
|
||||
alice.TopTools.Select(t => (t.Tool, t.Calls)));
|
||||
Assert.Equal(("community", 50, 250, 30L, 60L),
|
||||
(alice.Quota!.Plan, alice.Quota.DailyLimit, alice.Quota.MonthlyLimit, alice.Quota.DailyUsed, alice.Quota.MonthlyUsed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnAccountKnownOnlyFromTheTelemetry_HasNoObjectIdAndNoQuota()
|
||||
{
|
||||
// Every request refused: no counter row, but the calls happened.
|
||||
var carol = Overview().Tenants.Single(t => t.TenantId == TenantA).Accounts.Single(a => a.Principal == Carol);
|
||||
|
||||
Assert.Null(carol.UserId);
|
||||
Assert.Null(carol.Quota);
|
||||
Assert.Equal(4, carol.Calls);
|
||||
Assert.Equal(0, carol.Requests);
|
||||
Assert.Null(carol.PeakDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tools_RankByCalls_CostPerServedCall_AndMcpOnlyToolsFromTheLog()
|
||||
{
|
||||
var tools = Overview().Tools;
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "get_time_attribution", "search_docs", "get_session_events", "get_session_summary", "search_sessions", "list_tenants", "search_events" },
|
||||
tools.Select(t => t.Tool));
|
||||
|
||||
var summary = tools.Single(t => t.Tool == "get_session_summary");
|
||||
Assert.Equal(3, summary.Calls);
|
||||
Assert.Equal(2, summary.Accounts);
|
||||
Assert.Equal(1, summary.Tenants);
|
||||
Assert.Equal(5.0, summary.RequestsPerCall); // (10 + 5) requests over 3 served calls
|
||||
Assert.Equal(0.0, summary.ErrorRate);
|
||||
Assert.Equal(600, summary.P95DurationMs);
|
||||
Assert.Equal(19000, summary.AvgResultChars);
|
||||
Assert.Equal(new[] { "GET sessions/{id}", "GET sessions/{id}/events" }, summary.Routes.Select(r => r.Route));
|
||||
|
||||
var docs = tools.Single(t => t.Tool == "search_docs");
|
||||
Assert.Equal(4, docs.Calls);
|
||||
Assert.Null(docs.Accounts);
|
||||
Assert.Null(docs.Tenants);
|
||||
Assert.Equal(0, docs.RequestsPerCall);
|
||||
Assert.Equal(0.25, docs.ErrorRate);
|
||||
|
||||
// Every call refused: no served call, no cost to report.
|
||||
Assert.Equal(0, tools.Single(t => t.Tool == "get_time_attribution").RequestsPerCall);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Daily_HasEveryDayOfTheWindow_WithCallsByPlan()
|
||||
{
|
||||
var daily = Overview().Daily;
|
||||
|
||||
Assert.Equal(new[] { "20261103", "20261104", "20261105" }, daily.Select(d => d.Date));
|
||||
var today = daily[2];
|
||||
Assert.Equal(10, today.Calls); // alice 5, app 1, carol 4 — the operator's 50 left out
|
||||
Assert.Equal(35, today.Requests); // alice 30, app 5
|
||||
Assert.Equal(3, today.Accounts);
|
||||
Assert.Equal(new[] { ("community", 10L) }, today.CallsByPlan.Select(p => (p.Plan, p.Calls)));
|
||||
Assert.Equal(new[] { ("pro", 2L) }, daily[0].CallsByPlan.Select(p => (p.Plan, p.Calls)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuotaHits_AndMinuteLimits_RankTheAccountsClosestToALimit()
|
||||
{
|
||||
var overview = Overview();
|
||||
|
||||
var hit = Assert.Single(overview.QuotaHits);
|
||||
Assert.Equal((Alice, "user", "daily", 50L, 50L, 3L), (hit.Principal, hit.Level, hit.Scope, hit.Limit, hit.Used, hit.Refused));
|
||||
|
||||
Assert.Equal(60, overview.MinuteLimits.McpServerLimit);
|
||||
var minute = overview.MinuteLimits.Accounts;
|
||||
Assert.Equal(new[] { Bob, Alice }, minute.Select(m => m.Principal)); // 120/150 before 40/120 and 12/60
|
||||
Assert.Equal((120L, 150, 2L), (minute[0].PeakRequests, minute[0].BackendLimit, minute[0].BackendRefused));
|
||||
Assert.Equal((TenantA, 12L, 7L, 0L), (minute[1].TenantId, minute[1].PeakCalls, minute[1].McpRefused, minute[1].BackendRefused));
|
||||
// The share the list ranks by, in whole percent: the higher of calls/MCP limit and requests/backend limit.
|
||||
Assert.Equal(new[] { 80, 33 }, minute.Select(m => m.SharePercent)); // 120/150; max(12/60, 40/120)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Totals_SumTheLists_OnceForEveryConsumer()
|
||||
{
|
||||
var totals = Overview().Totals;
|
||||
|
||||
Assert.Equal(18, totals.Calls); // every tool of the ranking, search_docs' 4 MCP-only calls included
|
||||
Assert.Equal(4, totals.McpOnlyCalls);
|
||||
Assert.Equal(7, totals.Tools);
|
||||
Assert.Equal(62, totals.Requests); // tenant A 55, tenant B 7 — the operator's 100 left out
|
||||
Assert.Equal(2, totals.Tenants);
|
||||
Assert.Equal(4, totals.Accounts); // alice, carol, the app, bob
|
||||
Assert.Equal(1, totals.ServicePrincipals);
|
||||
Assert.Equal(1, totals.QuotaHitAccounts); // alice's daily window
|
||||
Assert.Equal(2, totals.MinuteRefusedAccounts); // alice at the MCP server, bob at the backend
|
||||
Assert.Equal(2, totals.LimitAccounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Totals_WithoutTheirStore_AreAbsent_NotAPartialSum()
|
||||
{
|
||||
var totals = Overview(telemetry: false).Totals;
|
||||
|
||||
Assert.Null(totals.Calls);
|
||||
Assert.Null(totals.McpOnlyCalls);
|
||||
Assert.Equal(0, totals.Tools);
|
||||
Assert.Equal(62, totals.Requests); // the counters still answer
|
||||
Assert.Equal(3, totals.Accounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenants_CarryTheirDomain_WhenTheConfigurationNamesOne()
|
||||
{
|
||||
var tenants = Overview().Tenants;
|
||||
|
||||
Assert.Equal("contoso.com", tenants.Single(t => t.TenantId == TenantA).DomainName);
|
||||
Assert.Null(tenants.Single(t => t.TenantId == TenantB).DomainName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnreadableTenantConfiguration_LeavesOnlyTheNameOut()
|
||||
{
|
||||
var config = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantA)).ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantB)).ReturnsAsync(new TenantConfiguration { TenantId = TenantB, DomainName = "fabrikam.com" });
|
||||
// Only the configuration read is exercised; the other collaborators stay unused.
|
||||
var service = new McpUsageOverviewService(
|
||||
null!, null!, null!, null!, config.Object, null!, null!, null!, NullLogger<McpUsageOverviewService>.Instance, TimeProvider.System);
|
||||
|
||||
Assert.Null(await service.ReadTenantConfigurationAsync(TenantA));
|
||||
Assert.Equal("fabrikam.com", (await service.ReadTenantConfigurationAsync(TenantB))?.DomainName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, 30, false, null)]
|
||||
[InlineData("7", "include", 7, true, null)]
|
||||
[InlineData("90", "exclude", 90, false, null)]
|
||||
[InlineData("91", null, 0, false, "days must be between 1 and 90")]
|
||||
[InlineData("0", null, 0, false, "days must be between 1 and 90")]
|
||||
[InlineData("-5", null, 0, false, "days must be between 1 and 90")]
|
||||
[InlineData("7.5", null, 0, false, "days must be an integer")]
|
||||
[InlineData("30", "all", 0, false, "platformAdmins must be include or exclude")]
|
||||
public void OverviewQuery_DefaultsAndBounds(string? days, string? platformAdmins, int expectedDays, bool expectedInclude, string? expectedError)
|
||||
{
|
||||
var parsed = AutopilotMonitor.Functions.Functions.Metrics.McpUsageMetricsFunction.ParseOverviewQuery(days, platformAdmins);
|
||||
|
||||
Assert.Equal((expectedDays, expectedInclude, expectedError), parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_NamesTheMcpLogCoverage_AndATelemetryOutageEmptiesOnlyItsParts()
|
||||
{
|
||||
Assert.Equal("20261104", Overview().Telemetry.McpLogFrom);
|
||||
|
||||
var dark = Overview(telemetry: false);
|
||||
Assert.False(dark.Telemetry.RequestsAvailable);
|
||||
Assert.False(dark.Telemetry.McpLogAvailable);
|
||||
Assert.Empty(dark.Tools);
|
||||
Assert.Empty(dark.QuotaHits);
|
||||
// The counters still carry tenants, accounts, requests and the quota.
|
||||
var tenantA = dark.Tenants.Single(t => t.TenantId == TenantA);
|
||||
Assert.Equal(new[] { Alice, App }, tenantA.Accounts.Select(a => a.Principal).OrderBy(p => p, StringComparer.Ordinal));
|
||||
Assert.Equal(0, tenantA.Calls);
|
||||
Assert.Equal(55, tenantA.Requests);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The fixed KQL of the MCP usage overview: which rows it reads (the worker's request row, MCP only, no access
|
||||
/// probe), that it counts calls exactly, leaves platform admins out unless asked, matches log lines by their
|
||||
/// start, reads only fields the MCP server writes (tests/fixtures/mcp-log-lines, proven on the MCP side by
|
||||
/// usage-log-fields.test.ts) — and that the parsers read the result tables by column name.
|
||||
/// </summary>
|
||||
public class McpUsageTelemetryQueriesTests
|
||||
{
|
||||
private static readonly Window Customers = new(new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 11, 6, 0, 0, 0, DateTimeKind.Utc), IncludePlatformAdmins: false);
|
||||
private static readonly Window Everyone = Customers with { IncludePlatformAdmins = true };
|
||||
|
||||
private static IEnumerable<string> RequestQueries(Window w) => new[] { Calls(w), Routes(w), Refusals(w), Peaks(w) };
|
||||
private static IEnumerable<string> McpLogQueries(Window w) => new[] { ToolQuality(w), McpRefusals(w), McpServerFacts(w) };
|
||||
|
||||
[Fact]
|
||||
public void Window_CoversWholeUtcDays()
|
||||
{
|
||||
Assert.Equal("datetime(2026-10-07T00:00:00Z)", Customers.From);
|
||||
Assert.Equal("datetime(2026-11-06T00:00:00Z)", Customers.To);
|
||||
Assert.Equal("P31D", Customers.Timespan); // the where clauses bound the window; the timespan only has to cover it
|
||||
foreach (var query in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
|
||||
{
|
||||
Assert.Contains(">= datetime(2026-10-07T00:00:00Z)", query);
|
||||
Assert.Contains("< datetime(2026-11-06T00:00:00Z)", query);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestQueries_ReadTheWorkerRow_McpOnly_WithoutTheAccessProbe()
|
||||
{
|
||||
foreach (var query in RequestQueries(Customers))
|
||||
{
|
||||
Assert.Contains("customDimensions.Source) == 'WorkerMiddleware'", query);
|
||||
Assert.Contains("customDimensions.ClientSource) =~ 'mcp'", query);
|
||||
Assert.Contains("customDimensions.FunctionName) != 'GlobalAccessProbe'", query);
|
||||
Assert.Contains("customDimensions.McpToolName", query);
|
||||
Assert.Contains("customDimensions.CorrelationId", query);
|
||||
Assert.Contains("tolower(tostring(customDimensions.TenantId))", query);
|
||||
Assert.Contains("tolower(tostring(customDimensions.UserId))", query);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calls_AreCountedOncePerCorrelationId_NeverEstimated()
|
||||
{
|
||||
var query = Calls(Customers);
|
||||
|
||||
Assert.Contains("by corr, tool, tenant, principal", query);
|
||||
Assert.Contains("calls = count()", query);
|
||||
foreach (var q in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
|
||||
Assert.DoesNotContain("dcount(", q);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatformAdmins_AreLeftOut_UnlessIncluded()
|
||||
{
|
||||
foreach (var query in RequestQueries(Customers))
|
||||
Assert.Contains("role !in ('GlobalAdmin', 'GlobalReader')", query);
|
||||
Assert.Contains("tostring(d.scope) != 'ga'", ToolQuality(Customers));
|
||||
|
||||
foreach (var query in RequestQueries(Everyone).Concat(McpLogQueries(Everyone)))
|
||||
{
|
||||
Assert.DoesNotContain("'GlobalAdmin'", query);
|
||||
Assert.DoesNotContain("!= 'ga'", query);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refusals_AreClassifiedByTheLinesTheMiddlewaresWrite()
|
||||
{
|
||||
var query = Refusals(Customers);
|
||||
|
||||
Assert.Contains($"startswith '{McpQuotaEnforcementMiddleware.BlockedLogPrefix}'", query);
|
||||
Assert.Contains($"startswith '{UserRateLimitMiddleware.ThrottledLogPrefix}'", query);
|
||||
Assert.Equal("[McpQuota] BLOCKED", McpQuotaEnforcementMiddleware.BlockedLogPrefix);
|
||||
Assert.Equal("[UserRateLimit] THROTTLED", UserRateLimitMiddleware.ThrottledLogPrefix);
|
||||
Assert.Contains("join kind=inner lines on operation_Id", query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Peaks_SlideASixtySecondWindowInTenSecondSteps()
|
||||
{
|
||||
var query = Peaks(Customers);
|
||||
|
||||
Assert.Contains("bin(timestamp, 10s)", query);
|
||||
Assert.Contains("bin(at, 10s)", query);
|
||||
Assert.Equal(2, Regex.Matches(query, Regex.Escape("mv-expand k = range(0, 5, 1)")).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpLogLines_AreMatchedByTheirStart_NeverByHas()
|
||||
{
|
||||
Assert.Contains($"Log_s startswith '{ToolCallLinePrefix}'", ToolQuality(Customers));
|
||||
Assert.Contains($"Log_s startswith '{McpRefusalLinePrefix}'", McpRefusals(Customers));
|
||||
Assert.Contains($"Log_s startswith '{BootLinePrefix}'", McpServerFacts(Customers));
|
||||
foreach (var query in McpLogQueries(Customers))
|
||||
Assert.DoesNotContain("Log_s has", query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpLogQueries_ReadOnlyWhatTheServerWrites()
|
||||
{
|
||||
using var contract = JsonDocument.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "mcp-log-lines", "fields.json")));
|
||||
var prefixes = contract.RootElement.GetProperty("prefixes");
|
||||
var fields = contract.RootElement.GetProperty("fields");
|
||||
|
||||
Assert.Equal(ToolCallLinePrefix, prefixes.GetProperty("tool_call").GetString());
|
||||
Assert.Equal(BootLinePrefix, prefixes.GetProperty("boot").GetString());
|
||||
Assert.Equal(McpRefusalLinePrefix, prefixes.GetProperty("mcp_refusal").GetString());
|
||||
|
||||
var toolCallFields = fields.GetProperty("tool_call").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
|
||||
var readFromToolCall = Regex.Matches(ToolQuality(Everyone), @"\bd\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
|
||||
Assert.NotEmpty(readFromToolCall);
|
||||
Assert.Subset(toolCallFields, readFromToolCall);
|
||||
|
||||
var bootFields = fields.GetProperty("boot").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
|
||||
var readFromBoot = Regex.Matches(McpServerFacts(Everyone), @"parse_json\(Log_s\)\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
|
||||
Assert.Equal(new[] { "rateLimitPerMinute" }, readFromBoot);
|
||||
Assert.Subset(bootFields, readFromBoot);
|
||||
}
|
||||
|
||||
// ── Parsers ──
|
||||
|
||||
private static KqlTable Table(string[] columns, params object?[][] rows)
|
||||
=> new()
|
||||
{
|
||||
Name = "PrimaryResult",
|
||||
Columns = columns.Select(c => new KqlColumn { Name = c, Type = "dynamic" }).ToList(),
|
||||
Rows = rows.Select(r => (IReadOnlyList<JsonElement>)r.Select(cell => JsonSerializer.SerializeToElement(cell)).ToList()).ToList(),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ParseCalls_ReadsByColumnName_WhateverTheOrder()
|
||||
{
|
||||
var table = Table(
|
||||
new[] { "role", "charged", "day", "principal", "tool", "calls", "tenant", "servedCalls", "firstAt" },
|
||||
new object?[] { "Admin", 59L, "20261028", "user@contoso.com", "get_time_attribution", 64L, "t1", 59L, "2026-10-28T16:12:30.4442709Z" });
|
||||
|
||||
var row = Assert.Single(ParseCalls(table));
|
||||
|
||||
Assert.Equal(new CallRow("20261028", "get_time_attribution", "t1", "user@contoso.com", 64, 59, 59,
|
||||
new DateTime(2026, 10, 28, 16, 12, 30, 444, DateTimeKind.Utc).AddTicks(2709), "Admin"), row);
|
||||
Assert.Equal(DateTimeKind.Utc, row.LastAt!.Value.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRefusals_KeepsMissingCountsMissing()
|
||||
{
|
||||
var table = Table(
|
||||
new[] { "gate", "day", "tenant", "principal", "level", "scope", "plan", "refused", "firstAt", "lastAt", "usedCount", "limitValue" },
|
||||
new object?[] { "minute", "20261026", "t1", "user@contoso.com", "", "", "", 2L, "2026-10-26T07:55:42Z", "2026-10-26T07:55:43Z", null, null });
|
||||
|
||||
var row = Assert.Single(ParseRefusals(table));
|
||||
|
||||
Assert.Equal("minute", row.Gate);
|
||||
Assert.Equal(2, row.Refused);
|
||||
Assert.Null(row.Used);
|
||||
Assert.Null(row.Limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseToolQuality_RoundsAFractionalCount_AndKeepsTheMean()
|
||||
{
|
||||
var table = Table(
|
||||
new[] { "tool", "calls", "errors", "cancelled", "overCap", "p95Ms", "avgChars" },
|
||||
new object?[] { "search_events", 26L, 1L, 0L, 0L, 1029.6, 10602.5 });
|
||||
|
||||
var row = Assert.Single(ParseToolQuality(table));
|
||||
|
||||
Assert.Equal(26, row.Calls);
|
||||
Assert.Equal(1030, row.P95Ms);
|
||||
Assert.Equal(10602.5, row.AvgChars);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseServerFacts_WithoutABootLine_HasNoLimit()
|
||||
{
|
||||
var facts = ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" }, new object?[] { "2026-10-07T01:52:43.167992Z", null }));
|
||||
|
||||
Assert.Equal(new DateTime(2026, 10, 7, 1, 52, 43, DateTimeKind.Utc).AddTicks(1_679_920), facts.Oldest);
|
||||
Assert.Null(facts.RateLimitPerMinute);
|
||||
Assert.Equal(new ServerFacts(null, null), ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" })));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -108,16 +108,10 @@ public class MetricsFilterInjectionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTenantUsageFilter_TenantInjection_StaysInsideLiteral()
|
||||
public void BuildUserUsageFilter_BothDates_NormalizeTheShapeAndBoundTheRange()
|
||||
{
|
||||
var filter = TableUserUsageRepository.BuildTenantUsageFilter("' or TenantId ne '", null, null);
|
||||
var filter = TableUserUsageRepository.BuildUserUsageFilter("user1", "2026-11-01", "2026-11-05");
|
||||
|
||||
Assert.Equal("TenantId eq ''' or TenantId ne '''", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTenantUsageFilter_NoScope_ReturnsNull()
|
||||
{
|
||||
Assert.Null(TableUserUsageRepository.BuildTenantUsageFilter(null, null, null));
|
||||
Assert.Equal("PartitionKey eq 'user1' and Date ge '20261101' and Date le '20261105'", filter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,64 +548,6 @@ public class MetricsWireParityTests
|
||||
new GetMcpUserUsageResponse { UserId = userId, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var records = new List<UserUsageRecord>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var summaries = new List<UserUsageDailySummary>
|
||||
{
|
||||
new UserUsageDailySummary
|
||||
{
|
||||
Date = "20260830",
|
||||
TenantId = tenantId,
|
||||
TotalRequests = 321,
|
||||
UniqueUsers = 4,
|
||||
UniqueEndpoints = 11,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var summaries = new List<UserUsageDailySummary>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
|
||||
}
|
||||
|
||||
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
@@ -832,6 +774,250 @@ public class MetricsWireParityTests
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsageOverview ------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageOverviewResponse_matches_the_overview_shape()
|
||||
{
|
||||
// One tenant with one account, one tool with one route, one quota refusal, one per-minute row — every
|
||||
// optional slot filled, so every key is pinned in its wire position.
|
||||
var at = new DateTime(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
|
||||
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
|
||||
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 35, MonthlyUsed = 60 };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
dateFrom = "20261103",
|
||||
dateTo = "20261105",
|
||||
includesPlatformAdmins = false,
|
||||
telemetry = new { requestsAvailable = true, mcpLogAvailable = true, mcpLogFrom = "20261104", partial = true },
|
||||
totals = new
|
||||
{
|
||||
calls = 10L, mcpOnlyCalls = 4L, tools = 2, requests = 50L, tenants = 1, accounts = 1, servicePrincipals = 0,
|
||||
quotaHitAccounts = 1, minuteRefusedAccounts = 1, limitAccounts = 1,
|
||||
},
|
||||
daily = new[] { new { date = "20261105", calls = 10L, requests = 35L, accounts = 3, callsByPlan = new[] { new { plan = "community", calls = 10L } } } },
|
||||
tenants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
domainName = "contoso.com",
|
||||
quota,
|
||||
calls = 7L,
|
||||
requests = 50L,
|
||||
quotaHits = 1,
|
||||
lastRequestAt = at,
|
||||
accounts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
userId = "oid-1",
|
||||
principal = "admin@contoso.com",
|
||||
role = "Admin",
|
||||
isPlatformAdmin = false,
|
||||
quota = new { plan = "community", dailyLimit = 50, monthlyLimit = 250, dailyUsed = 30L, monthlyUsed = 60L },
|
||||
calls = 7L,
|
||||
requests = 50L,
|
||||
peakDay = "20261105",
|
||||
peakDayRequests = 30L,
|
||||
quotaHits = 1,
|
||||
topTools = new[] { new { tool = "get_session_events", calls = 3L } },
|
||||
lastRequestAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = "get_session_summary",
|
||||
calls = 3L,
|
||||
accounts = 2,
|
||||
tenants = 1,
|
||||
requestsPerCall = 5.0,
|
||||
errorRate = 0.0,
|
||||
p95DurationMs = 600L,
|
||||
avgResultChars = 19000L,
|
||||
overCapCalls = 0L,
|
||||
routes = new[] { new { route = "GET sessions/{id}", requests = 3L } },
|
||||
},
|
||||
},
|
||||
quotaHits = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
date = "20261105", tenantId, principal = "admin@contoso.com", level = "user", scope = "daily", plan = "community",
|
||||
limit = 50L, used = 50L, refused = 3L, firstAt = at, lastAt = at,
|
||||
},
|
||||
},
|
||||
minuteLimits = new
|
||||
{
|
||||
mcpServerLimit = 60,
|
||||
accounts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId, principal = "admin@contoso.com", sharePercent = 33,
|
||||
peakCalls = 12L, peakCallsAt = at, mcpRefused = 7L, mcpLastRefusedAt = at,
|
||||
peakRequests = 40L, peakRequestsAt = at, backendLimit = 120, backendRefused = 2L, backendLastRefusedAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
new GetGlobalMcpUsageOverviewResponse
|
||||
{
|
||||
DateFrom = "20261103",
|
||||
DateTo = "20261105",
|
||||
IncludesPlatformAdmins = false,
|
||||
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = true, McpLogAvailable = true, McpLogFrom = "20261104", Partial = true },
|
||||
Totals = new McpUsageTotalsNode
|
||||
{
|
||||
Calls = 10, McpOnlyCalls = 4, Tools = 2, Requests = 50, Tenants = 1, Accounts = 1, ServicePrincipals = 0,
|
||||
QuotaHitAccounts = 1, MinuteRefusedAccounts = 1, LimitAccounts = 1,
|
||||
},
|
||||
Daily = new List<McpUsageDayItem>
|
||||
{
|
||||
new() { Date = "20261105", Calls = 10, Requests = 35, Accounts = 3, CallsByPlan = new List<McpUsagePlanCallsItem> { new() { Plan = "community", Calls = 10 } } },
|
||||
},
|
||||
Tenants = new List<McpUsageTenantItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DomainName = "contoso.com",
|
||||
Quota = quota,
|
||||
Calls = 7,
|
||||
Requests = 50,
|
||||
QuotaHits = 1,
|
||||
LastRequestAt = at,
|
||||
Accounts = new List<McpUsageAccountItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
UserId = "oid-1",
|
||||
Principal = "admin@contoso.com",
|
||||
Role = "Admin",
|
||||
IsPlatformAdmin = false,
|
||||
Quota = new McpUsageAccountQuotaNode { Plan = "community", DailyLimit = 50, MonthlyLimit = 250, DailyUsed = 30, MonthlyUsed = 60 },
|
||||
Calls = 7,
|
||||
Requests = 50,
|
||||
PeakDay = "20261105",
|
||||
PeakDayRequests = 30,
|
||||
QuotaHits = 1,
|
||||
TopTools = new List<McpUsageToolCallsItem> { new() { Tool = "get_session_events", Calls = 3 } },
|
||||
LastRequestAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Tools = new List<McpUsageToolItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Tool = "get_session_summary",
|
||||
Calls = 3,
|
||||
Accounts = 2,
|
||||
Tenants = 1,
|
||||
RequestsPerCall = 5.0,
|
||||
ErrorRate = 0.0,
|
||||
P95DurationMs = 600,
|
||||
AvgResultChars = 19000,
|
||||
OverCapCalls = 0,
|
||||
Routes = new List<McpUsageRouteItem> { new() { Route = "GET sessions/{id}", Requests = 3 } },
|
||||
},
|
||||
},
|
||||
QuotaHits = new List<McpUsageQuotaHitItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Date = "20261105", TenantId = tenantId, Principal = "admin@contoso.com", Level = "user", Scope = "daily", Plan = "community",
|
||||
Limit = 50, Used = 50, Refused = 3, FirstAt = at, LastAt = at,
|
||||
},
|
||||
},
|
||||
MinuteLimits = new McpUsageMinuteLimits
|
||||
{
|
||||
McpServerLimit = 60,
|
||||
Accounts = new List<McpUsageMinuteItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId, Principal = "admin@contoso.com", SharePercent = 33,
|
||||
PeakCalls = 12, PeakCallsAt = at, McpRefused = 7, McpLastRefusedAt = at,
|
||||
PeakRequests = 40, PeakRequestsAt = at, BackendLimit = 120, BackendRefused = 2, BackendLastRefusedAt = at,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageOverviewResponse_omits_what_a_store_did_not_answer()
|
||||
{
|
||||
// Telemetry down, an account known only from the telemetry shape (no object id, no quota), an MCP-only
|
||||
// tool (no reach, no quality), no MCP limit reported yet: every nullable key vanishes.
|
||||
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
|
||||
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 0, MonthlyUsed = 0 };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
dateFrom = "20261105",
|
||||
dateTo = "20261105",
|
||||
includesPlatformAdmins = true,
|
||||
telemetry = new { requestsAvailable = false, mcpLogAvailable = false },
|
||||
totals = new { tools = 1, requests = 0L, tenants = 1, accounts = 1, servicePrincipals = 1, quotaHitAccounts = 0, minuteRefusedAccounts = 0, limitAccounts = 0 },
|
||||
daily = Array.Empty<object>(),
|
||||
tenants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
quota,
|
||||
calls = 4L,
|
||||
requests = 0L,
|
||||
quotaHits = 0,
|
||||
accounts = new[]
|
||||
{
|
||||
new { principal = "app:11111111-2222-3333-4444-555555555555", isPlatformAdmin = false, calls = 4L, requests = 0L, quotaHits = 0, topTools = Array.Empty<object>() },
|
||||
},
|
||||
},
|
||||
},
|
||||
tools = new[] { new { tool = "search_docs", calls = 4L, requestsPerCall = 0.0, routes = Array.Empty<object>() } },
|
||||
quotaHits = Array.Empty<object>(),
|
||||
minuteLimits = new { accounts = Array.Empty<object>() },
|
||||
},
|
||||
new GetGlobalMcpUsageOverviewResponse
|
||||
{
|
||||
DateFrom = "20261105",
|
||||
DateTo = "20261105",
|
||||
IncludesPlatformAdmins = true,
|
||||
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = false, McpLogAvailable = false },
|
||||
Totals = new McpUsageTotalsNode { Tools = 1, Requests = 0, Tenants = 1, Accounts = 1, ServicePrincipals = 1 },
|
||||
Daily = new List<McpUsageDayItem>(),
|
||||
Tenants = new List<McpUsageTenantItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Quota = quota,
|
||||
Calls = 4,
|
||||
Requests = 0,
|
||||
QuotaHits = 0,
|
||||
Accounts = new List<McpUsageAccountItem>
|
||||
{
|
||||
new() { Principal = "app:11111111-2222-3333-4444-555555555555", Calls = 4, TopTools = new List<McpUsageToolCallsItem>() },
|
||||
},
|
||||
},
|
||||
},
|
||||
Tools = new List<McpUsageToolItem> { new() { Tool = "search_docs", Calls = 4, RequestsPerCall = 0, Routes = new List<McpUsageRouteItem>() } },
|
||||
QuotaHits = new List<McpUsageQuotaHitItem>(),
|
||||
MinuteLimits = new McpUsageMinuteLimits { Accounts = new List<McpUsageMinuteItem>() },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetGrowthMetrics ---------------------------------------------------------------
|
||||
|
||||
private static GrowthMetricsResponse GrowthResponse(double? median, IReadOnlyList<GrowthTenantRow>? tenants) => new()
|
||||
@@ -962,6 +1148,177 @@ public class MetricsWireParityTests
|
||||
AssertParity(GrowthLiteral(null, null), GrowthResponse(null, null));
|
||||
}
|
||||
|
||||
// ---------- GET global/metrics/tenant-activity ----------
|
||||
|
||||
private const string ActivityTenantId = "8bb31d22-0004-4c8d-b2e3-63f4bbbb0001";
|
||||
private static readonly DateTime ActivityOnboardedAt = new(2026, 6, 30, 9, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime ActivityCounterAt = new(2026, 11, 6, 7, 30, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime ActivityVisitAt = new(2026, 11, 6, 8, 15, 0, DateTimeKind.Utc);
|
||||
|
||||
private static GetTenantActivityResponse TenantActivityResponse(bool full) => new()
|
||||
{
|
||||
WindowDays = 30,
|
||||
WindowStart = "2026-10-07",
|
||||
UsageDaysThrough = "2026-11-05",
|
||||
PreviousWindowStart = "2026-09-07",
|
||||
RowsFrom = "2026-05-10",
|
||||
PortalActivityAvailable = full,
|
||||
Totals = new TenantActivityTotals
|
||||
{
|
||||
SignedUpTenants = 2,
|
||||
ActiveTenants = 1,
|
||||
ActiveTenantsPrevious = 1,
|
||||
ActiveTenantsTrendPercent = full ? 0 : null,
|
||||
Enrollments = 6,
|
||||
EnrollmentsPrevious = 4,
|
||||
EnrollmentsTrendPercent = full ? 50 : null,
|
||||
Succeeded = 5,
|
||||
Failed = 1,
|
||||
SuccessPercent = full ? 83 : null,
|
||||
Top10SharePercent = full ? 100 : null,
|
||||
Stages = new TenantActivityStageCounts { NeverEnrolled = 1, Active = 1 },
|
||||
PortalUsers = full ? 3 : null,
|
||||
PortalTenants = full ? 1 : null,
|
||||
},
|
||||
Daily = new List<TenantActivityDailyItem>
|
||||
{
|
||||
new() { Day = "2026-11-05", Enrollments = 6, Succeeded = 5, Failed = 1, ActiveTenants = 1 },
|
||||
},
|
||||
Tenants = new List<TenantActivityTenantItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = ActivityTenantId,
|
||||
DomainName = "contoso.example",
|
||||
CompanyName = full ? "Contoso" : null,
|
||||
Edition = TenantEditionNames.ProTrial,
|
||||
Stage = TenantActivityStages.Active,
|
||||
OnboardedAt = full ? ActivityOnboardedAt : null,
|
||||
Enrollments = 6,
|
||||
EnrollmentsPrevious = 4,
|
||||
TrendPercent = full ? 50 : null,
|
||||
Succeeded = 5,
|
||||
Failed = 1,
|
||||
SuccessPercent = full ? 83 : null,
|
||||
ActiveDays = 1,
|
||||
DailyEnrollments = full ? new[] { 6 } : null,
|
||||
FirstEnrollmentDay = full ? "2026-07-01" : null,
|
||||
LastActiveDay = full ? "2026-11-05" : null,
|
||||
LastCounterUpdateAt = full ? ActivityCounterAt : null,
|
||||
LifetimeEnrollments = 120,
|
||||
LifetimeSucceeded = 100,
|
||||
LifetimeEvents = 50_000,
|
||||
PortalUsers = full ? 3 : null,
|
||||
LastPortalVisitAt = full ? ActivityVisitAt : null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void GetTenantActivityResponse_matches_the_shape_with_every_optional_key()
|
||||
{
|
||||
AssertParity(new
|
||||
{
|
||||
windowDays = 30,
|
||||
windowStart = "2026-10-07",
|
||||
usageDaysThrough = "2026-11-05",
|
||||
previousWindowStart = "2026-09-07",
|
||||
rowsFrom = "2026-05-10",
|
||||
portalActivityAvailable = true,
|
||||
totals = new
|
||||
{
|
||||
signedUpTenants = 2,
|
||||
activeTenants = 1,
|
||||
activeTenantsPrevious = 1,
|
||||
activeTenantsTrendPercent = 0,
|
||||
enrollments = 6,
|
||||
enrollmentsPrevious = 4,
|
||||
enrollmentsTrendPercent = 50,
|
||||
succeeded = 5,
|
||||
failed = 1,
|
||||
successPercent = 83,
|
||||
top10SharePercent = 100,
|
||||
stages = new { neverEnrolled = 1, dormant = 0, @new = 0, declining = 0, active = 1, quiet = 0 },
|
||||
portalUsers = 3,
|
||||
portalTenants = 1,
|
||||
},
|
||||
daily = new[] { new { day = "2026-11-05", enrollments = 6, succeeded = 5, failed = 1, activeTenants = 1 } },
|
||||
tenants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = ActivityTenantId,
|
||||
domainName = "contoso.example",
|
||||
companyName = "Contoso",
|
||||
edition = "pro-trial",
|
||||
stage = "active",
|
||||
onboardedAt = ActivityOnboardedAt,
|
||||
enrollments = 6,
|
||||
enrollmentsPrevious = 4,
|
||||
trendPercent = 50,
|
||||
succeeded = 5,
|
||||
failed = 1,
|
||||
successPercent = 83,
|
||||
activeDays = 1,
|
||||
dailyEnrollments = new[] { 6 },
|
||||
firstEnrollmentDay = "2026-07-01",
|
||||
lastActiveDay = "2026-11-05",
|
||||
lastCounterUpdateAt = ActivityCounterAt,
|
||||
lifetimeEnrollments = 120L,
|
||||
lifetimeSucceeded = 100L,
|
||||
lifetimeEvents = 50_000L,
|
||||
portalUsers = 3,
|
||||
lastPortalVisitAt = ActivityVisitAt,
|
||||
},
|
||||
},
|
||||
}, TenantActivityResponse(full: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTenantActivityResponse_omits_every_absent_figure()
|
||||
{
|
||||
// No sign-in history, no previous window, no finished session, no names beyond the domain.
|
||||
AssertParity(new
|
||||
{
|
||||
windowDays = 30,
|
||||
windowStart = "2026-10-07",
|
||||
usageDaysThrough = "2026-11-05",
|
||||
previousWindowStart = "2026-09-07",
|
||||
rowsFrom = "2026-05-10",
|
||||
portalActivityAvailable = false,
|
||||
totals = new
|
||||
{
|
||||
signedUpTenants = 2,
|
||||
activeTenants = 1,
|
||||
activeTenantsPrevious = 1,
|
||||
enrollments = 6,
|
||||
enrollmentsPrevious = 4,
|
||||
succeeded = 5,
|
||||
failed = 1,
|
||||
stages = new { neverEnrolled = 1, dormant = 0, @new = 0, declining = 0, active = 1, quiet = 0 },
|
||||
},
|
||||
daily = new[] { new { day = "2026-11-05", enrollments = 6, succeeded = 5, failed = 1, activeTenants = 1 } },
|
||||
tenants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = ActivityTenantId,
|
||||
domainName = "contoso.example",
|
||||
edition = "pro-trial",
|
||||
stage = "active",
|
||||
enrollments = 6,
|
||||
enrollmentsPrevious = 4,
|
||||
succeeded = 5,
|
||||
failed = 1,
|
||||
activeDays = 1,
|
||||
lifetimeEnrollments = 120L,
|
||||
lifetimeSucceeded = 100L,
|
||||
lifetimeEvents = 50_000L,
|
||||
},
|
||||
},
|
||||
}, TenantActivityResponse(full: false));
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contains Newtonsoft.Json (D-420): new code reads and writes JSON with System.Text.Json. Every shipped
|
||||
/// source file under <c>src/</c> that uses Newtonsoft today is on a frozen baseline that may only shrink,
|
||||
/// and only the projects in <see cref="ProjectsWithPackage"/> may reference the package. Test projects are
|
||||
/// out of scope: they exercise Newtonsoft-bound code and never ship.
|
||||
/// A file uses Newtonsoft when a code line names the <c>Newtonsoft.Json</c> namespace (using, alias,
|
||||
/// fully qualified name, or the DLL name); comment lines do not count.
|
||||
/// </summary>
|
||||
public class NewtonsoftUsageGuardTests
|
||||
{
|
||||
private const string AgentWireAndState = "agent (net48): wire format and on-device state, which move only with an agent release";
|
||||
private const string DecisionCore = "DecisionCore serialization shared by the agent and its tests";
|
||||
private const string SummaryDialog = "summary dialog (net48): reads the agent's final status and checks the DLL ships beside it";
|
||||
private const string Ingest = "agent ingest wire (D-200)";
|
||||
private const string ConfigRules = "Newtonsoft-bound configuration and rule documents (PopulateObject)";
|
||||
private const string QueueEnvelope = "queue envelope contract shared by producers and consumers";
|
||||
private const string StoredJson = "stored JSON (table columns, blobs, report files) that existing rows must keep reading";
|
||||
private const string OutgoingPayload = "outgoing notification payloads";
|
||||
private const string RuleTemplates = "rule template interpolation over Newtonsoft tokens";
|
||||
|
||||
/// <summary>Path relative to <c>src/</c> → why it may still use Newtonsoft. Only shrinks.</summary>
|
||||
private static readonly Dictionary<string, string> Baseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BuiltInConfigCatalog.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashDumpCapture.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashRecord.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/PendingCrashReporter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EventRecordWatermark.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/MdmRebootPolicyTracker.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleStatePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/BandwidthStatePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OsBuildChangeHost.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Persistence/StartupEventGate.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequencePersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Signals/TelemetrySignalEmitter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Transitions/TelemetryTransitionEmitter.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/BackendTelemetryUploader.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemSerializer.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadCursorPersistence.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2/Program.ImeMatchingMode.cs"] = AgentWireAndState,
|
||||
["Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs"] = AgentWireAndState,
|
||||
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs"] = DecisionCore,
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs"] = DecisionCore,
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/StateSerializer.cs"] = DecisionCore,
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/TransitionSerializer.cs"] = DecisionCore,
|
||||
["Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs"] = DecisionCore,
|
||||
|
||||
["Agent/AutopilotMonitor.SummaryDialog/App.xaml.cs"] = SummaryDialog,
|
||||
["Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml.cs"] = SummaryDialog,
|
||||
["Agent/AutopilotMonitor.SummaryDialog/Models/FinalStatus.cs"] = SummaryDialog,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Ingest/EventDataNormalizer.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Ingest/TelemetryPayloadParser.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Ime/ImePatternHealthService.cs"] = Ingest,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityInventoryLoader.cs"] = Ingest,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchAdminConfigurationFunction.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Helpers/RequestBody.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/BuiltInGatherRules.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/BuiltInImeLogPatterns.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/GitHubRuleRepository.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/RulesWrapper.cs"] = ConfigRules,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs"] = ConfigRules,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Services/Activation/AzureQueueTenantAutoApproveEnqueuer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Analyze/AzureQueueAnalyzeOnEnrollmentEndProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/AzureQueueCriticalTableBackupProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/CriticalTableBackupQueueWorker.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Deletion/AzureQueueSessionDeletionMaintenanceTriggerProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionWorker.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Ime/AzureQueueImeMsiArchiveProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Maintenance/AzureQueueMaintenanceTriggerProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/AzureQueueNotificationDispatchProducer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Offboarding/AzureQueueTenantOffboardingEnqueuer.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingWorker.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueueEnvelopeReader.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueuePollingWorker.cs"] = QueueEnvelope,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/AzureQueueVulnerabilityCorrelateProducer.cs"] = QueueEnvelope,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.ServerActions.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelateHandler.cs"] = StoredJson,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs"] = StoredJson,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/DiscordRenderer.cs"] = OutgoingPayload,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs"] = OutgoingPayload,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/SlackRenderer.cs"] = OutgoingPayload,
|
||||
["Backend/AutopilotMonitor.Functions/Services/Notifications/TeamsWorkflowAdaptiveCardRenderer.cs"] = OutgoingPayload,
|
||||
|
||||
["Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs"] = RuleTemplates,
|
||||
};
|
||||
|
||||
/// <summary>The projects that may reference the Newtonsoft.Json package directly.</summary>
|
||||
private static readonly string[] ProjectsWithPackage =
|
||||
{
|
||||
"Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj",
|
||||
"Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj",
|
||||
"Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj",
|
||||
"Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj",
|
||||
"Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj",
|
||||
};
|
||||
|
||||
private static readonly Regex NamespaceUse = new(@"\bNewtonsoft\.Json\b", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex PackageReference = new(
|
||||
@"<PackageReference\s+Include\s*=\s*""Newtonsoft\.Json""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void Newtonsoft_use_is_frozen_per_file()
|
||||
{
|
||||
var actual = ShippedSources()
|
||||
.Where(s => UsesNewtonsoft(s.Text))
|
||||
.Select(s => s.Relative)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var violations = new List<string>();
|
||||
foreach (var file in actual.OrderBy(f => f, StringComparer.Ordinal))
|
||||
{
|
||||
if (!Baseline.ContainsKey(file))
|
||||
violations.Add($"{file}: NEW use of Newtonsoft.Json — read and write JSON with System.Text.Json; the Newtonsoft baseline is frozen (D-420).");
|
||||
}
|
||||
foreach (var file in Baseline.Keys.OrderBy(f => f, StringComparer.Ordinal))
|
||||
{
|
||||
if (!actual.Contains(file))
|
||||
violations.Add($"{file}: on the baseline but no longer uses Newtonsoft.Json (or is gone) — remove its entry (ratchet down).");
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0, "Newtonsoft.Json baseline violated:\n " + string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Newtonsoft_package_only_in_known_projects()
|
||||
{
|
||||
var srcRoot = Path.Combine(FindRepoRoot(), "src");
|
||||
var actual = SourceFiles(srcRoot, "*.csproj", includeTestProjects: true)
|
||||
.Where(f => PackageReference.IsMatch(File.ReadAllText(f)))
|
||||
.Select(f => Relative(srcRoot, f))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var violations = actual.Except(ProjectsWithPackage, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(p => $"{p}: NEW Newtonsoft.Json package reference — use System.Text.Json (D-420).")
|
||||
.Concat(ProjectsWithPackage.Except(actual, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(p => $"{p}: listed but no longer references Newtonsoft.Json — remove it from ProjectsWithPackage (ratchet down)."))
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.True(violations.Count == 0, "Newtonsoft.Json package references violated:\n " + string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("using Newtonsoft.Json;", true)]
|
||||
[InlineData("using Newtonsoft.Json.Linq;", true)]
|
||||
[InlineData("using JsonConvert = Newtonsoft.Json.JsonConvert;", true)]
|
||||
[InlineData(" catch (Newtonsoft.Json.JsonException) { }", true)]
|
||||
[InlineData("var dll = Path.Combine(dir, \"Newtonsoft.Json.dll\");", true)]
|
||||
[InlineData("var url = \"https://x\"; Newtonsoft.Json.Linq.JObject.Parse(s); // trailing", true)]
|
||||
[InlineData("var x = 1; // Newtonsoft.Json is not used here", false)]
|
||||
[InlineData(" /// <summary>Newtonsoft.Json defaults on both sides.</summary>", false)]
|
||||
[InlineData("// Newtonsoft.Json", false)]
|
||||
[InlineData(" /* Newtonsoft.Json */", false)]
|
||||
[InlineData(" * Newtonsoft.Json in a block comment", false)]
|
||||
[InlineData("var rule = await RequestBody.ReadNewtonsoftAsync<AnalyzeRule>(req);", false)]
|
||||
public void Use_detector_sees_code_but_not_comments(string line, bool expected)
|
||||
=> Assert.Equal(expected, UsesNewtonsoft(line));
|
||||
|
||||
private static bool UsesNewtonsoft(string text)
|
||||
{
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
var trimmed = raw.TrimStart();
|
||||
if (trimmed.StartsWith("//", StringComparison.Ordinal) || trimmed.StartsWith("/*", StringComparison.Ordinal) || trimmed.StartsWith('*'))
|
||||
continue;
|
||||
var line = raw;
|
||||
var comment = line.IndexOf("//", StringComparison.Ordinal);
|
||||
if (comment >= 0 && !line[..comment].Contains('"'))
|
||||
line = line[..comment];
|
||||
if (NamespaceUse.IsMatch(line))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<(string Relative, string Text)> ShippedSources()
|
||||
{
|
||||
var srcRoot = Path.Combine(FindRepoRoot(), "src");
|
||||
return SourceFiles(srcRoot, "*.cs", includeTestProjects: false)
|
||||
.Select(f => (Relative(srcRoot, f), File.ReadAllText(f)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks <paramref name="root"/> without entering build output or package folders: the web and MCP
|
||||
/// node_modules alone would make a plain recursive enumeration take tens of seconds.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> SourceFiles(string root, string pattern, bool includeTestProjects)
|
||||
{
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(root);
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var dir = pending.Pop();
|
||||
foreach (var file in Directory.EnumerateFiles(dir, pattern))
|
||||
yield return file;
|
||||
foreach (var sub in Directory.EnumerateDirectories(dir))
|
||||
{
|
||||
var name = Path.GetFileName(sub);
|
||||
if (SkippedFolders.Contains(name) || (!includeTestProjects && name.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
pending.Push(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> SkippedFolders = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", "node_modules", ".next" };
|
||||
|
||||
private static string Relative(string root, string path)
|
||||
=> Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/');
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -435,10 +435,10 @@ public class NotificationChannelHealthTests
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(webhookResult);
|
||||
}
|
||||
var telegram = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
|
||||
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>())).ReturnsAsync(Ok());
|
||||
return new NotificationChannelDispatcher(webhook.Object, telegram.Object, (push ?? new Mock<IPushChannelSender>()).Object,
|
||||
Mock.Of<IEmailChannelSender>(), recorder.Object, NullLogger<NotificationChannelDispatcher>.Instance);
|
||||
var email = new Mock<IEmailChannelSender>();
|
||||
email.Setup(e => e.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>())).ReturnsAsync(Ok());
|
||||
return new NotificationChannelDispatcher(webhook.Object, (push ?? new Mock<IPushChannelSender>()).Object,
|
||||
email.Object, recorder.Object, NullLogger<NotificationChannelDispatcher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -447,13 +447,13 @@ public class NotificationChannelHealthTests
|
||||
var recorder = new Mock<INotificationChannelHealthRecorder>();
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
var dispatcher = Dispatcher(recorder, push: push);
|
||||
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
|
||||
var mail = new NotificationChannel { Id = "mail", Name = "mail", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
|
||||
var phones = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Push, Enabled = true };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram, phones }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), mail, phones }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"), It.Is<NotificationSendResult>(x => !x.Success), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.Is<NotificationSendResult>(x => x.Success), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "mail"), It.Is<NotificationSendResult>(x => x.Success), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(It.IsAny<NotificationScope>(), It.Is<NotificationChannel>(c => c.Id == "push"), It.IsAny<NotificationSendResult>(), It.IsAny<ChannelSendKind>()), Times.Never);
|
||||
push.Verify(p => p.SendAsync(Tenant, It.IsAny<NotificationAlert>()), Times.Once);
|
||||
}
|
||||
@@ -474,13 +474,13 @@ public class NotificationChannelHealthTests
|
||||
{
|
||||
var recorder = new Mock<INotificationChannelHealthRecorder>();
|
||||
var dispatcher = Dispatcher(recorder, webhookResult: () => throw new InvalidOperationException("boom"));
|
||||
var telegram = new NotificationChannel { Id = "tg", Name = "tg", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
|
||||
var mail = new NotificationChannel { Id = "mail", Name = "mail", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), telegram }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
await dispatcher.SendToChannelsAsync(new[] { Webhook(), mail }, new NotificationAlert { Title = "t", Summary = "s" }, Tenant);
|
||||
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "c1"),
|
||||
It.Is<NotificationSendResult>(x => !x.Success && x.Message == "Unexpected error: boom"), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "tg"), It.IsAny<NotificationSendResult>(), ChannelSendKind.Delivery), Times.Once);
|
||||
recorder.Verify(r => r.RecordAsync(Tenant, It.Is<NotificationChannel>(c => c.Id == "mail"), It.IsAny<NotificationSendResult>(), ChannelSendKind.Delivery), Times.Once);
|
||||
}
|
||||
|
||||
// ── Read endpoint projection ──────────────────────────────────────────
|
||||
|
||||
@@ -36,6 +36,7 @@ public class NotificationChannelsTests
|
||||
"{\"name\":\"no-id\",\"providerType\":20,\"url\":\"https://x.example\"}",
|
||||
ChannelJson("none-provider", providerType: 0),
|
||||
ChannelJson("retired-teams-connector", providerType: 1),
|
||||
ChannelJson("retired-provider", providerType: 40),
|
||||
ChannelJson("unknown-provider", providerType: 99)) + "]";
|
||||
|
||||
var parsed = NotificationChannel.ParseList(json);
|
||||
@@ -153,6 +154,20 @@ public class NotificationChannelsTests
|
||||
Assert.Null(redacted[1].SigningSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactedCopy_MasksTheRecipientsOfAnEmailChannel()
|
||||
{
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
NotificationChannelsJson = "[" + ChannelJson("mail", providerType: 60, url: "ops@example.invalid") + "]",
|
||||
};
|
||||
|
||||
var redacted = NotificationChannel.ParseList(cfg.RedactedCopyForReader().NotificationChannelsJson);
|
||||
|
||||
Assert.Equal("mail", Assert.Single(redacted).Id);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactedCopy_UnparseableChannelsJson_FallsBackToWholeStringRedaction()
|
||||
{
|
||||
@@ -218,6 +233,7 @@ public class NotificationChannelsTests
|
||||
[InlineData("[{\"name\":\"no-id\",\"providerType\":20}]", "needs an id")]
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":99}]", "invalid provider type")]
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":1}]", "invalid provider type")] // retired Teams Office 365 Connector
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":40}]", "invalid provider type")] // retired, never reused
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":20,\"url\":\"http://plain.example\"}]", "")] // non-https rejected by SsrfGuard format check
|
||||
public void ValidateNotificationChannels_RejectsInvalidEntries(string json, string expectedFragment)
|
||||
{
|
||||
@@ -238,6 +254,7 @@ public class NotificationChannelsTests
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, false)] // retired Teams Office 365 Connector
|
||||
[InlineData(40, false)] // retired, never reused
|
||||
[InlineData(99, false)]
|
||||
[InlineData(0, true)]
|
||||
[InlineData(2, true)]
|
||||
@@ -257,8 +274,6 @@ public class NotificationChannelsTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40, false)] // Telegram
|
||||
[InlineData(40, true)]
|
||||
[InlineData(50, false)] // Push
|
||||
[InlineData(50, true)]
|
||||
[InlineData(60, false)] // E-mail
|
||||
@@ -275,7 +290,6 @@ public class NotificationChannelsTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40)]
|
||||
[InlineData(50)]
|
||||
[InlineData(60)]
|
||||
public void ValidateModel_LegacyWebhook_KeepsAnUnchangedStoredNonWebhookProviderSaveable(int providerType)
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ops-event payload delivery: the structured Details object reaches outbound channels, both as
|
||||
/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers),
|
||||
/// readable facts (card + e-mail formats) and verbatim under "data" (generic JSON consumers),
|
||||
/// and only the channels whose rule opted in get it — on the platform scope, like every ops send.
|
||||
/// Before this, an alert carried only category/event/severity and a tenant GUID.
|
||||
/// </summary>
|
||||
@@ -117,10 +117,10 @@ public class OpsAlertPayloadTests
|
||||
=> Assert.Equal("1.0", JObject.Parse(new GenericJsonRenderer().RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s", DataJson = """{"a":1}""" }))["schemaVersion"]!.Value<string>());
|
||||
|
||||
// ── Telegram text carries the same information ────────────────────────
|
||||
// ── E-mail text carries the same information ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TelegramRendering_IncludesFlattenedFacts()
|
||||
public void EmailRendering_IncludesFlattenedFacts()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
@@ -130,7 +130,7 @@ public class OpsAlertPayloadTests
|
||||
Facts = OpsAlertDispatchService.FlattenDetails("""{"domainName":"contoso.example"}"""),
|
||||
};
|
||||
|
||||
var text = TelegramNotificationService.RenderAlertText(alert);
|
||||
var text = EmailAlertRenderer.Text(alert);
|
||||
|
||||
Assert.Contains("Domain Name: contoso.example", text);
|
||||
Assert.Contains("Pro trial started", text);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The ops events behind a customer submission are the only signal an alert channel gets. They
|
||||
/// name ids and the portal path, never the submitter or a comment; the submitter stays in the
|
||||
/// row's user id, which the dispatch never forwards.
|
||||
/// </summary>
|
||||
public class OpsEventSubmissionSignalsTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Submitter = "someone@contoso.invalid";
|
||||
|
||||
private readonly List<OpsEventEntry> _events = new();
|
||||
private readonly OpsEventService _sut;
|
||||
|
||||
public OpsEventSubmissionSignalsTests()
|
||||
{
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(_events.Add)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
|
||||
_sut = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
}
|
||||
|
||||
private OpsEventEntry AssertSingleSignal(string eventType, string portalPath)
|
||||
{
|
||||
var entry = Assert.Single(_events);
|
||||
Assert.Equal(eventType, entry.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, entry.Severity);
|
||||
Assert.Equal(TenantId, entry.TenantId);
|
||||
Assert.Contains(portalPath, entry.Details!);
|
||||
|
||||
Assert.Equal(Submitter, entry.UserId);
|
||||
Assert.DoesNotContain(Submitter, entry.Message);
|
||||
Assert.DoesNotContain(Submitter, entry.Details!);
|
||||
return entry;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantSignup_names_the_tenant_page()
|
||||
{
|
||||
await _sut.RecordTenantSignupAsync(TenantId, Submitter);
|
||||
|
||||
AssertSingleSignal(OpsEventTypes.TenantSignup, $"/admin/tenants/management?tenantId={TenantId}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionReport_names_the_session_and_the_report_only()
|
||||
{
|
||||
await _sut.RecordSessionReportSubmittedAsync(TenantId, Submitter, "session-1", "report-1");
|
||||
|
||||
var entry = AssertSingleSignal(OpsEventTypes.SessionReportSubmitted, "/admin/reports/session-reports?reportId=report-1");
|
||||
Assert.Contains("session-1", entry.Details!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiagFilesReport_names_the_report_only()
|
||||
{
|
||||
await _sut.RecordDiagFilesReportSubmittedAsync(TenantId, Submitter, "report-2");
|
||||
|
||||
AssertSingleSignal(OpsEventTypes.DiagFilesReportSubmitted, "/admin/reports/session-reports?reportId=report-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuleSubmission_names_the_submission_and_the_rule_only()
|
||||
{
|
||||
await _sut.RecordRuleSubmissionReceivedAsync(TenantId, Submitter, "abc123def456", "analyze", "ANALYZE-X-1");
|
||||
|
||||
var entry = AssertSingleSignal(OpsEventTypes.RuleSubmissionReceived, "/admin/reports/rule-submissions?submissionId=abc123def456");
|
||||
Assert.Contains("ANALYZE-X-1", entry.Details!);
|
||||
}
|
||||
}
|
||||
@@ -12,28 +12,12 @@ using Moq;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Platform (ops) notification channels: legacy-slot synthesis so the pre-channels dispatch
|
||||
/// behavior survives the migration untouched, per-rule channel routing, the platform dispatch
|
||||
/// scope (an ops alert's tenant is its subject, never the channel owner), and redaction of the
|
||||
/// new secret-bearing field.
|
||||
/// Platform (ops) notification channels: the stored list, per-rule channel routing, the platform
|
||||
/// dispatch scope (an ops alert's tenant is its subject, never the channel owner), and redaction of
|
||||
/// the secret-bearing field.
|
||||
/// </summary>
|
||||
public class OpsNotificationChannelsTests
|
||||
{
|
||||
private static AdminConfiguration LegacyConfig(
|
||||
bool telegramEnabled = true, string telegramChat = "-1003785642894",
|
||||
bool teamsEnabled = true, string teamsUrl = "https://teams.example/hook",
|
||||
bool slackEnabled = false, string slackUrl = "")
|
||||
=> new()
|
||||
{
|
||||
UpdatedBy = "test",
|
||||
OpsAlertTelegramEnabled = telegramEnabled,
|
||||
OpsAlertTelegramChatId = telegramChat,
|
||||
OpsAlertTeamsEnabled = teamsEnabled,
|
||||
OpsAlertTeamsWebhookUrl = teamsUrl,
|
||||
OpsAlertSlackEnabled = slackEnabled,
|
||||
OpsAlertSlackWebhookUrl = slackUrl,
|
||||
};
|
||||
|
||||
private static NotificationChannel Channel(string id, bool enabled = true, int providerType = 20)
|
||||
=> new() { Id = id, Name = id, ProviderType = providerType, Url = "https://x.example/" + id, Enabled = enabled };
|
||||
|
||||
@@ -61,44 +45,20 @@ public class OpsNotificationChannelsTests
|
||||
private static IEnumerable<string> WithPayload(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
|
||||
=> OpsAlertDispatchService.ResolveTargets(rules, channels).WithPayload.Select(c => c.Id);
|
||||
|
||||
// ── Legacy synthesis ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_SynthesizesConfiguredLegacySlots()
|
||||
{
|
||||
var channels = LegacyConfig().GetOpsNotificationChannels();
|
||||
|
||||
Assert.Equal(2, channels.Count); // Slack slot is empty → not synthesized
|
||||
var telegram = channels[0];
|
||||
Assert.Equal(AdminConfiguration.LegacyTelegramChannelId, telegram.Id);
|
||||
Assert.Equal(AdminConfiguration.LegacyTelegramChannelName, telegram.Name);
|
||||
Assert.Equal((int)WebhookProviderType.Telegram, telegram.ProviderType);
|
||||
Assert.Equal("-1003785642894", telegram.Url);
|
||||
Assert.True(telegram.Enabled);
|
||||
|
||||
Assert.Equal(AdminConfiguration.LegacyTeamsChannelId, channels[1].Id);
|
||||
Assert.Equal((int)WebhookProviderType.TeamsWorkflowWebhook, channels[1].ProviderType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_CarriesLegacyEnabledFlags()
|
||||
{
|
||||
// A configured-but-disabled provider must stay disabled, or the migration would start
|
||||
// sending to a destination the operator had deliberately switched off.
|
||||
var channels = LegacyConfig(telegramEnabled: false).GetOpsNotificationChannels();
|
||||
|
||||
Assert.False(channels.Single(c => c.Id == AdminConfiguration.LegacyTelegramChannelId).Enabled);
|
||||
}
|
||||
// ── Stored list ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_EmptyWhenNothingConfigured()
|
||||
=> Assert.Empty(new AdminConfiguration { UpdatedBy = "t" }.GetOpsNotificationChannels());
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_StoredListWinsOverLegacySlots()
|
||||
public void GetOpsNotificationChannels_ReturnsTheStoredList()
|
||||
{
|
||||
var config = LegacyConfig();
|
||||
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") });
|
||||
var config = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "t",
|
||||
OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") }),
|
||||
};
|
||||
|
||||
var channels = config.GetOpsNotificationChannels();
|
||||
|
||||
|
||||
@@ -46,6 +46,25 @@ public class OrphanSweepOpsEventTests
|
||||
Assert.Contains("\"aggregateMetrics\":90000", evt.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MaintenanceCompleted_carries_the_step_memory_and_the_gc_limits()
|
||||
{
|
||||
var (service, saved) = Rig();
|
||||
var memory = new MaintenanceMemoryReport(
|
||||
new Dictionary<string, long> { ["appVersionRadar"] = 1900 },
|
||||
new Dictionary<string, long> { ["appVersionRadar"] = 1300 },
|
||||
GcHeapLimitMb: 1740,
|
||||
GcHighLoadMb: 2088);
|
||||
|
||||
await service.RecordMaintenanceCompletedAsync(123456, "Timer", memory: memory);
|
||||
|
||||
var evt = Assert.Single(saved);
|
||||
Assert.Contains("\"stepsPeakMb\":{\"appVersionRadar\":1900}", evt.Details);
|
||||
Assert.Contains("\"stepsAllocMb\":{\"appVersionRadar\":1300}", evt.Details);
|
||||
Assert.Contains("\"gcHeapLimitMb\":1740", evt.Details);
|
||||
Assert.Contains("\"gcHighLoadMb\":2088", evt.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphanEventsCleaned_is_a_Warning_with_per_table_totals_and_the_legacy_count()
|
||||
{
|
||||
|
||||
@@ -27,6 +27,8 @@ public class PlatformStatsStorageTests
|
||||
{
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<string> Filters { get; } = new();
|
||||
/// <summary>The projection of every query, in order; null for a query without one.</summary>
|
||||
public List<string[]?> Selects { get; } = new();
|
||||
public List<List<TableTransactionAction>> Submitted { get; } = new();
|
||||
public Func<string, TableEntity[]> RowsFor { get; set; } = _ => Array.Empty<TableEntity>();
|
||||
public Queue<Exception> SubmitFailures { get; } = new();
|
||||
@@ -35,9 +37,10 @@ public class PlatformStatsStorageTests
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(c => c.QueryAsync<TableEntity>(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((string filter, int? _, IEnumerable<string> _, CancellationToken _) =>
|
||||
.Returns((string filter, int? _, IEnumerable<string>? select, CancellationToken _) =>
|
||||
{
|
||||
Filters.Add(filter);
|
||||
Selects.Add(select?.ToArray());
|
||||
var page = Page<TableEntity>.FromValues(RowsFor(filter), null, Mock.Of<Response>());
|
||||
return AsyncPageable<TableEntity>.FromPages(new[] { page });
|
||||
});
|
||||
@@ -54,6 +57,7 @@ public class PlatformStatsStorageTests
|
||||
var service = new Mock<TableServiceClient>();
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns(Table.Object);
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.UsageMetrics)).Returns(Table.Object);
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.UserActivity)).Returns(Table.Object);
|
||||
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -281,6 +285,76 @@ public class PlatformStatsStorageTests
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetTenantActivityDaysAsync(DateTime.UtcNow.Date, DateTime.UtcNow.Date));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_activity_days_project_and_carry_the_success_and_failure_counts()
|
||||
{
|
||||
var h = new Harness
|
||||
{
|
||||
RowsFor = _ => new[]
|
||||
{
|
||||
new TableEntity("2026-10-01", Tenant) { ["SessionsTotal"] = 5, ["SessionsSucceeded"] = 3, ["SessionsFailed"] = 1 },
|
||||
new TableEntity("2026-10-02", Tenant) { ["SessionsTotal"] = 2 },
|
||||
}
|
||||
};
|
||||
|
||||
var days = await h.Sut.GetTenantActivityDaysAsync(
|
||||
new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(new[] { "PartitionKey", "RowKey", "SessionsTotal", "SessionsSucceeded", "SessionsFailed" }, Assert.Single(h.Selects));
|
||||
Assert.Collection(days,
|
||||
d => Assert.Equal((5, 3, 1), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)),
|
||||
d => Assert.Equal((2, 0, 0), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)));
|
||||
}
|
||||
|
||||
// ---------- portal visits (tenant activity) ----------
|
||||
|
||||
private static TableEntity Login(string tenantId, string upn, string objectId, DateTime? at)
|
||||
{
|
||||
var row = new TableEntity(tenantId, Guid.NewGuid().ToString("N")) { ["Upn"] = upn, ["ObjectId"] = objectId };
|
||||
if (at is { } loginAt) row["LoginAt"] = loginAt;
|
||||
return row;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Portal_visits_are_people_since_the_cutoff_keyed_by_object_id_else_upn()
|
||||
{
|
||||
const string upperTenant = "AAAAAAAA-0000-4000-8000-000000000000";
|
||||
var at = new DateTime(2026, 11, 5, 9, 30, 0, DateTimeKind.Utc);
|
||||
var h = new Harness
|
||||
{
|
||||
RowsFor = _ => new[]
|
||||
{
|
||||
Login(upperTenant, "alice@contoso.example", "OID-ALICE", at),
|
||||
Login(Tenant, "Bob@Contoso.example", "", at), // no object id: the UPN
|
||||
Login(Tenant, "app:11111111-2222-3333-4444-555555555555", "sp-object-id", at), // a service principal
|
||||
Login(Tenant, "", " ", at), // no identity at all
|
||||
Login(Tenant, "carol@contoso.example", "oid-carol", null), // no visit time
|
||||
}
|
||||
};
|
||||
|
||||
var visits = await h.Sut.GetPortalVisitsSinceAsync(new DateTime(2026, 8, 7, 12, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal("LoginAt ge datetime'2026-08-07T12:00:00Z'", Assert.Single(h.Filters));
|
||||
Assert.Equal(new[] { "PartitionKey", "Upn", "ObjectId", "LoginAt" }, Assert.Single(h.Selects));
|
||||
Assert.Collection(visits,
|
||||
v =>
|
||||
{
|
||||
Assert.Equal(upperTenant.ToLowerInvariant(), v.TenantId);
|
||||
Assert.Equal("oid-alice", v.PersonKey);
|
||||
Assert.Equal(at, v.At);
|
||||
Assert.Equal(DateTimeKind.Utc, v.At.Kind);
|
||||
},
|
||||
v => Assert.Equal((Tenant, "bob@contoso.example"), (v.TenantId, v.PersonKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_portal_visit_read_throws_instead_of_reporting_nobody()
|
||||
{
|
||||
var h = new Harness { RowsFor = _ => throw new RequestFailedException(503, "busy") };
|
||||
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetPortalVisitsSinceAsync(DateTime.UtcNow.AddDays(-90)));
|
||||
}
|
||||
|
||||
// ---------- seen device models ----------
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// (otherwise admin SignalR group joins + admin-audience notifications regress), while a pure GlobalReader
|
||||
/// stays read-only. Also covers the cross-tenant gate keying off HasGlobalScope.
|
||||
/// </summary>
|
||||
public class PolicyEnforcementMiddlewareTests
|
||||
public partial class PolicyEnforcementMiddlewareTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
@@ -1201,16 +1201,17 @@ public class PolicyEnforcementMiddlewareTests
|
||||
|
||||
/// <summary>
|
||||
/// Operator reads whose ?tenantId= is a handler-side filter for a Global Admin / Global Reader: session reports
|
||||
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), a
|
||||
/// tenant's MCP usage (its accounts and their tools) and verdict calibration (classifier diagnostics, drift
|
||||
/// alerts). None of it is the managed tenant's telemetry, so a delegated (MSP) reader never reaches them.
|
||||
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), MCP
|
||||
/// usage (a tenant's organization budget by account, the all-tenant overview with every tenant's accounts and
|
||||
/// tools) and verdict calibration (classifier diagnostics, drift alerts). None of it is the managed tenant's
|
||||
/// telemetry, so a delegated (MSP) reader never reaches them.
|
||||
/// </summary>
|
||||
public static TheoryData<string> OperatorFilterReads => new()
|
||||
{
|
||||
"/api/global/session-reports",
|
||||
"/api/global/rule-submissions",
|
||||
"/api/global/metrics/mcp-usage",
|
||||
"/api/global/metrics/mcp-usage/daily",
|
||||
"/api/global/metrics/mcp-usage/organization",
|
||||
"/api/global/metrics/mcp-usage/overview",
|
||||
"/api/global/metrics/verdict-calibration",
|
||||
};
|
||||
|
||||
@@ -1470,6 +1471,8 @@ public class PolicyEnforcementMiddlewareTests
|
||||
[InlineData("GET", "/api/global/distress-reports", TenantScoping.None)]
|
||||
// ops-events is platform-OPERATIONAL data — GA/Reader-only by intent (not a can't-bound limit), so None.
|
||||
[InlineData("GET", "/api/global/ops-events", TenantScoping.None)]
|
||||
// Every tenant's use side by side (names, enrollments, portal users) — an operator view, never an MSP's.
|
||||
[InlineData("GET", "/api/global/metrics/tenant-activity", TenantScoping.None)]
|
||||
// Operator reads with a ?tenantId= filter: OperatorFilterReads above.
|
||||
public void GlobalRoute_DelegatedAccessibility_MatchesContract(string method, string path, TenantScoping expected)
|
||||
{
|
||||
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// "View as tenant": a Global Admin reads one tenant's portal as its member, read-only. DecideAsync admits the
|
||||
/// headers only for a Global Admin, refuses every write, and decides the rest with the SAME evaluators over an
|
||||
/// emulated member of the viewed tenant — the parity test pins that the emulation answers every read route
|
||||
/// exactly like a real member of that tenant.
|
||||
/// </summary>
|
||||
public partial class PolicyEnforcementMiddlewareTests
|
||||
{
|
||||
private const string GaUpn = "ga@vendor.example";
|
||||
|
||||
private static ViewAsTarget ViewB(string role = Constants.TenantRoles.Admin) => new(TenantB, role);
|
||||
|
||||
/// <summary>A Global Admin homed in TenantA (the default identity binding) and an onboarded TenantB.</summary>
|
||||
private static Harness ViewAsHarness()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.Repo.Setup(r => r.GetGlobalRoleAsync(GaUpn)).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB });
|
||||
return h;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_GlobalAdmin_ReadsTheViewedTenantAsItsAdmin()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
var ctx = r.Context!;
|
||||
Assert.Equal(TenantB, ctx.TenantId);
|
||||
Assert.Equal(TenantB, ctx.TargetTenantId);
|
||||
Assert.False(ctx.IsGlobalAdmin);
|
||||
Assert.False(ctx.HasFleetScope);
|
||||
Assert.Null(ctx.AllowedTenantIds);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, ctx.UserRole);
|
||||
Assert.True(ctx.IsTenantAdmin);
|
||||
// Identity stays the real caller's: throttling, logs and the SignalR binding key off it.
|
||||
Assert.Equal(GaUpn, ctx.UserPrincipalName);
|
||||
Assert.Equal(new ViewAsContext(TenantA, Constants.TenantRoles.Admin), ctx.ViewAs);
|
||||
Assert.Equal(TenantA, ctx.IdentityTenantId());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tenant admin")]
|
||||
[InlineData("global reader")]
|
||||
[InlineData("delegated admin of the viewed tenant")]
|
||||
[InlineData("no role")]
|
||||
public async Task ViewAs_IsRefusedToEveryoneButAGlobalAdmin_AndReported(string caller)
|
||||
{
|
||||
const string upn = "someone@contoso.com";
|
||||
var h = ViewAsHarness();
|
||||
switch (caller)
|
||||
{
|
||||
case "tenant admin": h.AsTenantAdmin(TenantA, upn); break;
|
||||
case "global reader": h.Repo.Setup(r => r.GetGlobalRoleAsync(upn)).ReturnsAsync(Constants.GlobalRoles.GlobalReader); break;
|
||||
case "delegated admin of the viewed tenant": h.AsDelegated(TenantB, Constants.DelegatedRoles.DelegatedAdmin); break;
|
||||
}
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), viewAs: ViewB());
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(403, r.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, r.ErrorCode);
|
||||
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_ThroughMcpOrAForeignClientApp_IsRefusedEvenForAGlobalAdmin()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
var capped = AuthedPrincipal(TenantA, GaUpn);
|
||||
ClientAppBinding.MarkCapped(capped, "33333333-3333-3333-3333-333333333333");
|
||||
|
||||
var mcp = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), isMcpClient: true, viewAs: ViewB());
|
||||
var foreign = await h.Middleware.DecideAsync("GET", "/api/sessions", null, capped, viewAs: ViewB());
|
||||
|
||||
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, mcp.ErrorCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, foreign.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_AdmissionAnswers_ForMalformedHeaders_UnknownTenants_AndAnonymousCallers()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var malformed = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
|
||||
var unknown = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn),
|
||||
viewAs: new ViewAsTarget("33333333-3333-3333-3333-333333333333", Constants.TenantRoles.Admin));
|
||||
var anonymous = await h.Middleware.DecideAsync("GET", "/api/sessions", null, principal: null, viewAs: ViewB());
|
||||
|
||||
Assert.Equal((400, Constants.ApiErrorCodes.ViewAsInvalid), (malformed.StatusCode, malformed.ErrorCode));
|
||||
Assert.Equal((404, Constants.ApiErrorCodes.ViewAsTenantNotFound), (unknown.StatusCode, unknown.ErrorCode));
|
||||
Assert.Equal((401, Constants.ApiErrorCodes.AuthenticationRequired), (anonymous.StatusCode, anonymous.ErrorCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_RefusesWrites_RunsTheReadsDeclaredOnPostRoutes_AndRefusesTheGetThatStartsAnAction()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
var ga = AuthedPrincipal(TenantA, GaUpn);
|
||||
|
||||
// A test send would reach the customer's channels.
|
||||
var write = await h.Middleware.DecideAsync("POST", $"/api/config/{TenantB}/test-notification", null, ga, viewAs: ViewB());
|
||||
var join = await h.Middleware.DecideAsync("POST", "/api/realtime/groups/join", null, ga, viewAs: ViewB());
|
||||
var consentFlow = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/autopilot-device-validation/consent-url", null, ga, viewAs: ViewB());
|
||||
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (write.StatusCode, write.ErrorCode));
|
||||
Assert.Equal(ViewAsTarget.ReadOnlyMessage, write.ErrorMessage);
|
||||
Assert.True(join.Allowed);
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (consentFlow.StatusCode, consentFlow.ErrorCode));
|
||||
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(write, viewAsRequested: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_ThePlatformSurface_IsClosedLikeForAMember_AndTheDenialIsNotReported()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (r.StatusCode, r.ErrorCode));
|
||||
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
|
||||
// The same denial without view-as headers is the assume-breach report it always was.
|
||||
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_AnotherTenantsRoute_IsCrossTenantDenied_EvenTheGlobalAdminsHomeTenant()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
|
||||
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.CrossTenantAccessDenied), (r.StatusCode, r.ErrorCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_ASuspendedTenant_ShowsTheSuspension()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB, Disabled = true, DisabledReason = "Suspended for review" });
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
|
||||
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.TenantSuspended), (r.StatusCode, r.ErrorCode));
|
||||
Assert.Equal("Suspended for review", r.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_AsViewer_ReadsTheTenantButStaysOutOfTheAdminTier()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
var ga = AuthedPrincipal(TenantA, GaUpn);
|
||||
|
||||
var sessions = await h.Middleware.DecideAsync("GET", "/api/sessions", null, ga, viewAs: ViewB(Constants.TenantRoles.Viewer));
|
||||
var adminTier = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/notification-channel-health", null, ga,
|
||||
viewAs: ViewB(Constants.TenantRoles.Viewer));
|
||||
|
||||
Assert.True(sessions.Allowed);
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, sessions.Context!.UserRole);
|
||||
Assert.False(sessions.Context.IsTenantAdmin);
|
||||
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (adminTier.StatusCode, adminTier.ErrorCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewAs_RoutesWithoutAJwtTier_IgnoreTheHeaders()
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/health", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.Null(r.Context!.ViewAs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The core promise: what the Global Admin sees is what the member sees. For every route that runs in view-as
|
||||
/// mode and every tenant role, the emulated decision equals the decision for a real member of that role in the
|
||||
/// viewed tenant — admission, status, error code, role, admin flag, scope and tenants.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ViewAs_DecidesEveryReadRoute_ExactlyLikeARealMemberOfTheViewedTenant()
|
||||
{
|
||||
const string memberUpn = "member@tenant-b.example";
|
||||
const string memberOid = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
|
||||
var mismatches = new List<string>();
|
||||
var compared = 0;
|
||||
|
||||
foreach (var role in new[] { Constants.TenantRoles.Admin, Constants.TenantRoles.Operator, Constants.TenantRoles.Viewer })
|
||||
{
|
||||
var h = ViewAsHarness();
|
||||
h.Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new TenantMember { Upn = memberUpn, TenantId = TenantB, Role = role, IsEnabled = true });
|
||||
|
||||
foreach (var entry in EndpointAccessPolicyCatalog.Entries)
|
||||
{
|
||||
if (entry.ViewAsAccess != ViewAsAccess.Read
|
||||
|| entry.Policy is EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth)
|
||||
continue;
|
||||
|
||||
var path = "/api/" + Regex.Replace(entry.RouteTemplate, @"\{([^}]+)\}",
|
||||
m => m.Groups[1].Value == "tenantId" ? TenantB : "x1");
|
||||
var member = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantB, memberUpn, memberOid));
|
||||
var viewed = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB(role));
|
||||
|
||||
compared++;
|
||||
var expected = Describe(member);
|
||||
var actual = Describe(viewed);
|
||||
if (expected != actual)
|
||||
mismatches.Add($"{role} {entry.HttpMethod} {entry.RouteTemplate}: member={expected} viewAs={actual}");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Empty(mismatches);
|
||||
Assert.True(compared > 300, $"only {compared} decisions compared — the catalog walk is broken");
|
||||
}
|
||||
|
||||
private static string Describe(PolicyEnforcementMiddleware.PolicyResult r)
|
||||
=> r.Allowed
|
||||
? $"allow role={r.Context!.UserRole} admin={r.Context.IsTenantAdmin} scope={r.Context.HasFleetScope} tenant={r.Context.TenantId} target={r.Context.TargetTenantId}"
|
||||
: $"deny {r.StatusCode} {r.ErrorCode}";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The memory metric is a percentage of the limits the GC works against, so a reading keeps its
|
||||
/// meaning when the instance size changes, and an alert can name one number for every size.
|
||||
/// </summary>
|
||||
public class ProcessMemoryPressureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Heap_is_committed_memory_against_the_heap_limit()
|
||||
{
|
||||
var snapshot = default(ProcessSnapshot) with { CommittedBytes = 1305, TotalAvailableMemoryBytes = 1740 };
|
||||
|
||||
Assert.Equal(75.0, ProcessMemoryPressure.HeapPercent(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_is_memory_load_against_the_high_load_threshold()
|
||||
{
|
||||
var snapshot = default(ProcessSnapshot) with { MemoryLoadBytes = 2088, HighMemoryLoadThresholdBytes = 2088 };
|
||||
|
||||
Assert.Equal(100.0, ProcessMemoryPressure.LoadPercent(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_value_past_the_limit_is_not_capped()
|
||||
{
|
||||
var snapshot = default(ProcessSnapshot) with { MemoryLoadBytes = 2320, HighMemoryLoadThresholdBytes = 2088 };
|
||||
|
||||
Assert.Equal(111.1, ProcessMemoryPressure.LoadPercent(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_the_first_gc_there_is_no_reading()
|
||||
{
|
||||
var snapshot = default(ProcessSnapshot) with { WorkingSetBytes = 500 };
|
||||
|
||||
Assert.Null(ProcessMemoryPressure.HeapPercent(snapshot));
|
||||
Assert.Null(ProcessMemoryPressure.LoadPercent(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_captured_snapshot_after_a_gc_has_both_readings()
|
||||
{
|
||||
GC.Collect();
|
||||
|
||||
var snapshot = ProcessSnapshot.Capture();
|
||||
|
||||
Assert.True(ProcessMemoryPressure.HeapPercent(snapshot) > 0);
|
||||
Assert.True(ProcessMemoryPressure.LoadPercent(snapshot) > 0);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public class ProcessStallClassifierTests
|
||||
PendingWorkItems: pending,
|
||||
CompletedWorkItems: completed,
|
||||
WorkingSetBytes: 0, HeapSizeBytes: 0, CommittedBytes: 0,
|
||||
TotalAvailableMemoryBytes: 0, MemoryLoadBytes: 0, HighMemoryLoadThresholdBytes: 0,
|
||||
LastGcGeneration: 0, LastGcPause: TimeSpan.Zero, LastGcCompacted: false);
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// <summary>
|
||||
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
|
||||
/// webhook path, one fan-out per send), the validation rules (no destination, open to tenant
|
||||
/// admins — only Telegram and E-mail stay behind the GA gate — and one Push channel per scope),
|
||||
/// admins — only E-mail stays behind the GA gate — and one Push channel per scope),
|
||||
/// the raw-table deny-list and the dependency redaction.
|
||||
/// </summary>
|
||||
public class PushChannelRoutingTests
|
||||
@@ -35,15 +35,6 @@ public class PushChannelRoutingTests
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
{
|
||||
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static NotificationChannel PushChannel(string id = "push", bool enabled = true) =>
|
||||
new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = enabled };
|
||||
|
||||
@@ -56,9 +47,8 @@ public class PushChannelRoutingTests
|
||||
public async Task Push_channel_reaches_the_push_sender_with_the_callers_scope_and_never_the_webhook_path()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, push.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, push.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { PushChannel() }, alert, Tenant);
|
||||
@@ -66,7 +56,6 @@ public class PushChannelRoutingTests
|
||||
push.Verify(p => p.SendAsync(Tenant, alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,7 +64,7 @@ public class PushChannelRoutingTests
|
||||
// A pair stored before validation allowed only one: each would reach every paired device.
|
||||
var webhook = WebhookMock();
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, TelegramMock().Object, push.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, push.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
var slack = new NotificationChannel { Id = "slack", Name = "slack", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x", Enabled = true };
|
||||
|
||||
@@ -94,7 +83,7 @@ public class PushChannelRoutingTests
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
push.Setup(p => p.SendWithResultAsync(It.IsAny<NotificationScope>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, push.Object);
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, push.Object);
|
||||
|
||||
var result = await dispatcher.SendTestAsync(PushChannel(), new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
@@ -109,7 +98,6 @@ public class PushChannelRoutingTests
|
||||
Assert.False(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "" }.HasDestination());
|
||||
Assert.True(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x" }.HasDestination());
|
||||
Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Push));
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Telegram));
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Email));
|
||||
Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Slack));
|
||||
}
|
||||
@@ -127,23 +115,23 @@ public class PushChannelRoutingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_telegram()
|
||||
public void Gate_leaves_a_push_channel_to_the_tenant_admin_but_not_its_change_into_email()
|
||||
{
|
||||
var json = Json(PushChannel());
|
||||
|
||||
// Add, disable, rename and toggle: all the tenant admin's own.
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(PushChannel(enabled: false)), json, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(json, existingJson: null, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(PushChannel(enabled: false)), json, isGlobalAdmin: false));
|
||||
var renamed = PushChannel();
|
||||
renamed.Name = "Phones";
|
||||
renamed.NotifyOnFailure = true;
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(renamed), json, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(renamed), json, isGlobalAdmin: false));
|
||||
|
||||
// The same channel id turned into Telegram still needs a Global Administrator.
|
||||
var retyped = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Telegram, Url = "-100123", Enabled = true };
|
||||
Assert.Equal("Telegram channels can only be configured by a Global Administrator.",
|
||||
TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(Json(retyped), json, isGlobalAdmin: true));
|
||||
// The same channel id turned into E-mail still needs a Global Administrator.
|
||||
var retyped = new NotificationChannel { Id = "push", Name = "push", ProviderType = (int)WebhookProviderType.Email, Url = "ops@example.invalid", Enabled = true };
|
||||
Assert.Equal("E-mail channels can only be configured by a Global Administrator.",
|
||||
TenantConfigValidation.ValidatePlatformProviderGate(Json(retyped), json, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidatePlatformProviderGate(Json(retyped), json, isGlobalAdmin: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -5,9 +5,9 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cover for the RebootCount range predicate used on every search path that does NOT push the
|
||||
/// bound to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>) and the legacy
|
||||
/// unpaged scan. Without it a <c>deviceProperties + rebootCountMin=5</c> query leaks sessions
|
||||
/// Cover for the RebootCount range predicate used on the search path that does NOT push the
|
||||
/// bound to OData — the deviceProperties batch-get (<c>ApplyBasicFilters</c>). Without it a
|
||||
/// <c>deviceProperties + rebootCountMin=5</c> query leaks sessions
|
||||
/// with RebootCount < 5 (the medium finding). Min is inclusive (">= many reboots").
|
||||
/// </summary>
|
||||
public class RebootCountFilterBoundsTests
|
||||
|
||||
@@ -56,10 +56,11 @@ public class ReportUploadGateTests
|
||||
var gate = CreateGate(limit: 1);
|
||||
var start = new DateTime(2026, 10, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Assert.True(gate.TakeAlert(start));
|
||||
Assert.False(gate.TakeAlert(start.AddMinutes(59)));
|
||||
Assert.True(gate.TakeAlert(start + ReportUploadGate.AlertWindow));
|
||||
Assert.False(gate.TakeAlert(start + ReportUploadGate.AlertWindow));
|
||||
var window = ReportUploadGate.AlertWindow;
|
||||
Assert.True(gate.TakeAlert(start, window));
|
||||
Assert.False(gate.TakeAlert(start.AddMinutes(59), window));
|
||||
Assert.True(gate.TakeAlert(start + window, window));
|
||||
Assert.False(gate.TakeAlert(start + window, window));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -255,7 +255,7 @@ public class ReportsWireParityTests
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetDistressReportsFunction — { success, count, reports }
|
||||
// GetDistressReportsFunction — { success, count, reports, truncated }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
@@ -287,13 +287,15 @@ public class ReportsWireParityTests
|
||||
{
|
||||
success = true,
|
||||
count = reports.Count,
|
||||
reports
|
||||
reports,
|
||||
truncated = false
|
||||
},
|
||||
new DistressReportListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = reports.Count,
|
||||
Reports = reports
|
||||
Reports = reports,
|
||||
Truncated = false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The evaluation budget of tenant-authored rules (D-410): the first regex match timeout stops the
|
||||
/// rule instead of costing one second per event, a spent run budget leaves the later rules
|
||||
/// unevaluated, every scan without an operator call is checked too, a rule that did not finish keeps
|
||||
/// its stored row, and the dry-run reports the stop as verdict "stopped".
|
||||
/// </summary>
|
||||
public class RuleEvaluationBudgetTests
|
||||
{
|
||||
private const string TenantId = "tenant-budget";
|
||||
private const string SessionId = "session-budget";
|
||||
|
||||
/// <summary>Nested quantifier over input it cannot match: catastrophic backtracking, ends at the match timeout (2 s).</summary>
|
||||
private const string CatastrophicPattern = @"^(\w+\s?)+$";
|
||||
private static readonly string NonMatchingInput = new string('a', 60) + "!";
|
||||
|
||||
// ===== analysis run =====
|
||||
|
||||
[Fact]
|
||||
public async Task Regex_timeout_stops_the_rule_after_one_match_and_the_next_rule_still_runs()
|
||||
{
|
||||
var events = Enumerable.Range(1, 200).Select(i => Event("app_install_failed", i, ("errorDetail", NonMatchingInput))).ToList();
|
||||
var redos = CustomRule("TEST-REDOS", new RuleCondition
|
||||
{
|
||||
Signal = "slow", Source = "event_data", EventType = "app_install_failed",
|
||||
DataField = "errorDetail", Operator = "regex", Value = CatastrophicPattern, Required = true,
|
||||
});
|
||||
var next = CustomRule("TEST-NEXT", ExistsCondition("app_install_failed"));
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
var outcome = await Harness(events, redos, next).Engine().AnalyzeSessionAsync(TenantId, SessionId);
|
||||
clock.Stop();
|
||||
|
||||
// One timed-out match (2 s), not 200 of them.
|
||||
Assert.True(clock.ElapsedMilliseconds < 5000, $"took {clock.ElapsedMilliseconds} ms");
|
||||
var stop = Assert.Single(outcome.Stops);
|
||||
Assert.Equal(new RuleEvaluationStop("TEST-REDOS", EvaluationStopReasons.RegexTimeout, IsCatalogRule: false, EndsRun: false), stop);
|
||||
Assert.Empty(outcome.SkippedRuleIds);
|
||||
Assert.Equal(new[] { "TEST-NEXT" }, outcome.Results.Select(r => r.RuleId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Spent_budget_stops_the_run_keeps_every_unfinished_row_and_resolves_nothing()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { Event("app_install_failed", 1, ("appId", "app-1")) };
|
||||
var first = CustomRule("TEST-A", new RuleCondition
|
||||
{
|
||||
Signal = "a", Source = "event_data", EventType = "app_install_failed",
|
||||
DataField = "appId", Operator = "equals", Value = "app-1", Required = true,
|
||||
});
|
||||
var second = CustomRule("TEST-B", ExistsCondition("app_install_failed"));
|
||||
var third = CustomRule("TEST-C", ExistsCondition("app_install_failed"));
|
||||
var interimA = StoredRow("TEST-A", isInterim: true);
|
||||
var interimB = StoredRow("TEST-B", isInterim: true);
|
||||
|
||||
var harness = Harness(events, first, second, third);
|
||||
harness.ExistingResults.AddRange(new[] { interimA, interimB });
|
||||
var engine = harness.Engine(TimeSpan.Zero);
|
||||
|
||||
var outcome = await engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
|
||||
|
||||
var stop = Assert.Single(outcome.Stops);
|
||||
Assert.Equal("TEST-A", stop.RuleId);
|
||||
Assert.Equal(EvaluationStopReasons.TimeBudget, stop.Reason);
|
||||
Assert.True(stop.EndsRun);
|
||||
Assert.Equal(new[] { "TEST-B", "TEST-C" }, outcome.SkippedRuleIds);
|
||||
Assert.Empty(outcome.Results);
|
||||
Assert.Empty(outcome.ResolvedResults); // a stop is no evidence that a finding went away
|
||||
Assert.Equal(new[] { interimA, interimB }, outcome.KeptResults);
|
||||
Assert.Null(interimA.ResolvedAt);
|
||||
Assert.True(interimA.IsInterim);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rule_that_spends_its_share_stops_alone_and_the_next_rule_still_runs()
|
||||
{
|
||||
// One expensive rule must not use up the run for every rule after it (a device can make
|
||||
// catalog rules slow by flooding its own session): its share ends, the run goes on.
|
||||
var events = new List<EnrollmentEvent> { Event("app_install_failed", 1, ("appId", "app-1")) };
|
||||
var expensive = CustomRule("TEST-EXPENSIVE", new RuleCondition
|
||||
{
|
||||
Signal = "a", Source = "event_data", EventType = "app_install_failed",
|
||||
DataField = "appId", Operator = "equals", Value = "app-1", Required = true,
|
||||
});
|
||||
var next = CustomRule("TEST-NEXT", ExistsCondition("app_install_failed"));
|
||||
|
||||
var outcome = await Harness(events, expensive, next).Engine(ruleBudget: TimeSpan.Zero)
|
||||
.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
|
||||
var stop = Assert.Single(outcome.Stops);
|
||||
Assert.Equal(new RuleEvaluationStop("TEST-EXPENSIVE", EvaluationStopReasons.TimeBudget, IsCatalogRule: false, EndsRun: false), stop);
|
||||
Assert.Empty(outcome.SkippedRuleIds);
|
||||
Assert.Equal(new[] { "TEST-NEXT" }, outcome.Results.Select(r => r.RuleId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Each of these evaluators scans the session per matching event without calling an operator
|
||||
/// (quadratic in the session size); without its own check a spent budget would not stop it, and
|
||||
/// the rule would simply be evaluated.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("event_correlation pairing")]
|
||||
[InlineData("suppressByEvent scan")]
|
||||
[InlineData("app_install_duration start search")]
|
||||
public async Task Scans_without_an_operator_call_check_the_budget_too(string scan)
|
||||
{
|
||||
var (condition, events) = scan switch
|
||||
{
|
||||
"event_correlation pairing" => (
|
||||
new RuleCondition
|
||||
{
|
||||
Signal = "pair", Source = "event_correlation", EventType = "app_install_started",
|
||||
CorrelateEventType = "app_install_failed", JoinField = "appId", Required = true,
|
||||
},
|
||||
new[] { Event("app_install_started", 1, ("appId", "x")), Event("app_install_failed", 2, ("appId", "x")) }),
|
||||
"suppressByEvent scan" => (
|
||||
new RuleCondition
|
||||
{
|
||||
Signal = "failed", Source = "event_type", EventType = "app_install_failed", Operator = "exists", Required = true,
|
||||
SuppressByEvent = new SuppressByEventConfig { EventType = "app_install_completed", JoinField = "appId" },
|
||||
},
|
||||
new[] { Event("app_install_failed", 1, ("appId", "x")) }),
|
||||
_ => (
|
||||
new RuleCondition { Signal = "slow", Source = "app_install_duration", Operator = "gt", Value = "0", Required = true },
|
||||
new[] { Event("app_install_completed", 1, ("appId", "x")) }),
|
||||
};
|
||||
|
||||
var outcome = await Harness(events.ToList(), CustomRule("TEST-SCAN", condition)).Engine(TimeSpan.Zero)
|
||||
.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
|
||||
var stop = Assert.Single(outcome.Stops);
|
||||
Assert.Equal(EvaluationStopReasons.TimeBudget, stop.Reason);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
// ===== dry-run =====
|
||||
|
||||
[Fact]
|
||||
public void Dry_run_regex_timeout_ends_with_verdict_stopped_and_a_note_naming_the_condition()
|
||||
{
|
||||
var rule = CustomRule("TEST-DRY",
|
||||
ExistsCondition("app_install_failed"),
|
||||
new RuleCondition
|
||||
{
|
||||
Signal = "slow", Source = "event_data", EventType = "app_install_failed",
|
||||
DataField = "errorDetail", Operator = "regex", Value = CatastrophicPattern, Required = true,
|
||||
});
|
||||
var events = Enumerable.Range(1, 50).Select(i => Event("app_install_failed", i, ("errorDetail", NonMatchingInput))).ToList();
|
||||
|
||||
var dry = Harness(events).Engine().DryRunEvaluate(rule, events, new RuleEvaluationBudget(RuleEvaluationBudget.DryRun));
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.Stopped, dry.Verdict);
|
||||
Assert.Equal(EvaluationStopReasons.RegexTimeout, dry.StopReason);
|
||||
Assert.StartsWith("conditions[1] ('slow'): a regex hit the 2 s match timeout", dry.StopNote);
|
||||
// Only what finished before the stop; no half-evaluated evidence map.
|
||||
Assert.Equal(new[] { "exists_app_install_failed" }, dry.Conditions.Select(c => c.Signal));
|
||||
Assert.Null(dry.MatchedConditions);
|
||||
Assert.Null(dry.FinalConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dry_run_spent_budget_stops_at_the_first_operator()
|
||||
{
|
||||
var rule = CustomRule("TEST-DRY", ExistsCondition("app_install_failed"));
|
||||
rule.Preconditions = new List<RulePrecondition>
|
||||
{
|
||||
new() { Source = "event_data", EventType = "os_info", DataField = "edition", Operator = "equals", Value = "Enterprise" },
|
||||
};
|
||||
var events = new List<EnrollmentEvent> { Event("os_info", 1, ("edition", "Enterprise")) };
|
||||
|
||||
var dry = Harness(events).Engine().DryRunEvaluate(rule, events, new RuleEvaluationBudget(TimeSpan.Zero));
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.Stopped, dry.Verdict);
|
||||
Assert.Equal(EvaluationStopReasons.TimeBudget, dry.StopReason);
|
||||
Assert.StartsWith("preconditions[0] (os_info): the dry-run used up its 0 s time budget on this session's 1 events", dry.StopNote);
|
||||
Assert.Empty(dry.Preconditions);
|
||||
Assert.Empty(dry.Conditions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dry_run_of_a_caller_who_went_away_stops_as_cancelled()
|
||||
{
|
||||
var events = new List<EnrollmentEvent> { Event("app_install_failed", 1, ("appId", "x")) };
|
||||
var rule = CustomRule("TEST-DRY", new RuleCondition
|
||||
{
|
||||
Signal = "a", Source = "event_data", EventType = "app_install_failed", DataField = "appId", Operator = "equals", Value = "x", Required = true,
|
||||
});
|
||||
using var gone = new CancellationTokenSource();
|
||||
gone.Cancel();
|
||||
|
||||
var dry = await Harness(events).Engine().DryRunRuleAsync(TenantId, SessionId, rule, gone.Token);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.Stopped, dry.Verdict);
|
||||
Assert.Equal(EvaluationStopReasons.Cancelled, dry.StopReason);
|
||||
}
|
||||
|
||||
// ===== "Analyze now" =====
|
||||
|
||||
[Fact]
|
||||
public async Task Reanalyze_with_a_full_gate_answers_429_and_raises_one_ops_event()
|
||||
{
|
||||
var opsEvents = new List<OpsEventEntry>();
|
||||
var harness = Harness(new List<EnrollmentEvent>());
|
||||
var gate = new ReanalyzeGate(1, TestNotifications.CapturingOpsEvents(opsEvents), NullLogger<ReanalyzeGate>.Instance);
|
||||
using var busy = gate.TryEnter();
|
||||
var function = harness.Function(gate, opsEvents);
|
||||
|
||||
var (req, _) = EndpointHarness.Request(TenantId, queryString: "?reanalyze=true");
|
||||
var response = await function.Run(req, SessionId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.RateLimited, EndpointHarness.ErrorCode(response));
|
||||
Assert.True(response.Headers.TryGetValues("Retry-After", out var retryAfter));
|
||||
Assert.Equal("5", retryAfter!.Single());
|
||||
var alert = Assert.Single(opsEvents);
|
||||
Assert.Equal(OpsEventTypes.RuleEvaluationLimitReached, alert.EventType);
|
||||
Assert.Contains("re-analysis", alert.Details);
|
||||
harness.Maintenance.Verify(m => m.DeleteSessionRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reanalyze_writes_back_the_row_of_a_stopped_rule_and_names_it()
|
||||
{
|
||||
var events = Enumerable.Range(1, 20).Select(i => Event("app_install_failed", i, ("errorDetail", NonMatchingInput))).ToList();
|
||||
var redos = CustomRule("TEST-REDOS", new RuleCondition
|
||||
{
|
||||
Signal = "slow", Source = "event_data", EventType = "app_install_failed",
|
||||
DataField = "errorDetail", Operator = "regex", Value = CatastrophicPattern, Required = true,
|
||||
});
|
||||
var earlierFinding = StoredRow("TEST-REDOS", isInterim: false);
|
||||
var harness = Harness(events, redos);
|
||||
harness.ExistingResults.Add(earlierFinding);
|
||||
var opsEvents = new List<OpsEventEntry>();
|
||||
var function = harness.Function(
|
||||
new ReanalyzeGate(TestNotifications.CapturingOpsEvents(opsEvents), NullLogger<ReanalyzeGate>.Instance), opsEvents);
|
||||
|
||||
var (req, _) = EndpointHarness.Request(TenantId, queryString: "?reanalyze=true");
|
||||
var response = await function.Run(req, SessionId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
harness.Maintenance.Verify(m => m.DeleteSessionRuleResultsAsync(TenantId, SessionId), Times.Once);
|
||||
harness.Rules.Verify(r => r.StoreRuleResultAsync(earlierFinding), Times.Once);
|
||||
|
||||
response.Body.Position = 0;
|
||||
using var body = JsonDocument.Parse(response.Body);
|
||||
Assert.Equal(new[] { "TEST-REDOS" }, body.RootElement.GetProperty("stoppedRuleIds").EnumerateArray().Select(e => e.GetString()));
|
||||
|
||||
var stopped = Assert.Single(opsEvents);
|
||||
Assert.Equal(OpsEventTypes.RuleEvaluationStopped, stopped.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, stopped.Severity);
|
||||
}
|
||||
|
||||
// ===== fixtures =====
|
||||
|
||||
private static RuleCondition ExistsCondition(string eventType) => new()
|
||||
{
|
||||
Signal = "exists_" + eventType, Source = "event_type", EventType = eventType, Operator = "exists", Required = true,
|
||||
};
|
||||
|
||||
private static AnalyzeRule CustomRule(string ruleId, params RuleCondition[] conditions) => new()
|
||||
{
|
||||
RuleId = ruleId,
|
||||
IsBuiltIn = false, // the model defaults to a built-in rule
|
||||
Title = ruleId,
|
||||
Severity = "warning",
|
||||
Category = "test",
|
||||
Enabled = true,
|
||||
BaseConfidence = 80,
|
||||
ConfidenceThreshold = 40,
|
||||
Conditions = conditions.ToList(),
|
||||
Explanation = "test",
|
||||
};
|
||||
|
||||
private static RuleResult StoredRow(string ruleId, bool isInterim) => new()
|
||||
{
|
||||
RuleId = ruleId,
|
||||
ResultId = "result-" + ruleId,
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
IsInterim = isInterim,
|
||||
DetectedAt = DateTime.UtcNow.AddMinutes(-30),
|
||||
};
|
||||
|
||||
private static EnrollmentEvent Event(string eventType, int sequence, params (string Key, object Value)[] data) => new()
|
||||
{
|
||||
EventId = $"evt-{sequence}",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = eventType,
|
||||
Sequence = sequence,
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(sequence),
|
||||
Data = data.ToDictionary(d => d.Key, d => d.Value),
|
||||
};
|
||||
|
||||
private static RuleHarness Harness(List<EnrollmentEvent> events, params AnalyzeRule[] customRules) => new(events, customRules);
|
||||
|
||||
/// <summary>The tenant's custom rules behind a real AnalyzeRuleService, mocked storage.</summary>
|
||||
private sealed class RuleHarness
|
||||
{
|
||||
public Mock<IRuleRepository> Rules { get; } = new();
|
||||
public Mock<ISessionRepository> Sessions { get; } = new();
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public List<RuleResult> ExistingResults { get; } = new();
|
||||
|
||||
public RuleHarness(List<EnrollmentEvent> events, AnalyzeRule[] customRules)
|
||||
{
|
||||
Rules.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule>());
|
||||
Rules.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(customRules.ToList());
|
||||
Rules.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
Rules.Setup(r => r.GetRuleResultsAsync(TenantId, SessionId)).ReturnsAsync(() => ExistingResults.ToList());
|
||||
Rules.Setup(r => r.StoreRuleResultAsync(It.IsAny<RuleResult>())).ReturnsAsync(true);
|
||||
Sessions.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
}
|
||||
|
||||
private AnalyzeRuleService Service() => new(Rules.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
public RuleEngine Engine(TimeSpan? runBudget = null, TimeSpan? ruleBudget = null) =>
|
||||
new(Service(), Rules.Object, Sessions.Object, NullLogger<RuleEvaluationBudgetTests>.Instance)
|
||||
{
|
||||
RunBudget = runBudget ?? RuleEvaluationBudget.AnalysisRun,
|
||||
RuleBudget = ruleBudget ?? RuleEvaluationBudget.AnalysisRule,
|
||||
};
|
||||
|
||||
public GetRuleResultsFunction Function(ReanalyzeGate gate, List<OpsEventEntry> opsEvents) =>
|
||||
new(NullLogger<GetRuleResultsFunction>.Instance, Rules.Object, Maintenance.Object, Sessions.Object, Service(),
|
||||
gate, new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(opsEvents)));
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public class RuleEvidenceKeysTests
|
||||
Required = true,
|
||||
};
|
||||
|
||||
var dry = Engine().DryRunEvaluate(Rule(condition), Events());
|
||||
var dry = Engine().DryRunEvaluate(Rule(condition), Events(), new RuleEvaluationBudget(RuleEvaluationBudget.DryRun));
|
||||
|
||||
var matched = Assert.IsAssignableFrom<IDictionary<string, object>>(dry.MatchedConditions);
|
||||
Assert.True(matched.ContainsKey("sig"), $"{source}/{op} did not match the fixture");
|
||||
|
||||
@@ -344,6 +344,11 @@ public class RulePresentationTests
|
||||
Assert.Equal(RulePresentationLimits.TemplateMaxLength, fact.GetProperty("value").GetProperty("maxLength").GetInt32());
|
||||
Assert.Equal(RulePresentationLimits.TemplateMaxLength, subject.GetProperty("key").GetProperty("maxLength").GetInt32());
|
||||
Assert.Equal(RulePresentationLimits.TemplateMaxLength, subject.GetProperty("name").GetProperty("maxLength").GetInt32());
|
||||
|
||||
// The rule-shape counts (D-410): the schema lets validate_rule refuse what the backend refuses.
|
||||
var analyzeRule = defs.GetProperty("analyzeRule").GetProperty("properties");
|
||||
Assert.Equal(AnalyzeRuleLimits.MaxConditions, analyzeRule.GetProperty("conditions").GetProperty("maxItems").GetInt32());
|
||||
Assert.Equal(AnalyzeRuleLimits.MaxPreconditions, analyzeRule.GetProperty("preconditions").GetProperty("maxItems").GetInt32());
|
||||
}
|
||||
|
||||
private static string[] Strings(JsonElement array) => array.EnumerateArray().Select(e => e.GetString()!).ToArray();
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The per-instance caps on synchronous rule work (D-379, D-410) and the ops events behind them: a
|
||||
/// caller past a gate's limit gets 429 with a retry hint, the first refusal of a window raises one
|
||||
/// RuleEvaluationLimitReached naming the gate, and stopped rules reach the operators through a
|
||||
/// reporter that a tenant's rule count cannot turn into an alarm storm.
|
||||
/// </summary>
|
||||
public class RuleWorkGateTests
|
||||
{
|
||||
private const string Upn = "admin@example.com";
|
||||
|
||||
private readonly List<OpsEventEntry> _opsEvents = new();
|
||||
|
||||
[Theory]
|
||||
[InlineData("dry-run", 5)]
|
||||
[InlineData("re-analysis", 5)]
|
||||
[InlineData("pattern-test", 3)]
|
||||
public async Task Caller_past_the_limit_gets_429_with_a_retry_hint_and_one_ops_event_per_window(string gateName, int retryAfter)
|
||||
{
|
||||
var gate = Gate(gateName, limit: 1);
|
||||
using var busy = gate.TryEnter();
|
||||
Assert.NotNull(busy);
|
||||
Assert.Null(gate.TryEnter());
|
||||
|
||||
var (req, _) = EndpointHarness.Request("tenant-a", Upn);
|
||||
var refused = await gate.RefuseAsync(req, "tenant-a", Upn);
|
||||
await gate.RefuseAsync(req, "tenant-a", Upn);
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.RateLimited, EndpointHarness.ErrorCode(refused));
|
||||
Assert.True(refused.Headers.TryGetValues("Retry-After", out var header));
|
||||
Assert.Equal(retryAfter.ToString(), header!.Single());
|
||||
// The situation only: the portal and the MCP phrase the retry from Retry-After.
|
||||
Assert.EndsWith("running right now.", ErrorText(refused));
|
||||
|
||||
var alert = Assert.Single(_opsEvents);
|
||||
Assert.Equal(OpsEventTypes.RuleEvaluationLimitReached, alert.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, alert.Severity);
|
||||
Assert.Equal("tenant-a", alert.TenantId);
|
||||
using var details = JsonDocument.Parse(alert.Details!);
|
||||
Assert.Equal(gateName, details.RootElement.GetProperty("gate").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_limits_never_refuse_a_person()
|
||||
{
|
||||
var ops = TestNotifications.CapturingOpsEvents(_opsEvents);
|
||||
// One above the measured peak: 3 parallel dry-runs from one AI client on one instance.
|
||||
Assert.Equal(4, new RuleDryRunGate(ops, NullLogger<RuleDryRunGate>.Instance).Limit);
|
||||
Assert.Equal(5, new ReanalyzeGate(ops, NullLogger<ReanalyzeGate>.Instance).Limit);
|
||||
}
|
||||
|
||||
// ===== RuleEvaluationStopReporter =====
|
||||
|
||||
[Fact]
|
||||
public async Task A_stopped_custom_rule_is_a_warning_and_a_stopped_catalog_rule_an_error()
|
||||
{
|
||||
var reporter = new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(_opsEvents));
|
||||
var outcome = new AnalysisOutcome();
|
||||
outcome.Stops.Add(new RuleEvaluationStop("CUSTOM-1", EvaluationStopReasons.RegexTimeout, IsCatalogRule: false, EndsRun: false));
|
||||
outcome.Stops.Add(new RuleEvaluationStop("ANALYZE-APP-002", EvaluationStopReasons.TimeBudget, IsCatalogRule: true, EndsRun: true));
|
||||
outcome.SkippedRuleIds.AddRange(new[] { "CUSTOM-2", "CUSTOM-3" });
|
||||
|
||||
await reporter.ReportAsync("tenant-a", "session-1", AnalyzeRunKind.Terminal, outcome);
|
||||
|
||||
Assert.Equal(2, _opsEvents.Count);
|
||||
Assert.All(_opsEvents, e => Assert.Equal(OpsEventTypes.RuleEvaluationStopped, e.EventType));
|
||||
Assert.Equal(OpsEventSeverity.Warning, _opsEvents[0].Severity);
|
||||
Assert.Equal(OpsEventSeverity.Error, _opsEvents[1].Severity);
|
||||
using var budgetStop = JsonDocument.Parse(_opsEvents[1].Details!);
|
||||
Assert.Equal(2, budgetStop.RootElement.GetProperty("skippedRules").GetInt32());
|
||||
using var regexStop = JsonDocument.Parse(_opsEvents[0].Details!);
|
||||
Assert.Equal(0, regexStop.RootElement.GetProperty("skippedRules").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_event_per_tenant_and_rule_per_window()
|
||||
{
|
||||
var reporter = new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(_opsEvents));
|
||||
var start = new DateTime(2026, 11, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Assert.True(reporter.Take("tenant-a", "CUSTOM-1", isCatalogRule: false, start));
|
||||
Assert.False(reporter.Take("tenant-a", "CUSTOM-1", isCatalogRule: false, start.AddMinutes(59)));
|
||||
Assert.True(reporter.Take("tenant-b", "CUSTOM-1", isCatalogRule: false, start.AddMinutes(1)));
|
||||
Assert.True(reporter.Take("tenant-a", "CUSTOM-1", isCatalogRule: false, start + RuleEvaluationStopReporter.Window));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tenant_with_many_rules_cannot_flood_the_alarm_or_silence_others()
|
||||
{
|
||||
var reporter = new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(_opsEvents));
|
||||
var start = new DateTime(2026, 11, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var noisyTenant = Enumerable.Range(0, 1000).Count(i => reporter.Take("tenant-noisy", $"CUSTOM-{i}", false, start.AddSeconds(i)));
|
||||
Assert.Equal(RuleEvaluationStopReporter.MaxCustomEventsPerTenant, noisyTenant);
|
||||
|
||||
// Another tenant and the catalog keep their own room.
|
||||
Assert.True(reporter.Take("tenant-quiet", "CUSTOM-1", false, start.AddMinutes(20)));
|
||||
Assert.True(reporter.Take("tenant-noisy", "ANALYZE-SEC-005", isCatalogRule: true, start.AddMinutes(21)));
|
||||
|
||||
// A new window starts over.
|
||||
Assert.True(reporter.Take("tenant-noisy", "CUSTOM-new", false, start + RuleEvaluationStopReporter.Window));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_rule_stops_of_all_tenants_never_take_the_catalog_slots()
|
||||
{
|
||||
var reporter = new RuleEvaluationStopReporter(TestNotifications.CapturingOpsEvents(_opsEvents));
|
||||
var start = new DateTime(2026, 11, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var custom = Enumerable.Range(0, 100).Count(t =>
|
||||
Enumerable.Range(0, 10).Count(r => reporter.Take($"tenant-{t}", $"CUSTOM-{r}", false, start)) > 0);
|
||||
var catalog = Enumerable.Range(0, 100).Count(r => reporter.Take("tenant-0", $"ANALYZE-X-{r:D3}", true, start));
|
||||
|
||||
Assert.Equal(RuleEvaluationStopReporter.MaxCustomEventsPerWindow / RuleEvaluationStopReporter.MaxCustomEventsPerTenant, custom);
|
||||
Assert.Equal(RuleEvaluationStopReporter.MaxCatalogEventsPerWindow, catalog);
|
||||
}
|
||||
|
||||
private RuleWorkGate Gate(string name, int limit)
|
||||
{
|
||||
var ops = TestNotifications.CapturingOpsEvents(_opsEvents);
|
||||
return name switch
|
||||
{
|
||||
"dry-run" => new RuleDryRunGate(limit, ops, NullLogger<RuleDryRunGate>.Instance),
|
||||
"re-analysis" => new ReanalyzeGate(limit, ops, NullLogger<ReanalyzeGate>.Instance),
|
||||
_ => new TestLogPatternGate(limit, ops, NullLogger<TestLogPatternGate>.Instance),
|
||||
};
|
||||
}
|
||||
|
||||
private static string ErrorText(Microsoft.Azure.Functions.Worker.Http.HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
return doc.RootElement.GetProperty("error").GetString()!;
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,26 @@ public class RulesWireParityTests
|
||||
new DryRunAnalyzeRuleResponse { Success = true, SessionId = sessionId, Result = result });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DryRunAnalyzeRuleResponse_carries_a_stop_with_its_reason_and_note()
|
||||
{
|
||||
var sessionId = "7c1e2a90-1111-4b7c-a1d2-52f3bbbb0006";
|
||||
var result = new RuleDryRun
|
||||
{
|
||||
Verdict = RuleDryRunVerdict.Stopped,
|
||||
EventCount = 4200,
|
||||
BaseConfidence = 50,
|
||||
ConfidenceThreshold = 60,
|
||||
// D-410: verdict "stopped" — the trace keeps only the entries evaluated before the stop.
|
||||
StopReason = EvaluationStopReasons.RegexTimeout,
|
||||
StopNote = "conditions[0] ('slow'): a regex hit the 2 s match timeout on this session's data",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessionId, result },
|
||||
new DryRunAnalyzeRuleResponse { Success = true, SessionId = sessionId, Result = result });
|
||||
}
|
||||
|
||||
// ---- GetRuleResults ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
@@ -178,6 +198,8 @@ public class RulesWireParityTests
|
||||
};
|
||||
var openResults = results.Where(r => r.ResolvedAt == null).ToList();
|
||||
var persistFailureRuleIds = new List<string> { "ANALYZE-NET-001" };
|
||||
// D-410: rules the reanalyze stopped at its evaluation budget, omitted when there are none.
|
||||
var stoppedRuleIds = new List<string> { "CUSTOM-SLOW-001" };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
@@ -190,7 +212,8 @@ public class RulesWireParityTests
|
||||
highCount = openResults.Count(r => r.Severity == "high"),
|
||||
warningCount = openResults.Count(r => r.Severity == "warning"),
|
||||
persistFailureCount = persistFailureRuleIds.Count,
|
||||
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
|
||||
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null,
|
||||
stoppedRuleIds = stoppedRuleIds.Count > 0 ? stoppedRuleIds : null
|
||||
},
|
||||
new GetRuleResultsResponse
|
||||
{
|
||||
@@ -202,7 +225,8 @@ public class RulesWireParityTests
|
||||
HighCount = openResults.Count(r => r.Severity == "high"),
|
||||
WarningCount = openResults.Count(r => r.Severity == "warning"),
|
||||
PersistFailureCount = persistFailureRuleIds.Count,
|
||||
PersistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
|
||||
PersistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null,
|
||||
StoppedRuleIds = stoppedRuleIds.Count > 0 ? stoppedRuleIds : null
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -126,20 +126,21 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
[Fact]
|
||||
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
|
||||
{
|
||||
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
|
||||
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
|
||||
// sleep outlasting a fixed wall-clock threshold.
|
||||
// Deterministic: the body blocks until BOTH watchdogs have recorded their OpsEvents. The two
|
||||
// delays are independent timers, so under load the severe one can complete first; the cancel
|
||||
// in the finally would then drop a warn delay whose callback had not run yet.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
|
||||
harness.SetKillSwitch(false);
|
||||
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
|
||||
{
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
|
||||
});
|
||||
|
||||
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
|
||||
|
||||
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
|
||||
// Both watchdogs fired while the body was still running.
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ public class SessionDeletionWorkerTests
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
// PR-B audit consolidation: deletion_poisoned audit moved to SessionDeletionPoisoned
|
||||
// OpsEvent (operator-bound, Telegram-routable). Tenant-audit row is no longer written.
|
||||
// OpsEvent (operator-bound, routable through alert rules). Tenant-audit row is no longer written.
|
||||
var poisoned = Assert.Single(harness.OpsEventCalls, e => e.EventType == "SessionDeletionPoisoned");
|
||||
Assert.Equal(TenantId, poisoned.TenantId);
|
||||
Assert.Equal(SessionId, poisoned.SessionId);
|
||||
|
||||
@@ -29,6 +29,7 @@ public class SessionFreeTextSearchTests
|
||||
DeviceName = "DESKTOP-A1B2C3",
|
||||
SerialNumber = "5CG1234XYZ",
|
||||
Manufacturer = "Contoso",
|
||||
AutopilotGroupTag = "LAB-WEST",
|
||||
Model = "EliteBook 840",
|
||||
Status = SessionStatus.Failed,
|
||||
GeoCountry = "DE",
|
||||
@@ -178,6 +179,7 @@ public class SessionFreeTextSearchTests
|
||||
DeviceName = F("deviceName"),
|
||||
SerialNumber = F("serialNumber"),
|
||||
Manufacturer = F("manufacturer"),
|
||||
AutopilotGroupTag = F("autopilotGroupTag"),
|
||||
Model = F("model"),
|
||||
Status = Enum.Parse<SessionStatus>(F("status")),
|
||||
SessionId = F("sessionId"),
|
||||
|
||||
@@ -83,6 +83,11 @@ public class SessionIndexFieldManifestTests
|
||||
["EspSoftFailure"] = true,
|
||||
["CompletionSource"] = "esp_exit",
|
||||
["ValidatedBy"] = "AutopilotV1",
|
||||
["AutopilotDeviceId"] = "ap-1",
|
||||
["AutopilotGroupTag"] = "TEST",
|
||||
["AutopilotPurchaseOrderId"] = "PO-7",
|
||||
["AutopilotDisplayName"] = "LAB-01",
|
||||
["AutopilotUserAssigned"] = true,
|
||||
["FailureSnapshotJson"] = "{\"x\":1}",
|
||||
["AdminMarkedAction"] = "failed",
|
||||
["VerdictPath"] = "sweep:r5_incomplete",
|
||||
|
||||
@@ -152,6 +152,10 @@ public sealed class SharedManifestParityTests
|
||||
["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||
// Evidence keys per condition source: what a {{signal.key}} token can resolve (MCP rule lint).
|
||||
["analyzeRuleEvidenceKeys"] = RuleEvidenceKeys.BySource,
|
||||
// Tenant activity (GET global/metrics/tenant-activity): the lifecycle stages in their test order —
|
||||
// the MCP tool's stage filter enumerates them — and the edition names the portal's plan facet uses.
|
||||
["tenantActivityStages"] = TenantActivityStages.All,
|
||||
["tenantEditions"] = TenantEditionNames.All,
|
||||
// Vocabularies of the diagnosis headline (GET sessions/{id}/diagnosis): the portal's verdict
|
||||
// texts and culprit labels key on the same values the builder writes.
|
||||
["diagnosis"] = new Dictionary<string, object?>
|
||||
|
||||
@@ -1013,7 +1013,7 @@ public class SlaTenantStatusLifecycleTests
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var webhook = new WebhookNotificationService(new HttpClient());
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance), Mock.Of<IPushChannelSender>());
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, Mock.Of<IPushChannelSender>());
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
|
||||
+224
-12
@@ -60,7 +60,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
DeviceName = "PC-1",
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
@@ -95,7 +95,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
DeviceName = "PC-1",
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
@@ -134,7 +134,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
IsSelfDeployingProfile = false, // fresh read failed / degraded
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
|
||||
@@ -166,7 +166,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
IsSelfDeployingProfile = true,
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
|
||||
@@ -199,7 +199,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
IsCloudPc = false, // fresh marker read degraded
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
|
||||
@@ -233,7 +233,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
EnrollmentType = "v1", // degraded registry read on restart
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
|
||||
@@ -265,7 +265,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
EnrollmentType = "v2",
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
|
||||
@@ -295,7 +295,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
IsCloudPc = true,
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
var ok = await harness.Store(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
|
||||
@@ -327,7 +327,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
@@ -358,7 +358,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var boundAt = new DateTime(2026, 1, 1, 9, 0, 1, DateTimeKind.Utc);
|
||||
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
@@ -401,7 +401,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
.ReturnsAsync(Response.FromValue(initial, new Mock<Response>().Object))
|
||||
.ReturnsAsync(Response.FromValue(fresh, new Mock<Response>().Object));
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 3, 8, 0, 0, DateTimeKind.Utc),
|
||||
@@ -419,7 +419,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
public async Task StoreSessionAsync_fresh_row_carries_the_stamped_owner()
|
||||
{
|
||||
var harness = new Harness(existing: null);
|
||||
var ok = await harness.Sut.StoreSessionAsync(new SessionRegistration
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
@@ -431,6 +431,214 @@ public class StoreSessionReregistrationPreserveTests
|
||||
Assert.False(harness.Written.ContainsKey("OwnerThumbprint"));
|
||||
}
|
||||
|
||||
// ── Carry-over of the columns the registration does not own ─────────────
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_carries_every_declared_column_it_does_not_own()
|
||||
{
|
||||
// Driven by the manifest: a column added to SessionIndexFieldManifest later is carried
|
||||
// through an agent restart without anyone touching StoreSessionAsync. Merge-written columns
|
||||
// such as ImeAgentVersion, LastIngestAt, ExcessiveEventsAlerted, Block* and the pending
|
||||
// server actions used to be dropped by the Replace.
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
};
|
||||
foreach (var column in TableStorageService.CarriedColumns)
|
||||
existing[column] = $"carried:{column}";
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Contains("ImeAgentVersion", TableStorageService.CarriedColumns);
|
||||
Assert.Contains("PendingActionsJson", TableStorageService.CarriedColumns);
|
||||
foreach (var column in TableStorageService.CarriedColumns)
|
||||
Assert.Equal($"carried:{column}", harness.Written![column]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegistrationOwnedColumns_name_only_declared_columns()
|
||||
{
|
||||
// A typo in the owned list would silently turn a column into a carried one.
|
||||
var declared = SessionIndexFieldManifest.All
|
||||
.Concat(SessionIndexFieldManifest.PrimaryOnly)
|
||||
.Append("IndexRowKey") // Sessions-only bookkeeping, deliberately outside the index manifest
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
Assert.All(TableStorageService.RegistrationOwnedColumns, column => Assert.Contains(column, declared));
|
||||
Assert.Equal(
|
||||
TableStorageService.RegistrationOwnedColumns.Length,
|
||||
TableStorageService.RegistrationOwnedColumns.Distinct(StringComparer.Ordinal).Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_cas_retry_carries_the_values_of_its_own_fresh_read()
|
||||
{
|
||||
var startedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc));
|
||||
var initial = new TableEntity(TenantId, SessionId) { ["StartedAt"] = startedAt, ["Status"] = "InProgress" };
|
||||
initial.ETag = new ETag("0xINITIAL");
|
||||
var firstFresh = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = startedAt, ["Status"] = "InProgress",
|
||||
["ImeAgentVersion"] = "1.0", ["LastIngestAt"] = startedAt.AddMinutes(5),
|
||||
};
|
||||
firstFresh.ETag = new ETag("0xFRESH1");
|
||||
var secondFresh = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = startedAt, ["Status"] = "InProgress",
|
||||
["ImeAgentVersion"] = "2.0",
|
||||
};
|
||||
secondFresh.ETag = new ETag("0xFRESH2");
|
||||
|
||||
var harness = new Harness(initial);
|
||||
harness.Sessions.SetupSequence(t => t.GetEntityAsync<TableEntity>(
|
||||
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(initial, new Mock<Response>().Object))
|
||||
.ReturnsAsync(Response.FromValue(firstFresh, new Mock<Response>().Object))
|
||||
.ReturnsAsync(Response.FromValue(secondFresh, new Mock<Response>().Object));
|
||||
|
||||
var attempts = 0;
|
||||
TableEntity? written = null;
|
||||
harness.Sessions.Setup(t => t.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
|
||||
{
|
||||
if (++attempts == 1)
|
||||
return Task.FromException<Response>(new RequestFailedException(412, "ConditionNotMet"));
|
||||
written = e;
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(2, attempts);
|
||||
Assert.Equal("2.0", written!.GetString("ImeAgentVersion"));
|
||||
// Carried by the failed attempt, gone from the second fresh read: not carried again.
|
||||
Assert.False(written.ContainsKey("LastIngestAt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_does_not_carry_the_terminal_tuple_of_a_non_terminal_row()
|
||||
{
|
||||
// The terminal tuple is owned: it is re-stamped only while the fresh status is terminal.
|
||||
// A WhiteGlove Part 1 row resuming into Part 2 keeps the old behaviour of dropping it.
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "Pending",
|
||||
["CompletionSource"] = "whiteglove_part1",
|
||||
["FailureSnapshotJson"] = "{}",
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var ok = await harness.Store(new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 2, 9, 0, 0, DateTimeKind.Utc),
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("InProgress", harness.Written!.GetString("Status"));
|
||||
Assert.False(harness.Written.ContainsKey("CompletionSource"));
|
||||
Assert.False(harness.Written.ContainsKey("FailureSnapshotJson"));
|
||||
}
|
||||
|
||||
// ── Server facts: validator and Autopilot registration ──────────────────
|
||||
|
||||
private static SessionRegistration Reregistration() => new()
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, SerialNumber = "SN-1",
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
private static TableEntity ExistingRow(params (string Column, object Value)[] columns)
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
};
|
||||
foreach (var (column, value) in columns)
|
||||
row[column] = value;
|
||||
row.ETag = new ETag("0xEXISTING");
|
||||
return row;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_writes_the_autopilot_registration_of_a_new_session()
|
||||
{
|
||||
var harness = new Harness(existing: null);
|
||||
|
||||
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts
|
||||
{
|
||||
ValidatedBy = ValidatorType.AutopilotV1,
|
||||
AutopilotRegistration = new AutopilotRegistration
|
||||
{
|
||||
DeviceId = "ap-1", GroupTag = "TEST", PurchaseOrderId = "PO-7", DisplayName = "LAB-01", UserAssigned = true,
|
||||
},
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("AutopilotV1", harness.Written!.GetString("ValidatedBy"));
|
||||
Assert.Equal("ap-1", harness.Written.GetString("AutopilotDeviceId"));
|
||||
Assert.Equal("TEST", harness.Written.GetString("AutopilotGroupTag"));
|
||||
Assert.Equal("PO-7", harness.Written.GetString("AutopilotPurchaseOrderId"));
|
||||
Assert.Equal("LAB-01", harness.Written.GetString("AutopilotDisplayName"));
|
||||
Assert.True(harness.Written.GetBoolean("AutopilotUserAssigned"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_a_fresh_autopilot_registration_replaces_the_stored_one_whole()
|
||||
{
|
||||
// The group tag was removed in Intune between two agent starts: the column clears.
|
||||
var harness = new Harness(ExistingRow(
|
||||
("AutopilotDeviceId", "ap-1"), ("AutopilotGroupTag", "OLD"), ("AutopilotPurchaseOrderId", "PO-1"),
|
||||
("AutopilotDisplayName", "LAB-01"), ("AutopilotUserAssigned", true)));
|
||||
|
||||
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts
|
||||
{
|
||||
ValidatedBy = ValidatorType.AutopilotV1,
|
||||
AutopilotRegistration = new AutopilotRegistration { DeviceId = "ap-1", PurchaseOrderId = "PO-2" },
|
||||
});
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.False(harness.Written!.ContainsKey("AutopilotGroupTag"));
|
||||
Assert.False(harness.Written.ContainsKey("AutopilotDisplayName"));
|
||||
Assert.Equal("PO-2", harness.Written.GetString("AutopilotPurchaseOrderId"));
|
||||
Assert.False(harness.Written.GetBoolean("AutopilotUserAssigned"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_without_an_autopilot_registration_keeps_the_stored_one_and_the_validator()
|
||||
{
|
||||
// A re-registration another path admitted (or with validation switched off) proves nothing
|
||||
// about the Autopilot registration: the stored values and the recorded validator stay.
|
||||
var harness = new Harness(ExistingRow(
|
||||
("ValidatedBy", "AutopilotV1"), ("AutopilotDeviceId", "ap-1"), ("AutopilotGroupTag", "TEST"),
|
||||
("AutopilotUserAssigned", false)));
|
||||
|
||||
var ok = await harness.Store(Reregistration(), facts: new SessionServerFacts());
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("AutopilotV1", harness.Written!.GetString("ValidatedBy"));
|
||||
Assert.Equal("ap-1", harness.Written.GetString("AutopilotDeviceId"));
|
||||
Assert.Equal("TEST", harness.Written.GetString("AutopilotGroupTag"));
|
||||
Assert.False(harness.Written.GetBoolean("AutopilotUserAssigned"));
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Sessions { get; }
|
||||
@@ -483,5 +691,9 @@ public class StoreSessionReregistrationPreserveTests
|
||||
|
||||
Sut = new TableStorageService(mockServiceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Stores with no server facts unless a test hands them in.</summary>
|
||||
public Task<bool> Store(SessionRegistration registration, SessionOwner? owner = null, SessionServerFacts? facts = null)
|
||||
=> Sut.StoreSessionAsync(registration, facts ?? new SessionServerFacts(), owner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public class StoreSessionTenantLookupClaimTests
|
||||
{
|
||||
var harness = new Harness(existingOwner: null);
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new[] { "lookup-add", "session-write" }, harness.Order);
|
||||
@@ -54,7 +54,7 @@ public class StoreSessionTenantLookupClaimTests
|
||||
{
|
||||
var harness = new Harness(existingOwner: TenantA.ToUpperInvariant());
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Contains("session-write", harness.Order);
|
||||
@@ -67,7 +67,7 @@ public class StoreSessionTenantLookupClaimTests
|
||||
var harness = new Harness(existingOwner: TenantA);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SessionTenantConflictException>(
|
||||
() => harness.Sut.StoreSessionAsync(Registration(TenantB)));
|
||||
() => harness.Sut.StoreSessionAsync(Registration(TenantB), new SessionServerFacts()));
|
||||
|
||||
Assert.Equal(SessionId, ex.SessionId);
|
||||
Assert.Equal(TenantB, ex.RequestedTenantId);
|
||||
@@ -87,7 +87,7 @@ public class StoreSessionTenantLookupClaimTests
|
||||
harness.Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "ServerBusy"));
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA), new SessionServerFacts());
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.DoesNotContain("session-write", harness.Order);
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class SubmitGeneralFeedbackFunctionTests
|
||||
[Fact]
|
||||
public async Task TheAlert_CarriesNeitherTextNorSenderNorContactAddress()
|
||||
{
|
||||
// Channels bound to FeedbackReceived (Telegram, Slack, webhooks) receive message + details;
|
||||
// Channels bound to FeedbackReceived (Slack, webhooks, e-mail) receive message + details;
|
||||
// the feedback itself is read in the portal. Personal data must never ride along.
|
||||
var h = new Harness();
|
||||
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
|
||||
Assert.Equal("Pricing was too high for our use case.", h.Repo.PersistedOffboardingFeedback.Comment);
|
||||
Assert.NotNull(h.Repo.PersistedOffboardingFeedback.InteractedAt);
|
||||
|
||||
// OpsEvent emitted with the canonical type catalog-registered for Telegram-routing.
|
||||
// OpsEvent emitted with the canonical type catalog-registered for alert routing.
|
||||
var ops = Assert.Single(h.OpsEvents);
|
||||
Assert.Equal("OffboardingFeedbackReceived", ops.EventType);
|
||||
Assert.Equal(TenantId, ops.TenantId);
|
||||
@@ -283,7 +283,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
|
||||
|
||||
// Alert-dispatch dependencies are inert: empty AdminConfig service + bare HTTP
|
||||
// clients. The feedback path emits Info-severity so even if alerts ARE evaluated,
|
||||
// no Telegram/webhook fires for an unconfigured rule set.
|
||||
// no channel fires for an unconfigured rule set.
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ranking of <see cref="TableDistressReportRepository.KeepNewestAsync"/>: the table yields rows in
|
||||
/// tenant order, so a cap must keep the newest rows across tenants, never the first rows read.
|
||||
/// </summary>
|
||||
public class TableDistressReportRepositoryNewestTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 10, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DistressReportEntry Entry(string tenantId, int minute) => new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ErrorType = "DeviceNotRegistered",
|
||||
SerialNumber = $"SN-{tenantId[0]}-{minute}",
|
||||
IngestedAt = T0.AddMinutes(minute),
|
||||
};
|
||||
|
||||
private static async IAsyncEnumerable<DistressReportEntry> Source(params DistressReportEntry[] entries)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant order as the table returns it: all rows of tenant "a", then tenant "b", each newest first.
|
||||
private static DistressReportEntry[] TwoTenantsInterleavedInTime() =>
|
||||
[
|
||||
Entry("aaaaaaaa", 50), Entry("aaaaaaaa", 30), Entry("aaaaaaaa", 10),
|
||||
Entry("bbbbbbbb", 60), Entry("bbbbbbbb", 40), Entry("bbbbbbbb", 20),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task OverTheCap_KeepsNewestAcrossTenants_NewestFirst()
|
||||
{
|
||||
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(
|
||||
Source(TwoTenantsInterleavedInTime()), maxResults: 4);
|
||||
|
||||
Assert.True(truncated);
|
||||
Assert.Equal(new[] { 60, 50, 40, 30 }, reports.Select(r => (int)(r.IngestedAt - T0).TotalMinutes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AtTheCap_ReturnsAll_NotTruncated()
|
||||
{
|
||||
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(
|
||||
Source(TwoTenantsInterleavedInTime()), maxResults: 6);
|
||||
|
||||
Assert.False(truncated);
|
||||
Assert.Equal(new[] { 60, 50, 40, 30, 20, 10 }, reports.Select(r => (int)(r.IngestedAt - T0).TotalMinutes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyTable_ReturnsEmpty_NotTruncated()
|
||||
{
|
||||
var (reports, truncated) = await TableDistressReportRepository.KeepNewestAsync(Source(), maxResults: 4);
|
||||
|
||||
Assert.Empty(reports);
|
||||
Assert.False(truncated);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ public class TableLifecycleBucketTests
|
||||
[Constants.TableNames.GlobalAdmins] = "platform operator identities",
|
||||
[Constants.TableNames.McpUsers] = "platform MCP identities (PK='McpUsers', RK=UPN)",
|
||||
[Constants.TableNames.TenantGroupAssignments] = "per-UPN rows without tenant anchor; offboarding never touches it (see Constants doc)",
|
||||
[Constants.TableNames.PreviewConfig] = "global TelegramBot config row",
|
||||
[Constants.TableNames.PreviewConfig] = "global e-mail template overrides",
|
||||
[Constants.TableNames.Feedback] = "product feedback deliberately survives offboarding (TableFeedbackRepositoryTests contract)",
|
||||
[Constants.TableNames.BlockedVersions] = "platform-wide agent version blocks",
|
||||
[Constants.TableNames.GlobalNotifications] = "GA notifications; time-retention only",
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Telegram as a notification-channel provider: dispatch routing (it must never reach the
|
||||
/// SSRF-guarded webhook path), chat-ID validation, and the Global-Admin gate that keeps a
|
||||
/// tenant admin from sending through the platform-owned bot.
|
||||
/// </summary>
|
||||
public class TelegramChannelProviderTests
|
||||
{
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
{
|
||||
var mock = new Mock<WebhookNotificationService>(new HttpClient()) { CallBase = false };
|
||||
mock.Setup(w => w.SendAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
{
|
||||
var mock = new Mock<TelegramNotificationService>(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance)
|
||||
{ CallBase = false };
|
||||
mock.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true });
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static NotificationChannel Channel(int providerType, string? url, string id = "c1", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = providerType, Url = url, Enabled = enabled };
|
||||
|
||||
private static string ChannelJson(int providerType, string url, string id = "c1", bool enabled = true)
|
||||
=> $"[{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":{(enabled ? "true" : "false")}}}]";
|
||||
|
||||
// ── Dispatch routing ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert, NotificationScope.Platform);
|
||||
|
||||
telegram.Verify(t => t.SendAlertAsync("-1003785642894", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_RoutesWebhookProvidersToWebhookService()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, NotificationScope.Platform);
|
||||
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
"https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel((int)WebhookProviderType.Telegram, "-100123", "tg"),
|
||||
Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"),
|
||||
}, alert, NotificationScope.Platform);
|
||||
|
||||
telegram.Verify(t => t.SendAlertAsync("-100123", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
"https://sales.example/hook", WebhookProviderType.GenericJson, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_SkipsChannelsWithoutDestination()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel((int)WebhookProviderType.Telegram, null, "tg"),
|
||||
Channel((int)WebhookProviderType.Slack, "", "slack"),
|
||||
}, new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
telegram.Verify(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
webhook.Verify(w => w.SendAsync(
|
||||
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTest_RoutesTelegramToBot()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
telegram.Setup(t => t.SendAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new NotificationSendResult { Success = true, Message = "ok" });
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
var result = await dispatcher.SendTestAsync(
|
||||
Channel((int)WebhookProviderType.Telegram, "@salesdesk"),
|
||||
new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
Assert.True(result.Success);
|
||||
telegram.Verify(t => t.SendAlertAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
|
||||
}
|
||||
|
||||
// ── Chat-ID validation ────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("-1003785642894")]
|
||||
[InlineData("123456789")]
|
||||
[InlineData("@salesdesk")]
|
||||
[InlineData("@Sales_Desk_01")]
|
||||
public void ValidateTelegramChatId_AcceptsNumericAndUsernameForms(string chatId)
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("https://api.telegram.org/bot123/sendMessage")]
|
||||
[InlineData("-100abc")]
|
||||
[InlineData("@abc")] // too short
|
||||
[InlineData("@1sales")] // must start with a letter
|
||||
[InlineData("@sales-desk")] // hyphen not allowed
|
||||
[InlineData("123456789012345678901")] // 21 digits
|
||||
public void ValidateTelegramChatId_RejectsEverythingElse(string? chatId)
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_TelegramChatIdBypassesSsrfUrlGate()
|
||||
{
|
||||
// A chat ID is not a URL — the webhook SSRF gate would reject it. Proves the
|
||||
// provider-specific destination branch is wired, not just the format helper.
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-1003785642894")));
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Slack, "-1003785642894")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsTelegramChannelWithUrlDestination()
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "https://hooks.example/x")));
|
||||
|
||||
// ── Global-Admin gate ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_GlobalAdminMayAddChannel()
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: true));
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotAddChannel()
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: false));
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotRetargetExistingChannel()
|
||||
{
|
||||
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
||||
var retargeted = ChannelJson((int)WebhookProviderType.Telegram, "-100999");
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotFlipEnabledState()
|
||||
{
|
||||
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: true);
|
||||
var disabled = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: false);
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(disabled, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMaySaveWithUnchangedGaCreatedChannel()
|
||||
{
|
||||
// The gate must not turn a GA-created Telegram channel into a permanent save block
|
||||
// for the tenant admin's unrelated configuration changes.
|
||||
var json = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_IgnoresNonTelegramChannels()
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.GenericJson, "https://sales.example/hook"), null, isGlobalAdmin: false));
|
||||
|
||||
// ── Both write paths (PUT + field patch) go through ValidateModel ─────
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_EnforcesTelegramGateForNonGlobalAdmin()
|
||||
{
|
||||
var candidate = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
NotificationChannelsJson = ChannelJson((int)WebhookProviderType.Telegram, "-100123"),
|
||||
};
|
||||
var existing = new TenantConfiguration { TenantId = "t1" };
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user