mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-11 20:24:37 +00:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7633c02a1c | |||
| bd66ad7097 | |||
| be13d8f5ef | |||
| 8296fa7291 | |||
| 4faca8692b | |||
| ba715d5aa7 | |||
| ed73dc3a87 | |||
| 1e88d1db4b | |||
| f3778fbad6 | |||
| 1a85a75af1 | |||
| 5f13c71f1d | |||
| 041803e55c | |||
| eff89fd09f | |||
| 0cc35d2bae | |||
| 27cb286118 | |||
| c88e5411a8 | |||
| 5eba39f658 | |||
| 5f95648c73 | |||
| e4d527b6d5 | |||
| 258d7cd5f4 | |||
| 668ae14657 | |||
| 16bd82b148 | |||
| 225d0feca9 | |||
| 6b06edb222 | |||
| 12b29c2e08 | |||
| 5a37721d24 | |||
| 884f8495b2 | |||
| cc674b26ad | |||
| 7139374681 | |||
| e6d92c6005 | |||
| 46880b58e7 | |||
| ee96ff2d23 | |||
| 4275c13dfc | |||
| 1616e33c14 | |||
| 0b39455d4e | |||
| 48baffe753 | |||
| f6f88161f9 | |||
| d0a525ecbd | |||
| 257f403949 | |||
| 7cbcb6bc4c | |||
| f43462567e | |||
| b5ac4651a0 | |||
| 3bc8b87a19 | |||
| 950b3cc679 | |||
| 0559919a9e | |||
| e8f8b876e3 | |||
| 83afd4f1c9 | |||
| d695c9b8c4 | |||
| 1ee7c48c92 | |||
| 98ac414208 | |||
| 5153e90e7c | |||
| 5b139f90e7 | |||
| 05d21cb240 | |||
| 5cc69a696a | |||
| fd3ce44c0c | |||
| 0b48b1ce63 | |||
| 5cb536c46e | |||
| 3f97a418d4 | |||
| cdd44d152a | |||
| d26d877989 | |||
| 59959c663e | |||
| ed2e3fd96c | |||
| d35edeaf9f | |||
| 21b802a028 | |||
| ae258cd9a7 |
+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: bba6d21544...9e518b0caf
@@ -24,6 +24,11 @@ updates:
|
||||
dotnet-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
# Application Insights 3 is the OpenTelemetry rewrite (no telemetry processors, another sampling
|
||||
# model) and the Functions worker integration still builds on 2.x: a planned migration, not a bump.
|
||||
ignore:
|
||||
- dependency-name: 'Microsoft.ApplicationInsights.WorkerService'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/Web/autopilot-monitor-web
|
||||
@@ -101,8 +106,9 @@ updates:
|
||||
# SWA CLI for the deploy job of deploy-web.yml, which installs it from this lockfile next to
|
||||
# the production deploy token: a version reaches that job only through a reviewed bump here.
|
||||
# deploy-web watches this folder, so a merged bump redeploys and proves the new CLI at once.
|
||||
# Not under .github/: there Dependabot's update PR was refused as an unauthorized change.
|
||||
- package-ecosystem: npm
|
||||
directory: /.github/tools/swa-cli
|
||||
directory: /tools/swa-cli
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "autopilot-monitor-swa-cli",
|
||||
"private": true,
|
||||
"description": "Azure Static Web Apps CLI for the deploy job of deploy-web.yml - installed from this lockfile with --ignore-scripts, because that job holds the production deploy token.",
|
||||
"devDependencies": {
|
||||
"@azure/static-web-apps-cli": "2.0.10"
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ name: Workflow gates
|
||||
# version comment (stricter than zizmor's unpinned-uses audit).
|
||||
# * zizmor -- static analysis of every workflow (permissions, credential
|
||||
# persistence, template injection, ...). Configuration and the
|
||||
# reasoned ignores live in zizmor.yml at the repository root; the
|
||||
# same run is expected locally before a workflow change is committed.
|
||||
# reasoned ignores live in .github/zizmor.yml, which zizmor finds on
|
||||
# its own; the same run is expected locally before a workflow change
|
||||
# is committed.
|
||||
# * hosts -- no workflow or .github/scripts helper spells out a host; every one
|
||||
# comes from .github/scripts/Get-Endpoints.ps1, CI's member of the URL
|
||||
# registry (the C#, web and MCP registries have guard tests of their own).
|
||||
@@ -39,10 +40,10 @@ name: Workflow gates
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -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/**'
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
name: Deploy Web (Static Web App)
|
||||
|
||||
# Build here, upload with the SWA CLI from its own lockfile (.github/tools/swa-cli). No
|
||||
# Build here, upload with the SWA CLI from its own lockfile (tools/swa-cli). No
|
||||
# third-party action sees the deploy token, the build runs on the same path ci.yml tests
|
||||
# (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the action's container,
|
||||
# and the exported site is attested like the agent ZIP and the MSI.
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/tools/swa-cli/**'
|
||||
- 'tools/swa-cli/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
# --no-use-keychain keeps unloaded. Before the artifact arrives, so nothing from the
|
||||
# build zone can touch the install.
|
||||
- name: Install the SWA CLI from its lockfile
|
||||
working-directory: .github/tools/swa-cli
|
||||
working-directory: tools/swa-cli
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Download build artifact
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
env:
|
||||
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
.github/tools/swa-cli/node_modules/.bin/swa deploy \
|
||||
tools/swa-cli/node_modules/.bin/swa deploy \
|
||||
--app-location "$WEB_DIR" \
|
||||
--output-location out \
|
||||
--swa-config-location "$WEB_DIR" \
|
||||
|
||||
@@ -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: 461040a828...c4c3655cd0
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -65,6 +65,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -80,7 +92,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -94,7 +106,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -104,6 +117,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -126,6 +140,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+23
-4
@@ -4103,10 +4103,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-002",
|
||||
"title": "Unexpected Local Admin Accounts Detected",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts on the device, including accounts allowed only as standard users that hold local admin rights. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -4166,6 +4166,18 @@
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "role_violations",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.role_violations",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -4181,7 +4193,7 @@
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "The agent detected unexpected local user accounts or profile directories on this device that are not in the built-in or tenant-configured allowed list.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not on the allowed list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), account_details (per account: disabled state and Administrators membership), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list in Tenant Settings.",
|
||||
"explanation": "The agent detected unexpected local user accounts on this device: accounts the built-in or tenant-configured allowed list does not allow, or accounts it allows only as standard users that are members of the local Administrators group.\n\nThis is a security-relevant finding. Creating local admin accounts before or during Autopilot enrollment is a known bypass technique (e.g. via BypassNRO or Shift+F10 during OOBE) that grants persistent local administrator access.\n\nClick the event link below to jump to the local_admin_analysis event and check: unexpected_accounts (local user accounts not allowed by the list — disabled accounts included, a dormant account can be re-enabled later), unexpected_admin_members (unexpected accounts that are members of the local Administrators group — the actual backdoor), role_violations (accounts allowed only as standard users that are members of Administrators — Windows never puts Guest, kioskUser0 or WsiAccount there), account_details (per account: disabled state, Administrators membership and RID — 500 is the built-in Administrator), unexpected_profiles (C:\\Users profile directories not on the allowed list), logged_in_local_accounts (local accounts that were signed in when the shutdown scan ran — a signed-in local account is never exempted, only Entra/domain users are), bypass_nro (whether the BypassNRO registry flag was set), and confidence_score (the agent's confidence in the finding, 0-100).\n\nIf these accounts are expected for your environment (e.g. a service account or helpdesk account), add them to the allowed list under Settings → Agent Analyzers. Choose Standard user only for an account that must never be a local administrator.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Review the unexpected accounts",
|
||||
@@ -4195,7 +4207,8 @@
|
||||
"title": "Add expected accounts to the allowed list (false positive)",
|
||||
"steps": [
|
||||
"Go to Settings → Agent Analyzers section",
|
||||
"Under 'Allowed Admin Accounts', add the account names that are expected in your environment",
|
||||
"Under 'Allowed Local Accounts', add the account names that are expected in your environment",
|
||||
"Choose 'Standard user only' for an account that must not be a local administrator — it is still reported if it ever joins the Administrators group",
|
||||
"Save the settings — future enrollments will no longer flag these accounts"
|
||||
]
|
||||
},
|
||||
@@ -4205,6 +4218,7 @@
|
||||
"Check if the BypassNRO registry flag was set (indicates OOBE was bypassed)",
|
||||
"Review the enrollment timeline for Shift+F10 or other OOBE escape indicators",
|
||||
"Check 'unexpected_admin_members' and 'account_details' — an unexpected account with Administrators membership is a backdoor even while it is disabled (it can be re-enabled by a scheduled task after enrollment)",
|
||||
"Check 'role_violations' — a built-in Windows account or an account you allowed as standard user that sits in Administrators was put there on purpose",
|
||||
"Consider re-imaging the device if unauthorized admin access is confirmed"
|
||||
]
|
||||
}
|
||||
@@ -4227,6 +4241,11 @@
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Allowed only as standard user",
|
||||
"value": "{{role_violations.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+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>
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> run against the netstandard2.0 build of
|
||||
/// <see cref="LocalAdminAllowList"/> — the one the agent ships. The backend runs the same file against
|
||||
/// its net10.0 build, the portal against <c>allowedAccounts.ts</c>.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAllowListFixtureTests
|
||||
{
|
||||
private static IEnumerable<JToken> ParseCases()
|
||||
{
|
||||
var path = Path.Combine(RepoPaths.Fixtures("local-admin-allow-list"), "cases.json");
|
||||
return JObject.Parse(File.ReadAllText(path))["parse"]!;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => ParseCases().Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Agent_parses_the_entry_as_the_shared_case_says(string entry)
|
||||
{
|
||||
var c = ParseCases().First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-21
@@ -18,10 +18,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static readonly List<string> Allowed = new List<string>
|
||||
{
|
||||
"Administrator", "Guest", "DefaultAccount", "WDAGUtilityAccount", "defaultuser0", "defaultuser1", "adm-*"
|
||||
};
|
||||
// The real built-in catalog plus one tenant glob.
|
||||
private static readonly List<LocalAdminAnalyzer.AllowedEntry> Allowed =
|
||||
LocalAdminAnalyzer.BuildAllowedEntries(new[] { "adm-*" }, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, bool disabled, int rid) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
@@ -126,7 +125,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
[Fact]
|
||||
public void Local_admin_member_missing_from_the_wmi_inventory_is_still_flagged()
|
||||
{
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough.
|
||||
// WMI failed or did not list the account — the SAM group membership alone is enough,
|
||||
// and the account counts as unexpected too, like any unknown administrator (score 80).
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
LocalMember("Administrator", 500),
|
||||
@@ -136,8 +136,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
var result = LocalAdminAnalyzer.EvaluateAccounts(
|
||||
new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, true, Machine, Allowed);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -191,26 +192,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Well_known_rid_does_not_cover_ordinary_accounts_or_foreign_sids()
|
||||
public void Machine_account_rid_is_read_only_from_local_machine_sids()
|
||||
{
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "500"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "501"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "503"));
|
||||
Assert.True(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "504"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "502"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix + "1001"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid("S-1-5-32-544"));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(MachineSidPrefix));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(null));
|
||||
Assert.False(LocalAdminAnalyzer.IsWellKnownBuiltInRid(""));
|
||||
Assert.Equal(500, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "500"));
|
||||
Assert.Equal(501, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "501"));
|
||||
Assert.Equal(1001, LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "1001"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-12-1-1-2-3-500"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid("S-1-5-32-544"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(MachineSidPrefix + "x1"));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(null!));
|
||||
Assert.Null(LocalAdminAnalyzer.MachineAccountRid(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_named_like_a_builtin_but_with_an_ordinary_rid_is_still_matched_by_name_only()
|
||||
public void Localized_builtin_name_on_an_ordinary_rid_is_not_allowed()
|
||||
{
|
||||
// Name-based allowance is unchanged: the RID match only ADDS coverage.
|
||||
// Only the RID makes a built-in: an ordinary account merely named like a localized
|
||||
// built-in is no entry at all.
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
Account("Gast", disabled: false, rid: 1001)
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Roles on the allowed list: <c>name|user</c> and the standard-user built-ins (Guest,
|
||||
/// DefaultAccount, WDAGUtilityAccount by RID; kioskUser0, WsiAccount and other accounts carrying a
|
||||
/// built-in name by name) allow an account only while it is not an Administrators member. A
|
||||
/// restriction always wins; the four built-in accounts are decided by RID alone; profile folders
|
||||
/// have no role; a login allows a profile folder, never an account. All names are synthetic.
|
||||
/// </summary>
|
||||
public sealed class LocalAdminAnalyzerRoleTests
|
||||
{
|
||||
private const string Machine = "DESKTOP-TEST01";
|
||||
private const string MachineSidPrefix = "S-1-5-21-111-222-333-";
|
||||
|
||||
private static List<LocalAdminAnalyzer.AllowedEntry> Entries(params string[] tenantEntries)
|
||||
=> LocalAdminAnalyzer.BuildAllowedEntries(tenantEntries, new List<string>());
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountInfo Account(string name, int rid, bool disabled = false) =>
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = name, Disabled = disabled, Sid = MachineSidPrefix + rid };
|
||||
|
||||
private static LocalGroupMember Member(string name, int rid) =>
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\" + name, Sid = MachineSidPrefix + rid, SidUsage = 1 };
|
||||
|
||||
private static LocalAdminAnalyzer.LocalAccountCheckResult Evaluate(
|
||||
IList<LocalAdminAnalyzer.LocalAccountInfo> accounts, IList<LocalGroupMember> admins, List<LocalAdminAnalyzer.AllowedEntry> entries,
|
||||
bool enumerated = true)
|
||||
=> LocalAdminAnalyzer.EvaluateAccounts(accounts, admins, enumerated, Machine, entries);
|
||||
|
||||
// -------------------------------------------------------------- WsiAccount (the field case)
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_as_standard_user_with_its_profile_folder_scores_zero()
|
||||
{
|
||||
// The field case: web sign-in enabled the account for the sign-in, it holds no admin
|
||||
// membership and has C:\Users\WsiAccount — before roles existed this scored 80 (Error).
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrator", 500, disabled: true),
|
||||
Account("DefaultAccount", 503, disabled: true),
|
||||
Account("defaultuser0", 1000, disabled: true),
|
||||
Account("Gast", 501, disabled: true),
|
||||
Account("WDAGUtilityAccount", 504, disabled: true),
|
||||
Account("WsiAccount", 1001, disabled: true),
|
||||
Account("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
new LocalGroupMember { DomainAndName = "S-1-12-1-11-22-33-44", Sid = "S-1-12-1-11-22-33-44", SidUsage = 8 },
|
||||
Member("ADM-0A1B2C3D", 1002),
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("ADM-*"));
|
||||
var score = LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
bypassNro: false, result.Unexpected, result.UnexpectedAdminMembers, new List<string> { "jane.doe" });
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(0, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WsiAccount_in_administrators_is_a_role_violation_even_while_disabled()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001, disabled: true) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(
|
||||
false, result.Unexpected, result.UnexpectedAdminMembers, new List<string>()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("WsiAccount")]
|
||||
[InlineData("wsiaccount")]
|
||||
[InlineData("Wsi*")]
|
||||
[InlineData("*")]
|
||||
public void Tenant_entries_cannot_lift_a_builtin_restriction(string tenantEntry)
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("WsiAccount", 1001) },
|
||||
new List<LocalGroupMember> { Member("WsiAccount", 1001) },
|
||||
Entries(tenantEntry));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KioskUser0_in_administrators_is_a_role_violation()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("kioskUser0", 1003) },
|
||||
new List<LocalGroupMember> { Member("kioskUser0", 1003) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "kioskUser0" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- built-ins by RID
|
||||
|
||||
[Theory]
|
||||
[InlineData("Guest", 501)]
|
||||
[InlineData("Gast", 501)]
|
||||
[InlineData("Invité", 501)]
|
||||
[InlineData("DefaultAccount", 503)]
|
||||
[InlineData("WDAGUtilityAccount", 504)]
|
||||
public void Builtin_accounts_that_are_never_administrators_are_flagged_as_one(string name, int rid)
|
||||
{
|
||||
// A tenant entry naming the localized account (or even "*") cannot lift it either.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, rid, disabled: true) },
|
||||
new List<LocalGroupMember> { Member(name, rid) },
|
||||
Entries(name, "*"));
|
||||
|
||||
Assert.Equal(new[] { name }, result.Unexpected);
|
||||
Assert.Equal(new[] { name }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { name }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Administrator")]
|
||||
[InlineData("Administrateur")]
|
||||
[InlineData("Renamed_Admin")]
|
||||
public void The_builtin_administrator_is_allowed_by_rid_under_any_name(string name)
|
||||
{
|
||||
// "Admin*|user" must not turn the real Administrator into a finding: name entries never
|
||||
// decide the RID accounts — and the built-in name entry "Administrator|user" neither.
|
||||
var result = Evaluate(
|
||||
new[] { Account(name, 500) },
|
||||
new List<LocalGroupMember> { Member(name, 500) },
|
||||
Entries("Admin*|user", "Renamed*|user"));
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.True(result.BuiltInAdministratorEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_account_named_administrator_is_allowed_only_as_a_decoy()
|
||||
{
|
||||
// French device: the RID-500 account is "Administrateur", so "Administrator" is free.
|
||||
var accounts = new[]
|
||||
{
|
||||
Account("Administrateur", 500, disabled: true),
|
||||
Account("Administrator", 1004),
|
||||
};
|
||||
|
||||
var decoy = Evaluate(accounts, new List<LocalGroupMember> { Member("Administrateur", 500) }, Entries());
|
||||
Assert.Empty(decoy.Unexpected);
|
||||
|
||||
var impostor = Evaluate(accounts,
|
||||
new List<LocalGroupMember> { Member("Administrateur", 500), Member("Administrator", 1004) }, Entries());
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.RoleViolations);
|
||||
Assert.Equal(new[] { "Administrator" }, impostor.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_oobe_account_may_be_an_administrator()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("defaultuser0", 1000) },
|
||||
new List<LocalGroupMember> { Member("defaultuser0", 1000) },
|
||||
Entries());
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_sid_lets_the_english_builtin_name_stand_in_for_the_rid()
|
||||
{
|
||||
var accounts = new List<LocalAdminAnalyzer.LocalAccountInfo>
|
||||
{
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Administrator", Disabled = false, Sid = null! },
|
||||
new LocalAdminAnalyzer.LocalAccountInfo { Name = "Guest", Disabled = false, Sid = null! },
|
||||
};
|
||||
var admins = new List<LocalGroupMember>
|
||||
{
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Administrator", Sid = null!, SidUsage = 1 },
|
||||
new LocalGroupMember { DomainAndName = Machine + "\\Guest", Sid = null!, SidUsage = 1 },
|
||||
};
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries());
|
||||
|
||||
Assert.Equal(new[] { "Guest" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("Administrator", result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- tenant roles
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_entry_allows_the_account_until_it_is_an_administrator()
|
||||
{
|
||||
var accounts = new[] { Account("frontdesk", 1005) };
|
||||
|
||||
Assert.Empty(Evaluate(accounts, new List<LocalGroupMember>(), Entries("frontdesk|user")).Unexpected);
|
||||
|
||||
var admin = Evaluate(accounts, new List<LocalGroupMember> { Member("frontdesk", 1005) }, Entries("frontdesk|user"));
|
||||
Assert.Equal(new[] { "frontdesk" }, admin.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_standard_user_glob_restricts_each_matching_account()
|
||||
{
|
||||
var accounts = new[] { Account("svc-print", 1006), Account("svc-scan", 1007) };
|
||||
|
||||
var result = Evaluate(accounts, new List<LocalGroupMember> { Member("svc-scan", 1007) }, Entries("svc-*|user"));
|
||||
|
||||
Assert.Equal(new[] { "svc-scan" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "svc-scan" }, result.RoleViolations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restriction_wins_over_a_broader_entry_that_allows_more()
|
||||
{
|
||||
var accounts = new[] { Account("adm-kiosk", 1008), Account("adm-4711", 1009) };
|
||||
var admins = new List<LocalGroupMember> { Member("adm-kiosk", 1008), Member("adm-4711", 1009) };
|
||||
|
||||
var result = Evaluate(accounts, admins, Entries("adm-*", "adm-kiosk|user"));
|
||||
|
||||
Assert.Equal(new[] { "adm-kiosk" }, result.RoleViolations);
|
||||
Assert.DoesNotContain("adm-4711", result.Unexpected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Membership_unknown_lets_a_standard_user_entry_act_like_a_plain_one()
|
||||
{
|
||||
// Enumeration failed: nobody is known to be an administrator, so nothing is a violation.
|
||||
var result = Evaluate(new[] { Account("WsiAccount", 1001) }, new List<LocalGroupMember>(), Entries(), enumerated: false);
|
||||
|
||||
Assert.Empty(result.Unexpected);
|
||||
Assert.False(result.AdministratorsGroupEnumerated);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- folders are no accounts
|
||||
|
||||
[Fact]
|
||||
public void An_account_named_like_a_profile_folder_is_unexpected_while_the_folder_is_allowed()
|
||||
{
|
||||
var entries = Entries();
|
||||
|
||||
var result = Evaluate(new[] { Account("Public", 1010) }, new List<LocalGroupMember> { Member("Public", 1010) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "Public" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "Public" }, result.UnexpectedAdminMembers);
|
||||
Assert.Empty(result.RoleViolations);
|
||||
Assert.Equal(LocalAdminAnalyzer.AccountDecision.Unlisted,
|
||||
LocalAdminAnalyzer.Decide("Public", MachineSidPrefix + "1010", isAdmin: false, entries));
|
||||
Assert.Contains(entries, e => e.Pattern == "Public" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- accounts WMI did not return
|
||||
|
||||
[Fact]
|
||||
public void Administrators_members_missing_from_wmi_are_judged_as_administrators()
|
||||
{
|
||||
var members = new List<LocalGroupMember>
|
||||
{
|
||||
Member("Administrator", 500),
|
||||
Member("WsiAccount", 1001),
|
||||
Member("Gast", 501),
|
||||
Member("adm-4711", 1009),
|
||||
};
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), members, Entries("adm-*"));
|
||||
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.RoleViolations);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "WsiAccount", "Gast" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_member_wmi_already_returned_is_not_judged_twice()
|
||||
{
|
||||
var result = Evaluate(
|
||||
new[] { Account("backdoor", 1011) },
|
||||
new List<LocalGroupMember> { Member("backdoor", 1011) },
|
||||
Entries());
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_login_never_allows_an_account_not_even_when_wmi_failed()
|
||||
{
|
||||
// WMI failed: the split cannot recognise "backdoor" as local and allows it dynamically.
|
||||
// The dynamic entry is folder-only, so the signed-in backdoor administrator is still found.
|
||||
var split = LocalAdminAnalyzer.SplitLoggedInUsers(new[] { "backdoor" }, null!);
|
||||
var entries = Entries();
|
||||
entries.AddRange(split.Allowed.Select(u => new LocalAdminAnalyzer.AllowedEntry(u, LocalAccountRole.Any, profileFolderOnly: true)));
|
||||
|
||||
var result = Evaluate(new List<LocalAdminAnalyzer.LocalAccountInfo>(), new List<LocalGroupMember> { Member("backdoor", 1011) }, entries);
|
||||
|
||||
Assert.Equal(new[] { "backdoor" }, result.UnexpectedAdminMembers);
|
||||
Assert.Equal(new[] { "backdoor" }, result.Unexpected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- the entry list
|
||||
|
||||
[Fact]
|
||||
public void Entries_carry_the_builtins_and_skip_invalid_tenant_entries()
|
||||
{
|
||||
var ignored = new List<string>();
|
||||
var entries = LocalAdminAnalyzer.BuildAllowedEntries(
|
||||
new[] { "kiosk|user", "x|admin", "|user", "legacy*", "CONTOSO\\admin", " ", "Guest|user" }, ignored);
|
||||
|
||||
Assert.Equal(new[] { "x|admin", "|user" }, ignored);
|
||||
var accounts = entries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList();
|
||||
Assert.Contains("WsiAccount|user", accounts);
|
||||
Assert.Contains("kioskUser0|user", accounts);
|
||||
Assert.Contains("defaultuser0", accounts);
|
||||
Assert.Contains("kiosk|user", accounts);
|
||||
Assert.Contains("legacy*", accounts);
|
||||
Assert.Contains("CONTOSO\\admin", accounts);
|
||||
Assert.Single(accounts, a => a == "Guest|user");
|
||||
Assert.DoesNotContain("Public", accounts);
|
||||
Assert.Contains(entries, e => e.Pattern == "All Users" && e.ProfileFolderOnly);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- scoring
|
||||
|
||||
[Fact]
|
||||
public void Score_follows_the_signals()
|
||||
{
|
||||
var none = new List<string>();
|
||||
var backdoor = new List<string> { "backdoor" };
|
||||
|
||||
Assert.Equal(0, LocalAdminAnalyzer.ComputeConfidenceScore(false, none, none, backdoor));
|
||||
Assert.Equal(20, LocalAdminAnalyzer.ComputeConfidenceScore(true, none, none, none));
|
||||
Assert.Equal(40, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, backdoor, none));
|
||||
Assert.Equal(80, LocalAdminAnalyzer.ComputeConfidenceScore(false, backdoor, none, new List<string> { "BACKDOOR" }));
|
||||
Assert.Equal(100, LocalAdminAnalyzer.ComputeConfidenceScore(true, backdoor, backdoor, backdoor));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+237
-127
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
@@ -36,21 +37,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// (<c>builtin_administrator_enabled</c>) but not scored: whether OOBE enables it
|
||||
/// transiently is not established, so the field first collects evidence.
|
||||
///
|
||||
/// Built-in accounts are recognised by well-known RID (500/501/503/504 on the machine SID)
|
||||
/// in addition to their English names, so localized installs (Gast, Invité, Administrateur)
|
||||
/// do not flag their own disabled Guest/Administrator.
|
||||
/// Allowed list (<see cref="LocalAdminAllowList"/> built-ins plus the tenant's entries): an entry
|
||||
/// allows an account in any role, or — <c>name|user</c> — only while it is not an
|
||||
/// Administrators member. A restriction always wins over an entry that allows more. The four
|
||||
/// built-in accounts are decided by their well-known RID alone (500 any role; 501/503/504 never
|
||||
/// an administrator), so localized names (Gast, Administrateur) are recognised and no name entry
|
||||
/// can lift or impose a role on them. Profile folders have no role.
|
||||
///
|
||||
/// At shutdown the logged-in users are allowed dynamically — but only identities that are
|
||||
/// NOT local SAM accounts (Entra / domain users, whose profile folder is the only artifact
|
||||
/// they leave). A logged-in local account is exactly the delta this analyzer exists to
|
||||
/// catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// At shutdown the logged-in users are allowed dynamically — as profile folders only, and only
|
||||
/// identities that are NOT local SAM accounts (Entra / domain users, whose profile folder is the
|
||||
/// only artifact they leave). A logged-in local account is exactly the delta this analyzer exists
|
||||
/// to catch and is never exempted by its own login (CWE-807: the login state is under the
|
||||
/// adversary's control); it is reported as <c>logged_in_local_accounts</c>.
|
||||
///
|
||||
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
|
||||
/// enabling delta detection between pre- and post-enrollment state.
|
||||
///
|
||||
/// Tenant-supplied allowed accounts may contain glob wildcards (e.g. "adm-*");
|
||||
/// see <see cref="MatchesAllowedEntry"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAnalyzer : IAgentAnalyzer
|
||||
{
|
||||
@@ -58,26 +59,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<string> _allowedAccounts;
|
||||
|
||||
// Built-in accounts and profile folders always present on a freshly imaged Windows device.
|
||||
// "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts.
|
||||
// "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment.
|
||||
private static readonly List<string> BuiltInAllowedAccounts = new List<string>
|
||||
{
|
||||
"Administrator",
|
||||
"Guest",
|
||||
"DefaultAccount",
|
||||
"WDAGUtilityAccount",
|
||||
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
|
||||
"defaultuser1", // Sometimes seen in OOBE, but not always present
|
||||
"defaultuser2", // Sometimes seen in OOBE, but not always present
|
||||
"kioskUser0", // Assigned Access (kiosk profile) autologon account, created by Windows
|
||||
"Public", // Profile folder (not a user account)
|
||||
"Default", // Default user profile template
|
||||
"Default User", // Symlink to Default in some Windows versions
|
||||
"All Users" // Junction pointing to C:\ProgramData, always present
|
||||
};
|
||||
private readonly List<AllowedEntry> _allowedEntries;
|
||||
|
||||
public string Name => "LocalAdminAnalyzer";
|
||||
|
||||
@@ -97,19 +79,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_startupGate = startupGate;
|
||||
|
||||
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
|
||||
_allowedAccounts = new List<string>(BuiltInAllowedAccounts);
|
||||
if (allowedAccounts != null)
|
||||
{
|
||||
foreach (var account in allowedAccounts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(account) &&
|
||||
!_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_allowedAccounts.Add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
var ignored = new List<string>();
|
||||
_allowedEntries = BuildAllowedEntries(allowedAccounts, ignored);
|
||||
foreach (var entry in ignored)
|
||||
_logger.Warning($"{Name}: Allowed-list entry '{entry}' is not valid (expected 'name' or 'name|user') — ignored, it allows nothing");
|
||||
}
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
@@ -149,46 +122,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
foreach (var user in loggedIn.LocalAccounts)
|
||||
_logger.Warning($"{Name}: Logged-in user '{user}' is a local account — not allowed dynamically");
|
||||
|
||||
// Build effective allowed list: static + dynamic (non-local logged-in users at shutdown)
|
||||
var effectiveAllowed = _allowedAccounts;
|
||||
// Effective list: static entries + the profile folders of non-local logged-in users at
|
||||
// shutdown. Folder-only: a login never allows a local ACCOUNT — not even when the WMI
|
||||
// inventory failed and the split above could not recognise a local name.
|
||||
var effectiveEntries = _allowedEntries;
|
||||
if (loggedIn.Allowed.Count > 0)
|
||||
{
|
||||
effectiveAllowed = new List<string>(_allowedAccounts);
|
||||
effectiveEntries = new List<AllowedEntry>(_allowedEntries);
|
||||
foreach (var user in loggedIn.Allowed)
|
||||
{
|
||||
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
|
||||
if (!effectiveEntries.Any(e => string.Equals(e.Pattern, user, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
effectiveAllowed.Add(user);
|
||||
_logger.Info($"{Name}: Dynamically allowing logged-in user: {user}");
|
||||
effectiveEntries.Add(new AllowedEntry(user, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
_logger.Info($"{Name}: Dynamically allowing profile folder of logged-in user: {user}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bypassNroResult = CheckBypassNroRegistry();
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveAllowed);
|
||||
var profilesResult = CheckUserProfiles(effectiveAllowed);
|
||||
var accountsResult = CheckLocalAdminAccounts(localAccounts, effectiveEntries);
|
||||
var profilesResult = CheckUserProfiles(effectiveEntries);
|
||||
|
||||
int confidenceScore = 0;
|
||||
|
||||
if (bypassNroResult.Value == 1)
|
||||
confidenceScore += 20;
|
||||
|
||||
if (accountsResult.Unexpected.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// An unexpected account that is also a member of Administrators is the
|
||||
// actual backdoor (enabled or dormant) — high indicator on its own.
|
||||
if (accountsResult.UnexpectedAdminMembers.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// Profile overlap: unexpected account AND matching C:\Users folder
|
||||
bool profileOverlap = accountsResult.Unexpected.Any(a =>
|
||||
profilesResult.Unexpected.Any(p =>
|
||||
string.Equals(a, p, StringComparison.OrdinalIgnoreCase)));
|
||||
if (profileOverlap)
|
||||
confidenceScore += 40;
|
||||
|
||||
confidenceScore = Math.Min(confidenceScore, 100);
|
||||
var confidenceScore = ComputeConfidenceScore(
|
||||
bypassNroResult.Value == 1, accountsResult.Unexpected, accountsResult.UnexpectedAdminMembers, profilesResult.Unexpected);
|
||||
|
||||
EventSeverity severity;
|
||||
string findingLabel;
|
||||
@@ -219,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
$"bypassNro={bypassNroResult.Value}, " +
|
||||
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
|
||||
$"unexpectedAdminMembers={accountsResult.UnexpectedAdminMembers.Count}, " +
|
||||
$"roleViolations={accountsResult.RoleViolations.Count}, " +
|
||||
$"adminGroupEnumerated={accountsResult.AdministratorsGroupEnumerated}, " +
|
||||
$"builtinAdministratorEnabled={accountsResult.BuiltInAdministratorEnabled?.ToString() ?? "unknown"}, " +
|
||||
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
|
||||
@@ -230,7 +187,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{ "finding", findingLabel },
|
||||
{ "triggered_at", trigger },
|
||||
{ "enrollment_phase_at_check", phase.ToString() },
|
||||
{ "allowed_accounts", effectiveAllowed },
|
||||
// Account entries in canonical form ("Guest|user"); the folder-only entries are
|
||||
// constant or listed in dynamically_allowed_users.
|
||||
{ "allowed_accounts", _allowedEntries.Where(e => !e.ProfileFolderOnly).Select(e => e.Display).ToList() },
|
||||
{ "dynamically_allowed_users", loggedIn.Allowed },
|
||||
{ "logged_in_local_accounts", loggedIn.LocalAccounts },
|
||||
{ "checks", new Dictionary<string, object>
|
||||
@@ -243,6 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
},
|
||||
{ "unexpected_accounts", accountsResult.Unexpected },
|
||||
{ "unexpected_admin_members", accountsResult.UnexpectedAdminMembers },
|
||||
{ "role_violations", accountsResult.RoleViolations },
|
||||
{ "unexpected_profiles", profilesResult.Unexpected },
|
||||
{ "accounts_checked", accountsResult.AllChecked },
|
||||
{ "account_details", accountsResult.AccountDetails },
|
||||
@@ -299,31 +259,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence 0-100: BypassNRO +20, any unexpected account +40, any unexpected Administrators
|
||||
/// member +40 (the actual backdoor, enabled or dormant; a role violation is one), an unexpected
|
||||
/// account with a C:\Users folder of the same name +40. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static int ComputeConfidenceScore(
|
||||
bool bypassNro, IList<string> unexpectedAccounts, IList<string> unexpectedAdminMembers, IList<string> unexpectedProfiles)
|
||||
{
|
||||
var score = 0;
|
||||
if (bypassNro)
|
||||
score += 20;
|
||||
if (unexpectedAccounts.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAdminMembers.Count > 0)
|
||||
score += 40;
|
||||
if (unexpectedAccounts.Any(a => unexpectedProfiles.Any(p => string.Equals(a, p, StringComparison.OrdinalIgnoreCase))))
|
||||
score += 40;
|
||||
return Math.Min(score, 100);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allowed-list matching
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Matches an account or profile-folder name against one allowed-list entry.
|
||||
/// Entries containing <c>*</c> or <c>?</c> are glob patterns (same semantics as the
|
||||
/// Matches an account or profile-folder name against one allowed-list pattern.
|
||||
/// Patterns containing <c>*</c> or <c>?</c> are globs (same semantics as the
|
||||
/// hardware whitelist: <c>*</c> = any run, <c>?</c> = one char, case-insensitive);
|
||||
/// all other entries match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// all other patterns match exactly. No ambiguity with literal names — <c>*</c> and
|
||||
/// <c>?</c> are invalid characters in Windows account names.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool MatchesAllowedEntry(string name, string entry)
|
||||
internal static bool MatchesAllowedEntry(string name, string pattern)
|
||||
{
|
||||
if (entry.IndexOf('*') < 0 && entry.IndexOf('?') < 0)
|
||||
return string.Equals(entry, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (pattern.IndexOf('*') < 0 && pattern.IndexOf('?') < 0)
|
||||
return string.Equals(pattern, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var regexPattern = "^" + Regex.Escape(entry)
|
||||
var regexPattern = "^" + Regex.Escape(pattern)
|
||||
.Replace("\\*", ".*")
|
||||
.Replace("\\?", ".") + "$";
|
||||
|
||||
try
|
||||
{
|
||||
// Timeout guards against pathological patterns (ReDoS)
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(100));
|
||||
// Timeout guards against pathological patterns (ReDoS). CultureInvariant: under a
|
||||
// Turkish culture "i" and "I" are no case pair, so "WLapsAdmin*" missed "WLAPSADMIN01".
|
||||
return Regex.IsMatch(name, regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
@@ -331,39 +313,107 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string name, List<string> allowedAccounts)
|
||||
/// <summary>
|
||||
/// The effective static list: the built-in account-name and profile-folder entries of
|
||||
/// <see cref="LocalAdminAllowList.BuiltIns"/> (RID entries are decided by RID, see
|
||||
/// <see cref="Decide"/>) plus the tenant's entries. An entry that does not parse is added to
|
||||
/// <paramref name="ignored"/> and allows nothing. Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static List<AllowedEntry> BuildAllowedEntries(IEnumerable<string> tenantEntries, List<string> ignored)
|
||||
{
|
||||
return allowedAccounts.Any(a => MatchesAllowedEntry(name, a));
|
||||
var entries = new List<AllowedEntry>();
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match == LocalAdminBuiltInMatch.AccountName)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, builtIn.Role, profileFolderOnly: false));
|
||||
else if (builtIn.Match == LocalAdminBuiltInMatch.ProfileFolder)
|
||||
AddDistinct(entries, new AllowedEntry(builtIn.Name, LocalAccountRole.Any, profileFolderOnly: true));
|
||||
}
|
||||
|
||||
foreach (var raw in tenantEntries ?? Enumerable.Empty<string>())
|
||||
{
|
||||
if (LocalAdminAllowList.TryParseEntry(raw, out var pattern, out var role))
|
||||
AddDistinct(entries, new AllowedEntry(pattern, role, profileFolderOnly: false));
|
||||
else if (!string.IsNullOrWhiteSpace(raw))
|
||||
ignored.Add(raw);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void AddDistinct(List<AllowedEntry> entries, AllowedEntry entry)
|
||||
{
|
||||
if (!entries.Any(e => e.ProfileFolderOnly == entry.ProfileFolderOnly
|
||||
&& string.Equals(e.Display, entry.Display, StringComparison.OrdinalIgnoreCase)))
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known built-in accounts of the local machine domain, recognised by RID so that
|
||||
/// localized names (Gast, Invité, Administrateur, …) are not flagged:
|
||||
/// 500 Administrator, 501 Guest, 503 DefaultAccount, 504 WDAGUtilityAccount.
|
||||
/// Only S-1-5-21-… SIDs qualify (the WMI query is already restricted to local accounts).
|
||||
/// Decides one local account. The built-in accounts are decided by their RID alone: 500 in any
|
||||
/// role, 501/503/504 never as an administrator — no name entry, built-in or tenant, lifts or
|
||||
/// imposes a role on them. Without a SID (a WMI corner case) the English name stands in for the
|
||||
/// RID. Every other account is allowed when an account entry matches it, unless it is an
|
||||
/// administrator and ANY matching entry is standard-user only: a restriction always wins.
|
||||
/// Pure; internal for unit-testing.
|
||||
/// </summary>
|
||||
internal static AccountDecision Decide(string name, string sid, bool isAdmin, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var builtIn = FindBuiltInRidEntry(name, sid);
|
||||
if (builtIn != null)
|
||||
return isAdmin && builtIn.Role == LocalAccountRole.StandardUser ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
|
||||
var covered = false;
|
||||
var restricted = false;
|
||||
foreach (var entry in allowed)
|
||||
{
|
||||
if (entry.ProfileFolderOnly || !MatchesAllowedEntry(name, entry.Pattern))
|
||||
continue;
|
||||
covered = true;
|
||||
if (entry.Role == LocalAccountRole.StandardUser)
|
||||
restricted = true;
|
||||
}
|
||||
|
||||
if (!covered)
|
||||
return AccountDecision.Unlisted;
|
||||
return isAdmin && restricted ? AccountDecision.RoleViolation : AccountDecision.Allowed;
|
||||
}
|
||||
|
||||
private static LocalAdminBuiltInEntry FindBuiltInRidEntry(string name, string sid)
|
||||
{
|
||||
var rid = MachineAccountRid(sid);
|
||||
foreach (var builtIn in LocalAdminAllowList.BuiltIns)
|
||||
{
|
||||
if (builtIn.Match != LocalAdminBuiltInMatch.Rid)
|
||||
continue;
|
||||
var matches = rid.HasValue
|
||||
? builtIn.Rid == rid.Value
|
||||
: string.IsNullOrEmpty(sid) && string.Equals(builtIn.Name, name, StringComparison.OrdinalIgnoreCase);
|
||||
if (matches)
|
||||
return builtIn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RID (the last sub-authority) of a local-machine account SID (<c>S-1-5-21-…</c>; the WMI query
|
||||
/// and the member filter already restrict to local accounts), else null.
|
||||
/// Internal for direct unit-testing via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal static bool IsWellKnownBuiltInRid(string sid)
|
||||
internal static int? MachineAccountRid(string sid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sid) || !sid.StartsWith("S-1-5-21-", StringComparison.Ordinal))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var dash = sid.LastIndexOf('-');
|
||||
if (dash < 0 || dash == sid.Length - 1)
|
||||
return false;
|
||||
|
||||
switch (sid.Substring(dash + 1))
|
||||
{
|
||||
case "500":
|
||||
case "501":
|
||||
case "503":
|
||||
case "504":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return int.TryParse(sid.Substring(dash + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var rid)
|
||||
? rid
|
||||
: (int?)null;
|
||||
}
|
||||
|
||||
/// <summary>A profile folder has no role: any entry of the same name allows it.</summary>
|
||||
private static bool IsAllowedFolder(string folderName, IReadOnlyList<AllowedEntry> allowed)
|
||||
=> allowed.Any(e => MatchesAllowedEntry(folderName, e.Pattern));
|
||||
|
||||
/// <summary>
|
||||
/// Splits the logged-in user names into those that may be allowed dynamically (not a
|
||||
/// local SAM account — Entra / domain identities whose only artifact is a profile
|
||||
@@ -481,7 +531,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, List<string> allowedAccounts)
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<LocalAccountInfo> accounts, IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
// Administrators-group membership is read from the SAM, independent of WMI and of
|
||||
// the enabled state — a dormant backdoor with admin rights is still a member.
|
||||
@@ -489,13 +539,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
if (!group.Succeeded)
|
||||
_logger.Warning($"{Name}: Failed to enumerate members of '{group.GroupName}': NET_API_STATUS={group.ErrorCode}");
|
||||
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowedAccounts);
|
||||
var result = EvaluateAccounts(accounts, group.Members, group.Succeeded, Environment.MachineName, allowed);
|
||||
result.AdministratorsGroupErrorCode = group.ErrorCode;
|
||||
|
||||
foreach (var name in result.Unexpected)
|
||||
_logger.Debug($"{Name}: Unexpected local account: {name}");
|
||||
foreach (var name in result.UnexpectedAdminMembers)
|
||||
_logger.Debug($"{Name}: Unexpected Administrators member: {name}");
|
||||
foreach (var name in result.RoleViolations)
|
||||
_logger.Debug($"{Name}: Allowed only as a standard user but an Administrators member: {name}");
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -508,10 +560,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// - every local account is checked, disabled ones included (state is reported);
|
||||
/// - a local account is an Administrators member when its SID matches a group member,
|
||||
/// or (SID unavailable) its name matches a member of the local machine domain;
|
||||
/// - an account is allowed by name (allowed list, globs) or by well-known RID
|
||||
/// (<see cref="IsWellKnownBuiltInRid"/>) — localized built-ins are never flagged;
|
||||
/// - <c>UnexpectedAdminMembers</c> = unexpected accounts holding membership, plus local
|
||||
/// members not on the allowed list that the WMI inventory did not return at all;
|
||||
/// - each account is decided by <see cref="Decide"/>; an account no entry allows, or one
|
||||
/// that breaks a standard-user entry, is unexpected (and an unexpected admin when it
|
||||
/// holds membership; the latter also a role violation);
|
||||
/// - local members the WMI inventory did not return at all are decided as administrators
|
||||
/// and land in both unexpected lists, like any unknown administrator;
|
||||
/// - with membership unknown (enumeration failed) nobody counts as an administrator, so a
|
||||
/// standard-user entry acts like a plain one;
|
||||
/// - members outside the local machine domain (Entra role SIDs, domain groups) are
|
||||
/// listed for delta comparison but never flagged — they are expected on joined devices.
|
||||
/// </summary>
|
||||
@@ -520,7 +575,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
IList<LocalGroupMember> adminMembers,
|
||||
bool adminGroupEnumerated,
|
||||
string machineName,
|
||||
List<string> allowedAccounts)
|
||||
IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var result = new LocalAccountCheckResult { AdministratorsGroupEnumerated = adminGroupEnumerated };
|
||||
|
||||
@@ -539,45 +594,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
var inventorySids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var inventoryNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var account in accounts ?? new List<LocalAccountInfo>())
|
||||
{
|
||||
var isAdmin = (!string.IsNullOrEmpty(account.Sid) && adminSids.Contains(account.Sid))
|
||||
|| adminLocalNames.Contains(account.Name);
|
||||
var rid = MachineAccountRid(account.Sid);
|
||||
|
||||
if (!string.IsNullOrEmpty(account.Sid))
|
||||
inventorySids.Add(account.Sid);
|
||||
inventoryNames.Add(account.Name);
|
||||
|
||||
result.AllChecked.Add(account.Name);
|
||||
result.AccountDetails.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "name", account.Name },
|
||||
{ "disabled", account.Disabled },
|
||||
{ "administrators_member", isAdmin }
|
||||
{ "administrators_member", isAdmin },
|
||||
{ "rid", rid }
|
||||
});
|
||||
|
||||
if (account.Sid != null && account.Sid.EndsWith("-500", StringComparison.Ordinal))
|
||||
if (rid == 500)
|
||||
result.BuiltInAdministratorEnabled = !account.Disabled;
|
||||
|
||||
if (IsWellKnownBuiltInRid(account.Sid) || IsAllowed(account.Name, allowedAccounts))
|
||||
continue;
|
||||
|
||||
result.Unexpected.Add(account.Name);
|
||||
if (isAdmin)
|
||||
result.UnexpectedAdminMembers.Add(account.Name);
|
||||
Record(result, account.Name, Decide(account.Name, account.Sid, isAdmin, allowed), isAdmin);
|
||||
}
|
||||
|
||||
// Local members the WMI inventory did not surface (WMI failure, or an account the
|
||||
// provider does not list) — still an unexpected admin when not on the allowed list.
|
||||
// provider does not list): the SAM is authoritative for membership.
|
||||
foreach (var member in adminLocalMembers)
|
||||
{
|
||||
var name = member.Name;
|
||||
if (IsWellKnownBuiltInRid(member.Sid) || IsAllowed(name, allowedAccounts))
|
||||
var returnedByWmi = !string.IsNullOrEmpty(member.Sid)
|
||||
? inventorySids.Contains(member.Sid)
|
||||
: inventoryNames.Contains(member.Name);
|
||||
if (returnedByWmi)
|
||||
continue;
|
||||
if (result.UnexpectedAdminMembers.Any(u => string.Equals(u, name, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
result.UnexpectedAdminMembers.Add(name);
|
||||
Record(result, member.Name, Decide(member.Name, member.Sid, isAdmin: true, allowed), isAdmin: true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void Record(LocalAccountCheckResult result, string name, AccountDecision decision, bool isAdmin)
|
||||
{
|
||||
if (decision == AccountDecision.Allowed)
|
||||
return;
|
||||
AddOnce(result.Unexpected, name);
|
||||
if (isAdmin)
|
||||
AddOnce(result.UnexpectedAdminMembers, name);
|
||||
if (decision == AccountDecision.RoleViolation)
|
||||
AddOnce(result.RoleViolations, name);
|
||||
}
|
||||
|
||||
private static void AddOnce(List<string> names, string name)
|
||||
{
|
||||
if (!names.Any(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)))
|
||||
names.Add(name);
|
||||
}
|
||||
|
||||
private static bool IsLocalMember(LocalGroupMember member, string machineName)
|
||||
{
|
||||
var domain = member.Domain;
|
||||
@@ -586,7 +661,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
return string.Equals(domain, machineName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
|
||||
private UserProfileCheckResult CheckUserProfiles(IReadOnlyList<AllowedEntry> allowed)
|
||||
{
|
||||
var allFound = new List<string>();
|
||||
var unexpected = new List<string>();
|
||||
@@ -616,7 +691,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
|
||||
allFound.Add(folderName);
|
||||
|
||||
if (!IsAllowed(folderName, allowedAccounts))
|
||||
if (!IsAllowedFolder(folderName, allowed))
|
||||
{
|
||||
unexpected.Add(folderName);
|
||||
_logger.Debug($"{Name}: Unexpected profile folder: {folderName}");
|
||||
@@ -740,7 +815,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
/// <summary>Unexpected accounts that are members of the Administrators group.</summary>
|
||||
public List<string> UnexpectedAdminMembers { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member.</summary>
|
||||
/// <summary>Administrators members that an entry allows only as a standard user (subset of <see cref="UnexpectedAdminMembers"/>).</summary>
|
||||
public List<string> RoleViolations { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>Per-account state: name, disabled, administrators_member, rid.</summary>
|
||||
public List<Dictionary<string, object>> AccountDetails { get; set; } = new List<Dictionary<string, object>>();
|
||||
|
||||
/// <summary>Every Administrators member (DOMAIN\name or SID), local or not — for delta comparison.</summary>
|
||||
@@ -753,6 +831,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
public bool? BuiltInAdministratorEnabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One effective allowed-list entry: a name or glob and what it may allow.</summary>
|
||||
internal sealed class AllowedEntry
|
||||
{
|
||||
public AllowedEntry(string pattern, LocalAccountRole role, bool profileFolderOnly)
|
||||
{
|
||||
Pattern = pattern;
|
||||
Role = role;
|
||||
ProfileFolderOnly = profileFolderOnly;
|
||||
}
|
||||
|
||||
public string Pattern { get; }
|
||||
|
||||
public LocalAccountRole Role { get; }
|
||||
|
||||
/// <summary>Allows the C:\Users folder of this name, never a local account.</summary>
|
||||
public bool ProfileFolderOnly { get; }
|
||||
|
||||
/// <summary>Canonical entry text as the event lists it ("Guest|user").</summary>
|
||||
public string Display => LocalAdminAllowList.Format(Pattern, Role);
|
||||
}
|
||||
|
||||
internal enum AccountDecision
|
||||
{
|
||||
Allowed,
|
||||
|
||||
/// <summary>No entry allows the account.</summary>
|
||||
Unlisted,
|
||||
|
||||
/// <summary>An administrator that a standard-user entry (or a built-in RID) allows only as a standard user.</summary>
|
||||
RoleViolation,
|
||||
}
|
||||
|
||||
/// <summary>Logged-in users at shutdown, split by whether the name is a local SAM account.</summary>
|
||||
internal sealed class LoggedInUserSplit
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -71,7 +72,7 @@ public class AppInsightsQueryFunctionTests
|
||||
"rows":[["2026-09-04T10:00:00Z",42,"{\"TenantId\":\"t1\"}",null]]}]}
|
||||
""";
|
||||
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
|
||||
|
||||
Assert.Null(partial);
|
||||
var table = Assert.Single(tables);
|
||||
@@ -97,7 +98,7 @@ public class AppInsightsQueryFunctionTests
|
||||
"error":{"code":"PartialError","message":"There were some errors when processing your query.","innererror":{"code":"E_QUERY_RESULT_SET_TOO_LARGE","message":"Query result set has exceeded the internal record count limit 500000."}}}
|
||||
""";
|
||||
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
|
||||
|
||||
Assert.Single(tables);
|
||||
Assert.Equal("E_QUERY_RESULT_SET_TOO_LARGE: Query result set has exceeded the internal record count limit 500000.", partial);
|
||||
@@ -106,7 +107,7 @@ public class AppInsightsQueryFunctionTests
|
||||
[Fact]
|
||||
public void Empty_or_shapeless_body_yields_no_tables_and_no_partial()
|
||||
{
|
||||
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody("{}");
|
||||
var (tables, partial) = LogQueryExecutor.ParseKustoBody("{}");
|
||||
Assert.Empty(tables);
|
||||
Assert.Null(partial);
|
||||
}
|
||||
@@ -115,16 +116,16 @@ public class AppInsightsQueryFunctionTests
|
||||
[Fact]
|
||||
public void Error_extraction_prefers_innererror_and_tolerates_non_json()
|
||||
{
|
||||
var (msg, code) = AppInsightsQueryFunction.ExtractError(
|
||||
var (msg, code) = LogQueryExecutor.ExtractError(
|
||||
"""{"error":{"message":"The request had some invalid properties","code":"BadArgumentError","innererror":{"code":"SyntaxError","message":"Query could not be parsed at 'foo'"}}}""");
|
||||
Assert.Equal("SyntaxError", code);
|
||||
Assert.Equal("Query could not be parsed at 'foo'", msg);
|
||||
|
||||
var (outerMsg, outerCode) = AppInsightsQueryFunction.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
|
||||
var (outerMsg, outerCode) = LogQueryExecutor.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
|
||||
Assert.Equal("InsufficientAccessError", outerCode);
|
||||
Assert.Equal("Forbidden", outerMsg);
|
||||
|
||||
var (plainMsg, plainCode) = AppInsightsQueryFunction.ExtractError("<html>502 Bad Gateway</html>");
|
||||
var (plainMsg, plainCode) = LogQueryExecutor.ExtractError("<html>502 Bad Gateway</html>");
|
||||
Assert.Null(plainCode);
|
||||
Assert.Equal("Telemetry store query failed", plainMsg);
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ public class AuthFunctionSideEffectTests
|
||||
return BuildRequest(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")));
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal)
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal, RequestContext? requestContext = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
@@ -190,9 +190,11 @@ public class AuthFunctionSideEffectTests
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
|
||||
if (requestContext != null)
|
||||
items[RequestContext.ItemsKey] = requestContext;
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(
|
||||
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
@@ -1093,4 +1095,96 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, push.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")].Status);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser under view-as: a Global Admin reads another tenant as its member
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const string ViewedTenantId = "cccccccc-1111-2222-3333-444444444444";
|
||||
|
||||
/// <summary>The context PolicyEnforcementMiddleware builds for an admitted view-as request.</summary>
|
||||
private static RequestContext ViewingAs(string role) => new()
|
||||
{
|
||||
TenantId = ViewedTenantId,
|
||||
TargetTenantId = ViewedTenantId,
|
||||
UserPrincipalName = Upn,
|
||||
UserRole = role,
|
||||
IsTenantAdmin = role == Constants.TenantRoles.Admin,
|
||||
ViewAs = new ViewAsContext(TenantId, role),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_ViewAs_AnswersAsTheViewedTenantsMember_WithoutAnySignInSideEffect()
|
||||
{
|
||||
// The viewed tenant asks for the Terms + DPA; the caller's home row still lacks its domain — exactly the
|
||||
// state in which an ordinary sign-in would seed it, onboard, and record a login.
|
||||
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
|
||||
viewed.DomainName = "fabrikam.example";
|
||||
viewed.CompanyName = "Fabrikam";
|
||||
viewed.DpaAcceptancePending = true;
|
||||
var home = DefaultConfig();
|
||||
home.DomainName = null!;
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((home, true));
|
||||
var viewedStore = _tenantConfigMock.StubWrites(ViewedTenantId, row: viewed);
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(true);
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
|
||||
var seen = new DateTime(2026, 11, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen(platformUtc: seen));
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Viewer));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await ReadBodyAsync(response);
|
||||
Assert.Contains($"\"tenantId\":\"{ViewedTenantId}\"", body);
|
||||
Assert.Contains("\"isGlobalAdmin\":false", body);
|
||||
Assert.Contains("\"isGlobalReader\":false", body);
|
||||
Assert.Contains("\"isDelegated\":false", body);
|
||||
Assert.Contains("\"role\":\"Viewer\"", body);
|
||||
Assert.Contains("\"isTenantAdmin\":false", body);
|
||||
Assert.Contains("\"dpaAcceptancePending\":true", body);
|
||||
Assert.Contains("\"whatsNewSeenPlatformUtc\":\"2026-11-01T00:00:00Z\"", body);
|
||||
Assert.Contains(
|
||||
$"\"viewAs\":{{\"tenantId\":\"{ViewedTenantId}\",\"tenantName\":\"Fabrikam\",\"role\":\"Viewer\",\"homeTenantId\":\"{TenantId}\"}}",
|
||||
body);
|
||||
|
||||
// No sign-in side effect in either tenant, and no role lookup of the caller at all.
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Empty(viewedStore.Writes);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_tenantAdminsMock.Verify(x => x.GetMembershipSnapshotAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_metricsRepoMock.Verify(x => x.RecordUserLoginAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_globalAdminMock.Verify(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_ViewAs_AnInactiveTenant_ShowsItsActivationGate()
|
||||
{
|
||||
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
|
||||
viewed.DomainName = "fabrikam.example";
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((DefaultConfig(), true));
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(false);
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
|
||||
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen());
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Admin));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
// What the tenant's own admin gets while the tenant waits for activation — the Global Admin bypass is gone.
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Contains("PendingActivation", await ReadBodyAsync(response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,39 @@ public class AuthFunctionTests
|
||||
Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_MemberWithARoleGetsTheCompanyName()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = " Contoso Ltd. ";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: ViewerRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("Contoso Ltd.", (string)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HappyPath_RolelessMemberGetsNoCompanyName()
|
||||
{
|
||||
// Without a role the caller sees only the Progress Portal; the config GET would refuse them.
|
||||
var config = DefaultConfig();
|
||||
config.CompanyName = "Contoso Ltd.";
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null((string?)ToDynamic(result.Body).companyName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("platform", "platform")]
|
||||
[InlineData("Platform", "platform")]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -218,10 +219,12 @@ public class AutopilotDeviceValidatorTests
|
||||
|
||||
private const string Upn = "first.last@example.invalid";
|
||||
|
||||
private static JsonElement Device(string json) => JsonSerializer.Deserialize<JsonElement>(json);
|
||||
|
||||
[Fact]
|
||||
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
|
||||
{
|
||||
var device = Newtonsoft.Json.Linq.JObject.Parse(
|
||||
var device = Device(
|
||||
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
|
||||
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
|
||||
|
||||
@@ -241,7 +244,7 @@ public class AutopilotDeviceValidatorTests
|
||||
[Fact]
|
||||
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
|
||||
{
|
||||
var device = Newtonsoft.Json.Linq.JObject.Parse(
|
||||
var device = Device(
|
||||
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
|
||||
|
||||
var registration = AutopilotDeviceValidator.ReadRegistration(device);
|
||||
@@ -256,7 +259,7 @@ public class AutopilotDeviceValidatorTests
|
||||
public void ReadRegistration_CutsOverlongText()
|
||||
{
|
||||
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
|
||||
var device = Newtonsoft.Json.Linq.JObject.Parse($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
|
||||
var device = Device($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
|
||||
|
||||
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
@@ -202,6 +202,28 @@ public class ConfigBackupHookTests
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_SetupGuideMarkers_NoSnapshot()
|
||||
{
|
||||
// The setup guide's markers would otherwise push real settings out of the two backup slots.
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.SetupGuideSettingsReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideHardwareReviewedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedAt = DateTime.UtcNow;
|
||||
incoming.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "setup-guide", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AddOnPermissionState_NoSnapshot()
|
||||
{
|
||||
|
||||
@@ -772,6 +772,35 @@ public class ConfigWireParityTests
|
||||
|
||||
// ---- UpdateTenantConfiguration -------------------------------------------------------
|
||||
|
||||
// ---- RecordSetupGuideStep ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetupGuideStateResponse_matches_the_marker_shape()
|
||||
{
|
||||
DateTime? settingsReviewedAt = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
|
||||
DateTime? hardwareReviewedAt = settingsReviewedAt.Value.AddMinutes(3);
|
||||
DateTime? dismissedAt = null;
|
||||
string? dismissedBy = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
settingsReviewedAt,
|
||||
hardwareReviewedAt,
|
||||
dismissedAt,
|
||||
dismissedBy
|
||||
},
|
||||
new SetupGuideStateResponse
|
||||
{
|
||||
Success = true,
|
||||
SettingsReviewedAt = settingsReviewedAt,
|
||||
HardwareReviewedAt = hardwareReviewedAt,
|
||||
DismissedAt = null,
|
||||
DismissedBy = null
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.DependencyCollector;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Credential URLs never reach a dependency row (K20, D-424): inside a request of a marked client the row
|
||||
/// keeps scheme + host and the method, whatever host the URL names; outside it nothing changes; the mark
|
||||
/// does not leak into the caller's flow; the real collector pipeline carries the mark to the processor;
|
||||
/// and both credential clients (webhooks, push) carry the mark.
|
||||
/// </summary>
|
||||
public class CredentialUrlRedactionProcessorTests
|
||||
{
|
||||
private sealed class CapturingProcessor : ITelemetryProcessor
|
||||
{
|
||||
public List<ITelemetry> Received { get; } = new();
|
||||
public void Process(ITelemetry item) => Received.Add(item);
|
||||
}
|
||||
|
||||
private sealed class CapturingChannel : ITelemetryChannel
|
||||
{
|
||||
private readonly List<ITelemetry> _items = new();
|
||||
public IReadOnlyList<ITelemetry> Items { get { lock (_items) return _items.ToList(); } }
|
||||
public bool? DeveloperMode { get; set; }
|
||||
public string? EndpointAddress { get; set; }
|
||||
public void Send(ITelemetry item) { lock (_items) _items.Add(item); }
|
||||
public void Flush() { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Stands in for the collector: tracks a dependency row from inside the request's flow.</summary>
|
||||
private sealed class TrackingInnerHandler(ITelemetryProcessor processor, DependencyTelemetry row) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
processor.Process(row);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
}
|
||||
}
|
||||
|
||||
private static DependencyTelemetry Row(string url, string method = "POST")
|
||||
=> new() { Type = "Http", Data = url, Name = $"{method} {new Uri(url).AbsolutePath}", Target = new Uri(url).Authority };
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
[InlineData("https://discord.com/api/webhooks/123456/discord-token", "https://discord.com")]
|
||||
[InlineData("https://hooks.slack.com/services/T000/B000/slack-secret", "https://hooks.slack.com")]
|
||||
[InlineData("https://default00000000.00.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/abc/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers&sv=1.0&sig=signature", "https://default00000000.00.environment.api.powerplatform.com")]
|
||||
[InlineData("https://tickets.example.test/hooks/intake?apikey=generic-secret", "https://tickets.example.test")]
|
||||
public void Redact_keeps_scheme_host_and_method_only(string url, string expected)
|
||||
{
|
||||
var dependency = Row(url);
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Redact_of_an_unparseable_url_keeps_only_the_target_host()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "not a url /api/webhooks/1/token", Name = "POST /api/webhooks/1/token", Target = "discord.com" };
|
||||
|
||||
CredentialUrlRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("discord.com", dependency.Data);
|
||||
Assert.Equal("POST discord.com", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_outside_a_marked_request_are_untouched()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var dependency = Row("https://graph.microsoft.com/v1.0/devices?$filter=x", "GET");
|
||||
|
||||
processor.Process(dependency);
|
||||
|
||||
Assert.Same(dependency, Assert.Single(next.Received));
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices?$filter=x", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices", dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_mark_covers_the_request_and_ends_with_it()
|
||||
{
|
||||
var next = new CapturingProcessor();
|
||||
var processor = new CredentialUrlRedactionProcessor(next);
|
||||
var inside = Row("https://discord.com/api/webhooks/123456/discord-token");
|
||||
using var client = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler
|
||||
{
|
||||
InnerHandler = new TrackingInnerHandler(processor, inside),
|
||||
});
|
||||
|
||||
await client.PostAsync("https://discord.com/api/webhooks/123456/discord-token", new StringContent("{}"));
|
||||
var after = Row("https://graph.microsoft.com/v1.0/devices", "GET");
|
||||
processor.Process(after);
|
||||
|
||||
Assert.Equal("https://discord.com", inside.Data);
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices", after.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_real_collector_pipeline_carries_the_mark_to_the_processor()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
using var configuration = new TelemetryConfiguration
|
||||
{
|
||||
TelemetryChannel = channel,
|
||||
ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000",
|
||||
};
|
||||
configuration.TelemetryProcessorChainBuilder.Use(next => new CredentialUrlRedactionProcessor(next)).Build();
|
||||
using var collector = new DependencyTrackingTelemetryModule();
|
||||
collector.Initialize(configuration);
|
||||
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var server = AnswerNoContentAsync(listener, connections: 2);
|
||||
var secretPath = $"http://127.0.0.1:{port}/api/webhooks/123456/discord-token";
|
||||
|
||||
using (var marked = new HttpClient(new CredentialUrlRedactionProcessor.MarkingHandler { InnerHandler = new SocketsHttpHandler() }))
|
||||
(await marked.PostAsync(secretPath, new StringContent("{}"))).Dispose();
|
||||
using (var plain = new HttpClient(new SocketsHttpHandler()))
|
||||
(await plain.PostAsync(secretPath + "?unmarked=1", new StringContent("{}"))).Dispose();
|
||||
await server;
|
||||
|
||||
var rows = channel.Items.OfType<DependencyTelemetry>().Where(d => d.Target?.EndsWith(":" + port) == true).ToList();
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.Contains(rows, d => d.Data == "http://127.0.0.1" && d.Name == "POST http://127.0.0.1");
|
||||
Assert.Contains(rows, d => d.Data == secretPath + "?unmarked=1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(WebhookNotificationService))]
|
||||
[InlineData(PushHttpClientRegistration.ClientName)]
|
||||
public void Both_credential_clients_carry_the_mark(string clientName)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ResiliencePolicies>();
|
||||
services.AddWebhookNotificationHttpClient();
|
||||
services.AddPushSender();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var handlers = new List<HttpMessageHandler>();
|
||||
for (HttpMessageHandler? handler = provider.GetRequiredService<IHttpMessageHandlerFactory>().CreateHandler(clientName);
|
||||
handler != null;
|
||||
handler = (handler as DelegatingHandler)?.InnerHandler)
|
||||
{
|
||||
handlers.Add(handler);
|
||||
}
|
||||
|
||||
Assert.Single(handlers.OfType<CredentialUrlRedactionProcessor.MarkingHandler>());
|
||||
}
|
||||
|
||||
/// <summary>Answers each of <paramref name="connections"/> requests with 204 and closes the connection.</summary>
|
||||
private static async Task AnswerNoContentAsync(TcpListener listener, int connections)
|
||||
{
|
||||
for (var i = 0; i < connections; i++)
|
||||
{
|
||||
using var socket = await listener.AcceptTcpClientAsync();
|
||||
var stream = socket.GetStream();
|
||||
var buffer = new byte[8192];
|
||||
var received = new StringBuilder();
|
||||
while (!received.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer);
|
||||
if (read == 0) break;
|
||||
received.Append(Encoding.ASCII.GetString(buffer, 0, read));
|
||||
}
|
||||
// The request body ("{}") follows the headers; the 204 does not wait for it.
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,9 +101,8 @@ public class DeviceAssociationValidatorTests
|
||||
Assert.Equal("preassociated", result.AssociationState);
|
||||
Assert.Equal("00000000-0000-0000-0000-000000000000", result.DevicePreparationPolicyId);
|
||||
Assert.Equal("admin@contoso.com", result.PreAssociatedByUserPrincipalName);
|
||||
// Newtonsoft.Json yields "" for JSON null when calling .ToString() on the JToken;
|
||||
// downstream telemetry uses IsNullOrEmpty so both null and "" surface as "absent".
|
||||
Assert.True(string.IsNullOrEmpty(result.AssignedToUserPrincipalName));
|
||||
// JSON null is absence (D-420); the former Newtonsoft reader turned it into "".
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
Assert.NotNull(result.PreAssociationDateTime);
|
||||
Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel)
|
||||
}
|
||||
|
||||
@@ -640,4 +640,116 @@ public class DistressValidationTests
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), ReportDistressFunction.MaxTimestampFuture);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ResolveErrorType — the agent reports every backend 403 as DeviceNotRegistered; the
|
||||
// hardware whitelist runs before the registration lookup, so a 403 for hardware outside
|
||||
// the whitelist is stored as HardwareNotAllowed.
|
||||
// =========================================================================
|
||||
|
||||
private const string ResolveTenant = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
private static TenantConfiguration ValidatingTenant(Action<TenantConfiguration>? change = null)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(ResolveTenant);
|
||||
config.ValidateAutopilotDevice = true; // CreateDefault starts with every validation off
|
||||
change?.Invoke(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
private static DistressErrorType Resolve(
|
||||
TenantConfiguration config, string? manufacturer = "VMware, Inc.", string? model = "VMware20,1",
|
||||
DistressErrorType reported = DistressErrorType.DeviceNotRegistered, int? http = 403,
|
||||
DistressCertSourceState? certState = null)
|
||||
=> ReportDistressFunction.ResolveErrorType(reported, http, manufacturer, model, certState, config);
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(ValidatingTenant()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ModelOutsideTheWhitelist_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ModelWhitelist = "Latitude*");
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config, "Dell Inc.", "OptiPlex 7090"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell Inc.", "Latitude 5440")]
|
||||
[InlineData("LENOVO", "21HD")] // wildcards match case-insensitively
|
||||
[InlineData("HP", "EliteBook 840 G10")]
|
||||
[InlineData("Microsoft Corporation", "Surface Laptop 5")]
|
||||
public void ResolveErrorType_WhitelistedHardware_StaysDeviceNotRegistered(string manufacturer, string model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_ManufacturerAddedToTheWhitelist_StaysDeviceNotRegistered()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ManufacturerWhitelist += ",VMware*");
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "VMware20,1")]
|
||||
[InlineData("VMware, Inc.", null)]
|
||||
[InlineData("", "")]
|
||||
public void ResolveErrorType_WithoutManufacturerOrModel_StaysAsReported(string? manufacturer, string? model)
|
||||
{
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(ValidatingTenant(), manufacturer, model));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_SuspendedTenant_StaysAsReported()
|
||||
{
|
||||
// The validator refuses a suspended tenant before it looks at the hardware.
|
||||
var config = ValidatingTenant(c => c.Disabled = true);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TenantWithoutValidation_StaysAsReported()
|
||||
{
|
||||
var config = ValidatingTenant(c => c.ValidateAutopilotDevice = false);
|
||||
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_TestTenantWithoutValidationButInsecureRequests_IsHardwareNotAllowed()
|
||||
{
|
||||
var config = ValidatingTenant(c =>
|
||||
{
|
||||
c.ValidateAutopilotDevice = false;
|
||||
c.AllowInsecureAgentRequests = true;
|
||||
});
|
||||
|
||||
Assert.Equal(DistressErrorType.HardwareNotAllowed, Resolve(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveErrorType_NoCertificateInStore_StaysAsReported()
|
||||
{
|
||||
// Without a certificate the agent authenticates with a bootstrap token, which skips the
|
||||
// hardware check.
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered,
|
||||
Resolve(ValidatingTenant(), certState: DistressCertSourceState.NoCertInStore));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DistressErrorType.AuthCertificateRejected, 401)]
|
||||
[InlineData(DistressErrorType.TpmPssUnsupported, 403)]
|
||||
[InlineData(DistressErrorType.ClientCertificateNotReceived, 403)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, 401)]
|
||||
[InlineData(DistressErrorType.DeviceNotRegistered, null)]
|
||||
public void ResolveErrorType_OtherTypesAndStatusCodes_StayAsReported(DistressErrorType reported, int? http)
|
||||
{
|
||||
Assert.Equal(reported, Resolve(ValidatingTenant(), reported: reported, http: http));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ public class GetHardwareRejectedFunctionTests
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("HardwareNotAllowed", "Dell", "Latitude", "SN1", T0),
|
||||
MakeEntry("CertificateExpired", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("Timeout", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
MakeEntry("DeviceNotRegistered", "Dell", "Latitude", "SN2", T0.AddMinutes(1)),
|
||||
MakeEntry("TpmPssUnsupported", "HP", "EliteBook", "SN3", T0.AddMinutes(2)),
|
||||
};
|
||||
|
||||
var (aggregated, totalRawReports) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
@@ -64,6 +64,11 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"graphAccessRejected",
|
||||
"intuneDeviceBindingPermissionMissing",
|
||||
"isTrial",
|
||||
// Non-sensitive by review: booleans only; who hid the guide and when stays in the
|
||||
// admin-gated config. hasEnrollmentSessions is absent here: unknown → omitted.
|
||||
"setupGuideDismissed",
|
||||
"setupGuideHardwareDone",
|
||||
"setupGuideSettingsDone",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
|
||||
@@ -520,4 +525,53 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
|
||||
Assert.DoesNotContain("Secret Holdings", json);
|
||||
}
|
||||
|
||||
// ── setup guide ──
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NewTenantHasEveryStepOpen()
|
||||
{
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(TenantConfiguration.CreateDefault("t"), Now, hasEnrollmentSessions: false);
|
||||
|
||||
Assert.False(payload.SetupGuideSettingsDone);
|
||||
Assert.False(payload.SetupGuideHardwareDone);
|
||||
Assert.False(payload.SetupGuideDismissed);
|
||||
Assert.False(payload.HasEnrollmentSessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_ReflectsMarkersAndDismissal()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideSettingsReviewedAt = Now;
|
||||
config.SetupGuideHardwareReviewedAt = Now;
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "admin@contoso.com";
|
||||
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||
|
||||
Assert.True(payload.SetupGuideSettingsDone);
|
||||
Assert.True(payload.SetupGuideHardwareDone);
|
||||
Assert.True(payload.SetupGuideDismissed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_UnknownEnrollmentHistoryIsOmitted()
|
||||
{
|
||||
var element = Serialize(TenantConfiguration.CreateDefault("t"));
|
||||
|
||||
Assert.False(element.TryGetProperty("hasEnrollmentSessions", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_SetupGuide_NeverLeaksWhoDismissedIt()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideDismissedAt = Now;
|
||||
config.SetupGuideDismissedBy = "hidden.admin@contoso.com";
|
||||
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("hidden.admin", json);
|
||||
}
|
||||
}
|
||||
|
||||
+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,658 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The contract of the Graph and Entra token-endpoint response parsers (D-420): what every realistic body,
|
||||
/// and every malformed one, yields. Written against the Newtonsoft parsers before their move to
|
||||
/// System.Text.Json and kept as their specification. Tests whose name ends in _D1 to _D7 pin a
|
||||
/// deliberate difference of that move (listed in D-420).
|
||||
/// </summary>
|
||||
public class GraphResponseParsingTests
|
||||
{
|
||||
private const string Serial = "PF3XKQ7";
|
||||
private const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
|
||||
|
||||
// ── GraphTokenService.ReadTokenResponse ─────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"access_token":"t","expires_in":3599}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t","expires_in":"3599"}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t","expires_in":" 3599 "}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t","expires_in":"+3599"}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t",/*c*/"expires_in":3599,}""", 3599L)]
|
||||
[InlineData("""{"access_token":"t"}""", null)]
|
||||
[InlineData("""{"access_token":"t","expires_in":null}""", null)]
|
||||
public void Token_response_reads_the_token_and_its_lifetime(string body, long? expiresIn)
|
||||
{
|
||||
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
|
||||
Assert.Equal("t", token);
|
||||
Assert.Equal(expiresIn, lifetime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{"access_token":""}""")]
|
||||
[InlineData("""{"access_token":null,"expires_in":"abc"}""")]
|
||||
public void Token_response_without_a_token_yields_none_and_ignores_the_lifetime(string body)
|
||||
{
|
||||
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
|
||||
Assert.True(string.IsNullOrEmpty(token));
|
||||
Assert.Null(lifetime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<html>busy</html>")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("""{"access_token":"t"} x""")]
|
||||
public void Token_response_that_is_not_one_object_throws(string body)
|
||||
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse(body));
|
||||
|
||||
// D5: an expires_in that is not a whole number counts as absent; the token stays usable with the
|
||||
// default lifetime. Newtonsoft threw on the first five and coerced the last two (3600, 1).
|
||||
[Theory]
|
||||
[InlineData("\"abc\"")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("99999999999999999999")]
|
||||
[InlineData("3599.5")]
|
||||
[InlineData("true")]
|
||||
public void Token_response_with_an_unusable_lifetime_keeps_the_token_D5(string expiresIn)
|
||||
{
|
||||
var (token, lifetime) = GraphTokenService.ReadTokenResponse($$"""{"access_token":"t","expires_in":{{expiresIn}}}""");
|
||||
Assert.Equal("t", token);
|
||||
Assert.Null(lifetime);
|
||||
}
|
||||
|
||||
// ── EntraTokenRejection.Parse ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"error_codes":[7000229],"error_description":"AADSTS7000229: missing"}""", "AADSTS7000229")]
|
||||
[InlineData("""{"error_codes":["7000229",53003]}""", "AADSTS53003")]
|
||||
[InlineData("""{"error_codes":[7000229.0],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
|
||||
[InlineData("""{"error_codes":7000229,"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
|
||||
[InlineData("""{"error_codes":[],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
|
||||
[InlineData("""{"error_codes":[1],"error_codes":[7000229]}""", "AADSTS7000229")]
|
||||
[InlineData("""{"error_codes":[7000229,],/*c*/}""", "AADSTS7000229")]
|
||||
public void Entra_rejection_reads_the_first_integer_code_then_the_description(string body, string code)
|
||||
=> Assert.Equal(code, EntraTokenRejection.Parse(400, body).ErrorCode);
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("\"AADSTS7000229\"")]
|
||||
[InlineData("42")]
|
||||
[InlineData("[1,2]")]
|
||||
[InlineData("<html>busy</html>")]
|
||||
[InlineData("""{"error_description":null}""")]
|
||||
[InlineData("""{"error_description":42}""")]
|
||||
[InlineData("""{"error_codes":[7000229]} x""")]
|
||||
public void Entra_rejection_without_a_readable_object_keeps_only_the_status(string? body)
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, body);
|
||||
Assert.Equal(400, rejection.StatusCode);
|
||||
Assert.Null(rejection.ErrorCode);
|
||||
Assert.Null(rejection.Description);
|
||||
}
|
||||
|
||||
// D3: a description is kept as sent, even when it looks like a date (Newtonsoft dropped it).
|
||||
[Fact]
|
||||
public void Entra_rejection_keeps_a_date_shaped_description_D3()
|
||||
=> Assert.Equal("2026-11-01T08:00:00Z", EntraTokenRejection.Parse(400, """{"error_description":"2026-11-01T08:00:00Z"}""").Description);
|
||||
|
||||
// D6: an error code beyond Int64 is skipped like any other unusable entry (Newtonsoft threw).
|
||||
[Fact]
|
||||
public void Entra_rejection_skips_an_out_of_range_code_D6()
|
||||
=> Assert.Equal("AADSTS53003", EntraTokenRejection.Parse(400, """{"error_codes":[99999999999999999999,53003]}""").ErrorCode);
|
||||
|
||||
// ── AutopilotDeviceValidator.FindRegistration ───────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("""{"value":"oops"}""")]
|
||||
[InlineData("""{"value":[]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":null}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
|
||||
public void Autopilot_list_without_the_serial_is_not_registered(string body)
|
||||
=> Assert.Null(AutopilotDeviceValidator.FindRegistration(body, Serial));
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("""{"value":[]} x""")]
|
||||
public void Autopilot_list_that_is_not_one_object_throws(string body)
|
||||
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(body, Serial));
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[null,{"id":"ap-1","serialNumber":"PF3XKQ7"}]}""", Serial));
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_matches_the_trimmed_serial_case_insensitively_and_stops_there()
|
||||
{
|
||||
var registration = AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[{"id":"ap-1","serialNumber":" pf3xkq7 ","groupTag":" Sales ","purchaseOrderIdentifier":"PO-7","displayName":"KIOSK-1","userPrincipalName":"u@contoso.com"},null]}""",
|
||||
Serial);
|
||||
|
||||
Assert.NotNull(registration);
|
||||
Assert.Equal("ap-1", registration!.DeviceId);
|
||||
Assert.Equal("Sales", registration.GroupTag);
|
||||
Assert.Equal("PO-7", registration.PurchaseOrderId);
|
||||
Assert.Equal("KIOSK-1", registration.DisplayName);
|
||||
Assert.True(registration.UserAssigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_registration_drops_blank_and_non_text_values_and_cuts_long_text()
|
||||
{
|
||||
var longText = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 10);
|
||||
var registration = AutopilotDeviceValidator.FindRegistration(
|
||||
$$"""{"value":[{"id":" ","serialNumber":"PF3XKQ7","groupTag":true,"purchaseOrderIdentifier":{},"displayName":"{{longText}}","userPrincipalName":null}]}""",
|
||||
Serial);
|
||||
|
||||
Assert.NotNull(registration);
|
||||
Assert.Null(registration!.DeviceId);
|
||||
Assert.Null(registration.GroupTag);
|
||||
Assert.Null(registration.PurchaseOrderId);
|
||||
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, registration.DisplayName!.Length);
|
||||
Assert.False(registration.UserAssigned);
|
||||
}
|
||||
|
||||
// D4: a serial that is not text never matches (Newtonsoft matched through its text).
|
||||
[Fact]
|
||||
public void Autopilot_list_with_a_numeric_serial_does_not_match_D4()
|
||||
=> Assert.Null(AutopilotDeviceValidator.FindRegistration("""{"value":[{"id":"ap-1","serialNumber":12345}]}""", "12345"));
|
||||
|
||||
// D3: registration text is kept as sent, even when it looks like a date (Newtonsoft dropped it).
|
||||
[Fact]
|
||||
public void Autopilot_registration_keeps_date_shaped_text_D3()
|
||||
{
|
||||
var registration = AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7","groupTag":"2026-01-01T00:00:00Z","displayName":"/Date(1713096021000)/"}]}""",
|
||||
Serial);
|
||||
|
||||
Assert.Equal("2026-01-01T00:00:00Z", registration!.GroupTag);
|
||||
Assert.Equal("/Date(1713096021000)/", registration.DisplayName);
|
||||
}
|
||||
|
||||
// ── CorporateIdentifierValidator.ReadIdentityPage ───────────────────────────
|
||||
|
||||
private static readonly string CorporateId =
|
||||
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "")]
|
||||
[InlineData("null", "")]
|
||||
[InlineData("{}", "")]
|
||||
[InlineData("""{"value":null}""", "")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":""}""", "")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":null}""", "")]
|
||||
[InlineData("""{"value":{},"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""", "https://graph.microsoft.com/beta/next")]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"1234-5678"}]}""", "")]
|
||||
public void Corporate_page_without_the_identifier_yields_the_next_url(string body, string nextUrl)
|
||||
=> Assert.Equal((false, nextUrl), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}],"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""")]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"MANUFACTURERMODELSERIAL","importedDeviceIdentifier":"MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"}]}""")]
|
||||
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":null},{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""")]
|
||||
public void Corporate_page_with_the_identifier_is_found(string body)
|
||||
=> Assert.Equal((true, string.Empty), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[]")]
|
||||
public void Corporate_page_that_is_not_one_object_throws(string body)
|
||||
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
|
||||
|
||||
[Fact]
|
||||
public void Corporate_page_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => CorporateIdentifierValidator.ReadIdentityPage(
|
||||
"""{"value":[null,{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""",
|
||||
CorporateId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("5")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{}")]
|
||||
public void Corporate_page_with_a_non_text_next_link_never_yields_a_page_to_follow(string nextLink)
|
||||
{
|
||||
// The scan must stop as a failure: either the read throws or the url cannot be requested.
|
||||
try
|
||||
{
|
||||
var (found, nextUrl) = CorporateIdentifierValidator.ReadIdentityPage($$"""{"value":[],"@odata.nextLink":{{nextLink}}}""", CorporateId);
|
||||
Assert.False(found);
|
||||
Assert.False(string.IsNullOrEmpty(nextUrl));
|
||||
Assert.False(Uri.IsWellFormedUriString(nextUrl, UriKind.Absolute));
|
||||
}
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
|
||||
{
|
||||
// Throwing ends the scan as transient, like an unusable url does.
|
||||
}
|
||||
}
|
||||
|
||||
// D4: an identifier that is not text never matches (Newtonsoft matched an array through its text).
|
||||
[Fact]
|
||||
public void Corporate_page_with_an_array_identifier_does_not_match_D4()
|
||||
=> Assert.False(CorporateIdentifierValidator.ReadIdentityPage(
|
||||
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":["Microsoft Corporation,Virtual Machine,1234-5678"]}]}""",
|
||||
CorporateId).Found);
|
||||
|
||||
// ── DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse ─────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("{} x")]
|
||||
[InlineData("""{"value":null}""")]
|
||||
[InlineData("""{"value":{}}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
|
||||
public void Association_list_without_the_serial_is_not_found(string body)
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Association_list_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[null,{"serialNumber":"PF3XKQ7"}]}""", Serial));
|
||||
|
||||
[Fact]
|
||||
public void Association_list_reads_the_matching_device_and_stops_there()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":" pf3xkq7 ","associationState":"associated","devicePreparationPolicyId":"pol-1","preassociatedByUserPrincipalName":"a@contoso.com","assignedToUserPrincipalName":"u@contoso.com","managedDeviceId":"md-1","preassociationDateTime":"2026-05-08T12:16:49Z"},null]}""",
|
||||
Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("associated", result.AssociationState);
|
||||
Assert.Equal("pol-1", result.DevicePreparationPolicyId);
|
||||
Assert.Equal("a@contoso.com", result.PreAssociatedByUserPrincipalName);
|
||||
Assert.Equal("u@contoso.com", result.AssignedToUserPrincipalName);
|
||||
Assert.Equal("md-1", result.ManagedDeviceId);
|
||||
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc), result.PreAssociationDateTime);
|
||||
Assert.Equal(DateTimeKind.Utc, result.PreAssociationDateTime!.Value.Kind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7"}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":null}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":""}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"0001-01-01T00:00:00Z"}]}""")]
|
||||
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"not-a-date"}]}""")]
|
||||
public void Association_date_that_is_unset_reads_as_null(string body)
|
||||
=> Assert.Null(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial).AssociationDateTime);
|
||||
|
||||
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void Association_fields_that_are_json_null_read_as_null_D1()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":"PF3XKQ7","associationState":null,"assignedToUserPrincipalName":null,"managedDeviceId":null}]}""",
|
||||
Serial);
|
||||
Assert.Null(result.AssociationState);
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
Assert.Null(result.ManagedDeviceId);
|
||||
}
|
||||
|
||||
// D4: text whose escapes are no valid UTF-16 reads as absent, in the serial of another entry and in a field
|
||||
// of the match, instead of failing the whole lookup (Newtonsoft read it into a broken string).
|
||||
[Fact]
|
||||
public void Association_text_with_a_broken_escape_reads_as_null_D4()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":"\ud800"},{"serialNumber":"PF3XKQ7","assignedToUserPrincipalName":"u\ud800"}]}""", Serial);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.AssignedToUserPrincipalName);
|
||||
}
|
||||
|
||||
// D2: the date keeps its fraction, in any culture (Newtonsoft cut it to whole seconds).
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("en-GB")]
|
||||
[InlineData("th-TH")]
|
||||
public void Association_date_keeps_its_fraction_in_any_culture_D2(string culture)
|
||||
{
|
||||
var date = InCulture(culture, () => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"2026-05-08T12:16:49.1234567Z"}]}""",
|
||||
Serial).PreAssociationDateTime);
|
||||
|
||||
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc).AddTicks(1234567), date);
|
||||
Assert.Equal(DateTimeKind.Utc, date!.Value.Kind);
|
||||
}
|
||||
|
||||
// ── CloudPcDeviceValidator.ParseCloudPcResponse ─────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("""{"value":[]} x""")]
|
||||
[InlineData("""{"value":null}""")]
|
||||
[InlineData("""{"value":[{"managedDeviceId":"other"}]}""")]
|
||||
public void CloudPc_list_without_the_device_is_not_found(string body)
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, IntuneId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_with_a_null_entry_before_the_match_throws()
|
||||
=> Assert.Throws<InvalidOperationException>(() => CloudPcDeviceValidator.ParseCloudPcResponse(
|
||||
$$"""{"value":[null,{"managedDeviceId":"{{IntuneId}}"}]}""", IntuneId));
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_reads_the_matching_cloud_pc()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
|
||||
$$"""{"value":[{"id":"cpc-1","managedDeviceId":" {{IntuneId.ToUpperInvariant()}} ","managedDeviceName":"CPC-ADM-1","servicePlanName":"Cloud PC Enterprise"},null]}""",
|
||||
IntuneId);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("cpc-1", result.CloudPcId);
|
||||
Assert.Equal("CPC-ADM-1", result.ManagedDeviceName);
|
||||
Assert.Equal("Cloud PC Enterprise", result.ServicePlanName);
|
||||
}
|
||||
|
||||
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void CloudPc_fields_that_are_json_null_read_as_null_D1()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
|
||||
$$"""{"value":[{"managedDeviceId":"{{IntuneId}}","id":null,"managedDeviceName":null,"servicePlanName":null}]}""", IntuneId);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Null(result.CloudPcId);
|
||||
Assert.Null(result.ManagedDeviceName);
|
||||
Assert.Null(result.ServicePlanName);
|
||||
}
|
||||
|
||||
// ── IntuneDeviceBindingValidator.ParseManagedDeviceResponse ─────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("""[{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"}]""")]
|
||||
[InlineData("""{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"} x""")]
|
||||
[InlineData("""{"id":null}""")]
|
||||
[InlineData("""{"id":42}""")]
|
||||
[InlineData("""{"id":"other"}""")]
|
||||
public void Managed_device_that_is_not_the_requested_one_is_not_found(string body)
|
||||
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, IntuneId).Outcome);
|
||||
|
||||
[Theory]
|
||||
[InlineData("\"managed\"", IntuneDeviceBindingOutcome.Match)]
|
||||
[InlineData("null", IntuneDeviceBindingOutcome.Match)]
|
||||
[InlineData("\"retirePending\"", IntuneDeviceBindingOutcome.NotManaged)]
|
||||
[InlineData("\"WIPEISSUED\"", IntuneDeviceBindingOutcome.NotManaged)]
|
||||
[InlineData("\"deletePending\"", IntuneDeviceBindingOutcome.NotManaged)]
|
||||
public void Managed_device_on_its_way_out_is_not_managed(string managementState, IntuneDeviceBindingOutcome outcome)
|
||||
=> Assert.Equal(outcome, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":" {{IntuneId.ToUpperInvariant()}} ","managementState":{{managementState}}}""", IntuneId).Outcome);
|
||||
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("false", false)]
|
||||
[InlineData("null", null)]
|
||||
[InlineData("\"true\"", null)]
|
||||
[InlineData("1", null)]
|
||||
public void Managed_device_registration_flag_is_strictly_boolean(string value, bool? expected)
|
||||
=> Assert.Equal(expected, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","azureADRegistered":{{value}}}""", IntuneId).AzureAdRegistered);
|
||||
|
||||
[Theory]
|
||||
[InlineData("null")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
public void Managed_device_telemetry_fields_that_are_blank_read_as_null(string value)
|
||||
{
|
||||
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","serialNumber":{{value}},"managedDeviceOwnerType":{{value}},"deviceEnrollmentType":{{value}},"enrolledDateTime":{{value}}}""",
|
||||
IntuneId);
|
||||
Assert.Null(result.SerialNumber);
|
||||
Assert.Null(result.OwnerType);
|
||||
Assert.Null(result.EnrollmentType);
|
||||
Assert.Null(result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_reads_its_fields()
|
||||
{
|
||||
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","deviceName":"DESKTOP-1","azureADDeviceId":"aad-1","managementState":"managed","serialNumber":"PF3XKQ7","managedDeviceOwnerType":"company","deviceEnrollmentType":"windowsAzureADJoin","enrolledDateTime":"2026-05-08T12:16:49Z"}""",
|
||||
IntuneId);
|
||||
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
|
||||
Assert.Equal("DESKTOP-1", result.DeviceName);
|
||||
Assert.Equal("aad-1", result.AzureAdDeviceId);
|
||||
Assert.Equal("PF3XKQ7", result.SerialNumber);
|
||||
Assert.Equal("company", result.OwnerType);
|
||||
Assert.Equal("windowsAzureADJoin", result.EnrollmentType);
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero), result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_unset_enrollment_date_reads_as_null()
|
||||
=> Assert.Null(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"0001-01-01T00:00:00Z"}""", IntuneId).EnrolledDateTime);
|
||||
|
||||
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void Managed_device_fields_that_are_json_null_read_as_null_D1()
|
||||
{
|
||||
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","deviceName":null,"azureADDeviceId":null,"managementState":null}""", IntuneId);
|
||||
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
|
||||
Assert.Null(result.DeviceName);
|
||||
Assert.Null(result.AzureAdDeviceId);
|
||||
Assert.Null(result.ManagementState);
|
||||
}
|
||||
|
||||
// D2: the enrollment date keeps its fraction and its day and month in any culture (Newtonsoft cut the
|
||||
// fraction and swapped day and month on day-first cultures).
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("en-GB")]
|
||||
[InlineData("th-TH")]
|
||||
public void Managed_device_enrollment_date_is_the_same_in_any_culture_D2(string culture)
|
||||
{
|
||||
var date = InCulture(culture, () => IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"2026-05-08T12:16:49.1234567Z"}""", IntuneId).EnrolledDateTime);
|
||||
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), date);
|
||||
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
|
||||
}
|
||||
|
||||
// D4: an id that is not text never matches (Newtonsoft matched through its text).
|
||||
[Fact]
|
||||
public void Managed_device_with_a_numeric_id_is_not_found_D4()
|
||||
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound,
|
||||
IntuneDeviceBindingValidator.ParseManagedDeviceResponse("""{"id":12345}""", "12345").Outcome);
|
||||
|
||||
// ── Dates in any offset (new with System.Text.Json) ─────────────────────────
|
||||
|
||||
// Newtonsoft turned an offset date into machine-local time and read that back as UTC, so the instant
|
||||
// depended on the host's time zone; it was only right on a UTC host.
|
||||
[Theory]
|
||||
[InlineData("2026-05-08T14:16:49+02:00")]
|
||||
[InlineData("2026-05-08T07:16:49-05:00")]
|
||||
[InlineData("2026-05-08T12:16:49Z")]
|
||||
[InlineData("2026-05-08T12:16:49")]
|
||||
public void Dates_in_any_offset_read_as_the_same_utc_instant(string value)
|
||||
{
|
||||
var expected = new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc);
|
||||
|
||||
var association = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
|
||||
$$"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"{{value}}"}]}""", Serial).PreAssociationDateTime;
|
||||
var enrolled = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"{{value}}"}""", IntuneId).EnrolledDateTime;
|
||||
|
||||
Assert.Equal(expected, association);
|
||||
Assert.Equal(DateTimeKind.Utc, association!.Value.Kind);
|
||||
Assert.Equal(new DateTimeOffset(expected), enrolled);
|
||||
Assert.Equal(TimeSpan.Zero, enrolled!.Value.Offset);
|
||||
}
|
||||
|
||||
// ── ScriptDisplayNameResolver.ReadScriptPage / ReadScriptDisplayName ────────
|
||||
|
||||
[Fact]
|
||||
public void Script_page_keeps_object_entries_with_an_id_and_the_next_url()
|
||||
{
|
||||
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(
|
||||
"""{"value":[1,"x",null,[],{"displayName":"no id"},{"id":null},{"id":""},{"id":"s1","displayName":"A","fileName":"a.ps1"},{"id":"s2","displayName":"B"}],"@odata.nextLink":"https://graph.microsoft.com/beta/p2"}""",
|
||||
withFileName: true);
|
||||
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
new ScriptDisplayNameResolver.ScriptListItem("s1", "A", "a.ps1"),
|
||||
new ScriptDisplayNameResolver.ScriptListItem("s2", "B", null),
|
||||
}, scripts);
|
||||
Assert.Equal("https://graph.microsoft.com/beta/p2", next);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"value":[{"id":"s1"}]}""")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":null}""")]
|
||||
[InlineData("""{"value":[],"@odata.nextLink":""}""")]
|
||||
[InlineData("""{"value":"oops"}""")]
|
||||
[InlineData("{}")]
|
||||
public void Script_page_without_a_next_link_ends_the_pull(string body)
|
||||
=> Assert.True(string.IsNullOrEmpty(ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false).NextUrl));
|
||||
|
||||
[Fact]
|
||||
public void Script_page_of_health_scripts_reads_no_file_name()
|
||||
=> Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
|
||||
"""{"value":[{"id":"s1","displayName":"A","fileName":"a.ps1"}]}""", withFileName: false).Scripts).FileName);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("null")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("not json")]
|
||||
public void Script_page_or_name_that_is_not_one_object_throws(string body)
|
||||
{
|
||||
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false));
|
||||
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptDisplayName(body));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"displayName":"Install agent"}""", "Install agent")]
|
||||
[InlineData("""{"id":"s1"}""", null)]
|
||||
public void Script_name_reads_the_display_name(string body, string? name)
|
||||
=> Assert.Equal(name, ScriptDisplayNameResolver.ReadScriptDisplayName(body));
|
||||
|
||||
// D1: a display name that is JSON null is absent (Newtonsoft read it as an empty string).
|
||||
[Fact]
|
||||
public void Script_name_that_is_json_null_reads_as_null_D1()
|
||||
{
|
||||
Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":null}"""));
|
||||
Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
|
||||
"""{"value":[{"id":"s1","displayName":null}]}""", withFileName: false).Scripts).DisplayName);
|
||||
}
|
||||
|
||||
// D3: a display name is kept as sent in any culture, even when it looks like a date (Newtonsoft
|
||||
// rewrote it in the current culture, "1/1/2026 12:00:00 AM" in en-US).
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("th-TH")]
|
||||
public void Script_name_shaped_like_a_date_is_kept_as_sent_D3(string culture)
|
||||
=> Assert.Equal("2026-01-01T00:00:00Z", InCulture(culture, () =>
|
||||
ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":"2026-01-01T00:00:00Z"}""")));
|
||||
|
||||
// D4: a display name that is not text is absent (Newtonsoft read a number as its text).
|
||||
[Fact]
|
||||
public void Script_name_that_is_a_number_reads_as_null_D4()
|
||||
=> Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":42}"""));
|
||||
|
||||
// ── D7: a body cut off mid-object is unreadable ─────────────────────────────
|
||||
|
||||
// Newtonsoft's DeserializeObject read the part before the cut as if the open containers were closed, so a
|
||||
// cut-off list could still match. Every parser now answers it like any other unreadable body. HttpClient
|
||||
// throws on a premature end before a parser sees the body, so only a server-side cut can get here.
|
||||
|
||||
[Fact]
|
||||
public void Token_response_cut_off_throws_D7()
|
||||
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse("""{"access_token":"t","expires_in":3599"""));
|
||||
|
||||
[Fact]
|
||||
public void Entra_rejection_cut_off_keeps_only_the_status_D7()
|
||||
{
|
||||
var rejection = EntraTokenRejection.Parse(400, """{"error_codes":[53003""");
|
||||
Assert.Null(rejection.ErrorCode);
|
||||
Assert.Null(rejection.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_cut_off_throws_D7()
|
||||
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(
|
||||
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7"}""", Serial));
|
||||
|
||||
[Fact]
|
||||
public void Corporate_page_cut_off_throws_D7()
|
||||
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(
|
||||
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}""",
|
||||
CorporateId));
|
||||
|
||||
[Fact]
|
||||
public void Association_list_cut_off_is_not_found_D7()
|
||||
{
|
||||
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse("""{"value":[{"serialNumber":"PF3XKQ7"}""", Serial);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_cut_off_is_not_found_D7()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse($$"""{"value":[{"managedDeviceId":"{{IntuneId}}"}""", IntuneId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_cut_off_is_not_found_D7()
|
||||
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{"id":"{{IntuneId}}","azureADRegistered":true""", IntuneId).Outcome);
|
||||
|
||||
private static T InCulture<T>(string culture, Func<T> read)
|
||||
{
|
||||
var saved = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
||||
return read();
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public class GraphTokenServiceTests
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler)
|
||||
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler, IMemoryCache? cache = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
@@ -33,7 +33,7 @@ public class GraphTokenServiceTests
|
||||
})
|
||||
.Build();
|
||||
|
||||
var svc = NewService(config, new StubHttpClientFactory(handler));
|
||||
var svc = NewService(config, new StubHttpClientFactory(handler), cache: cache);
|
||||
|
||||
return (svc, handler);
|
||||
}
|
||||
@@ -47,11 +47,12 @@ public class GraphTokenServiceTests
|
||||
IConfiguration config,
|
||||
System.Net.Http.IHttpClientFactory factory,
|
||||
IGraphAccessHealthRecorder? accessHealth = null,
|
||||
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null) =>
|
||||
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null,
|
||||
IMemoryCache? cache = null) =>
|
||||
new GraphTokenService(
|
||||
logger ?? NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
cache ?? new MemoryCache(new MemoryCacheOptions()),
|
||||
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
@@ -131,6 +132,53 @@ public class GraphTokenServiceTests
|
||||
Assert.Equal(1, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
// The token stays cached for expires_in minus the 5-minute skew, clamped to 60 s .. 55 min. An absent
|
||||
// or unusable expires_in (D5 in D-420: text, a fraction, zero or less) falls back to the 35-minute
|
||||
// default, so 30 minutes; a value far beyond the cap is capped instead of overflowing.
|
||||
[Theory]
|
||||
[InlineData("3599", 3299)]
|
||||
[InlineData("\"3599\"", 3299)]
|
||||
[InlineData("7200", 3300)]
|
||||
[InlineData("120", 60)]
|
||||
[InlineData("null", 1800)]
|
||||
[InlineData("\"abc\"", 1800)]
|
||||
[InlineData("3599.5", 1800)]
|
||||
[InlineData("0", 1800)]
|
||||
[InlineData("-5", 1800)]
|
||||
[InlineData("9223372036854775807", 3300)]
|
||||
public async Task Token_cache_lifetime_follows_expires_in(string expiresIn, int seconds)
|
||||
{
|
||||
var cache = new RecordingMemoryCache();
|
||||
var (svc, _) = Create(new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, $$"""{"access_token":"tok-A","expires_in":{{expiresIn}}}"""), cache);
|
||||
|
||||
Assert.Equal("tok-A", (await svc.GetAccessTokenAsync(TenantA)).AccessToken);
|
||||
|
||||
var entry = Assert.Single(cache.Entries, e => e.Key is string key && key.StartsWith($"graph-token:{TenantA}:", StringComparison.Ordinal));
|
||||
Assert.Equal(TimeSpan.FromSeconds(seconds), entry.AbsoluteExpirationRelativeToNow);
|
||||
}
|
||||
|
||||
/// <summary>A memory cache that keeps every entry it creates, so a test can read the lifetime the service chose.</summary>
|
||||
private sealed class RecordingMemoryCache : IMemoryCache
|
||||
{
|
||||
private readonly MemoryCache _inner = new(new MemoryCacheOptions());
|
||||
|
||||
public List<ICacheEntry> Entries { get; } = new();
|
||||
|
||||
public ICacheEntry CreateEntry(object key)
|
||||
{
|
||||
var entry = _inner.CreateEntry(key);
|
||||
Entries.Add(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
public bool TryGetValue(object key, out object? value) => _inner.TryGetValue(key, out value);
|
||||
|
||||
public void Remove(object key) => _inner.Remove(key);
|
||||
|
||||
public void Dispose() => _inner.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidateTenant_forces_fresh_token_on_next_call()
|
||||
{
|
||||
@@ -451,7 +499,7 @@ public class GraphTokenServiceTests
|
||||
logger.Verify(l => l.Log(
|
||||
Microsoft.Extensions.Logging.LogLevel.Warning,
|
||||
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
|
||||
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
|
||||
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state != null && state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
|
||||
Moq.It.IsAny<Exception?>(),
|
||||
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
|
||||
}
|
||||
|
||||
@@ -235,6 +235,26 @@ public class GrowthMetricsBuilderTests
|
||||
Assert.Null(a.MedianDaysToFirstEnrollment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_yesterday_is_aggregated_yesterdays_counters_count_as_activity_and_the_rows_end_the_day_before()
|
||||
{
|
||||
// Sessions yesterday after a 40-day pause; the first maintenance run after midnight has not written them yet.
|
||||
var configs = new[] { Config('a', Utc(6, 1, 9)) };
|
||||
var stats = new[] { Stats('a', 6, 5, Utc(10, 27, 22)) };
|
||||
var rows = new[] { Active('a', 2026, 9, 17) };
|
||||
|
||||
var early = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions, yesterdayAggregated: false);
|
||||
|
||||
Assert.Equal("2026-10-26", early.UsageDaysThrough);
|
||||
Assert.Equal(0, early.AllTenants.Dormant);
|
||||
Assert.Equal(1, early.AllTenants.Active7Days);
|
||||
|
||||
// Aggregated, yesterday has no row: the counters moved for an old session, which is no activity.
|
||||
var later = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, NoExclusions);
|
||||
Assert.Equal("2026-10-27", later.UsageDaysThrough);
|
||||
Assert.Equal(1, later.AllTenants.Dormant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_peak_needs_three_signups_and_twice_the_median_of_the_two_weeks_before()
|
||||
{
|
||||
|
||||
@@ -52,7 +52,8 @@ public class InfrastructureWireParityTests
|
||||
mcpClientRegistrationEnabled = true,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen,
|
||||
dpaAcceptancePending = true
|
||||
dpaAcceptancePending = true,
|
||||
companyName = "Contoso Ltd."
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -74,7 +75,8 @@ public class InfrastructureWireParityTests
|
||||
McpClientRegistrationEnabled = true,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen,
|
||||
DpaAcceptancePending = true
|
||||
DpaAcceptancePending = true,
|
||||
CompanyName = "Contoso Ltd."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -124,32 +124,38 @@ public class IntuneDeviceBindingValidatorTests
|
||||
Assert.Null(result.EnrolledDateTime);
|
||||
}
|
||||
|
||||
// -- ParseEnrolledDateTime --
|
||||
// -- enrolledDateTime --
|
||||
|
||||
private static DateTimeOffset? EnrolledDateTime(string jsonValue)
|
||||
=> IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
|
||||
$$"""{ "id": "{{DeviceId}}", "enrolledDateTime": {{jsonValue}} }""", DeviceId).EnrolledDateTime;
|
||||
|
||||
[Fact]
|
||||
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
|
||||
public void EnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
|
||||
{
|
||||
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
|
||||
// as a real date would report an object age of ~2000 years and poison the race analysis.
|
||||
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
|
||||
Assert.Null(EnrolledDateTime("\"0001-01-01T00:00:00Z\""));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not-a-date")]
|
||||
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
|
||||
[InlineData("null")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
[InlineData("\"not-a-date\"")]
|
||||
[InlineData("20260824")]
|
||||
[InlineData("true")]
|
||||
public void EnrolledDateTime_UnusableValue_IsNull(string jsonValue)
|
||||
{
|
||||
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
|
||||
Assert.Null(EnrolledDateTime(jsonValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEnrolledDateTime_NormalizesToUtc()
|
||||
public void EnrolledDateTime_NormalizesToUtc()
|
||||
{
|
||||
// A device object created minutes ago is the signature of an enrollment race, so the
|
||||
// instant has to be comparable regardless of the offset Graph reports it in.
|
||||
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
|
||||
var parsed = EnrolledDateTime("\"2026-08-24T17:20:00+02:00\"");
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Graph and Entra token-endpoint response parsers as they read with Newtonsoft before D-420, copied
|
||||
/// verbatim as the reference for <see cref="UpstreamJsonDifferentialTests"/>. Temporary: removed together
|
||||
/// with that test once the System.Text.Json parsers have run cleanly in production (backlog).
|
||||
/// </summary>
|
||||
internal static class LegacyUpstreamParsers
|
||||
{
|
||||
public static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
|
||||
{
|
||||
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
var accessToken = tokenJson?["access_token"]?.ToString();
|
||||
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, (long?)tokenJson?["expires_in"]);
|
||||
}
|
||||
|
||||
public static EntraTokenRejection ParseEntraRejection(int statusCode, string? body)
|
||||
{
|
||||
JObject? json = null;
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try { json = JsonConvert.DeserializeObject<JToken>(body) as JObject; }
|
||||
catch (JsonException) { json = null; }
|
||||
}
|
||||
var description = (json?["error_description"] as JValue)?.Value as string;
|
||||
|
||||
string? code = null;
|
||||
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
|
||||
if (first != null)
|
||||
{
|
||||
code = "AADSTS" + first.Value<long>();
|
||||
}
|
||||
else
|
||||
{
|
||||
var start = description?.IndexOf("AADSTS", StringComparison.Ordinal) ?? -1;
|
||||
if (start >= 0)
|
||||
{
|
||||
var digits = new string(description!.Skip(start + "AADSTS".Length).TakeWhile(char.IsDigit).ToArray());
|
||||
code = digits.Length == 0 ? null : "AADSTS" + digits;
|
||||
}
|
||||
}
|
||||
|
||||
string? shortened = null;
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
var line = description.Split('\n')[0].Trim();
|
||||
shortened = line.Length <= 300 ? line : line.Substring(0, 300);
|
||||
}
|
||||
return new EntraTokenRejection(statusCode, code, shortened);
|
||||
}
|
||||
|
||||
public static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
|
||||
{
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
if (data?["value"] is not JArray devices || devices.Count == 0)
|
||||
return null;
|
||||
|
||||
var exactDevice = devices.FirstOrDefault(d => string.Equals(
|
||||
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
|
||||
if (exactDevice == null)
|
||||
return null;
|
||||
|
||||
return new AutopilotRegistration
|
||||
{
|
||||
DeviceId = RegistrationText(exactDevice["id"]),
|
||||
GroupTag = RegistrationText(exactDevice["groupTag"]),
|
||||
PurchaseOrderId = RegistrationText(exactDevice["purchaseOrderIdentifier"]),
|
||||
DisplayName = RegistrationText(exactDevice["displayName"]),
|
||||
UserAssigned = RegistrationText(exactDevice["userPrincipalName"]) != null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? RegistrationText(JToken? token)
|
||||
{
|
||||
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return null;
|
||||
return value!.Length > AutopilotDeviceValidator.MaxRegistrationFieldLength
|
||||
? value.Substring(0, AutopilotDeviceValidator.MaxRegistrationFieldLength)
|
||||
: value;
|
||||
}
|
||||
|
||||
public static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
|
||||
{
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
if (data?["value"] is JArray identities)
|
||||
{
|
||||
foreach (var identity in identities)
|
||||
{
|
||||
var type = identity?["importedDeviceIdentityType"]?.ToString();
|
||||
var value = identity?["importedDeviceIdentifier"]?.ToString();
|
||||
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
|
||||
&& value != null
|
||||
&& string.Equals(CorporateIdentifierValidator.NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
|
||||
{
|
||||
return (true, string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (false, data?["@odata.nextLink"]?.ToString() ?? string.Empty);
|
||||
}
|
||||
|
||||
public static DeviceAssociationResult ParseAssociation(string responseBody, string normalizedSerial)
|
||||
{
|
||||
var notFound = new DeviceAssociationResult
|
||||
{
|
||||
IsValid = false,
|
||||
SerialNumber = normalizedSerial,
|
||||
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
|
||||
};
|
||||
|
||||
JObject? data;
|
||||
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
|
||||
catch (JsonException) { return notFound; }
|
||||
|
||||
var devices = data?["value"] as JArray;
|
||||
if (devices == null || devices.Count == 0)
|
||||
return notFound;
|
||||
|
||||
var match = devices.FirstOrDefault(d => string.Equals(
|
||||
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
|
||||
if (match == null)
|
||||
return notFound;
|
||||
|
||||
return new DeviceAssociationResult
|
||||
{
|
||||
IsValid = true,
|
||||
SerialNumber = normalizedSerial,
|
||||
AssociationState = match["associationState"]?.ToString(),
|
||||
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
|
||||
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
|
||||
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
|
||||
PreAssociationDateTime = ParseAssociationDate(match["preassociationDateTime"]),
|
||||
AssociationDateTime = ParseAssociationDate(match["associationDateTime"]),
|
||||
ManagedDeviceId = match["managedDeviceId"]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTime? ParseAssociationDate(JToken? token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null) return null;
|
||||
var s = token.ToString();
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
|
||||
return dt.Year <= 1 ? null : dt;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static CloudPcValidationResult ParseCloudPc(string responseBody, string normalizedId)
|
||||
{
|
||||
var notFound = new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
|
||||
};
|
||||
|
||||
JObject? data;
|
||||
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
|
||||
catch (JsonException) { return notFound; }
|
||||
|
||||
var cloudPcs = data?["value"] as JArray;
|
||||
if (cloudPcs == null || cloudPcs.Count == 0)
|
||||
return notFound;
|
||||
|
||||
var match = cloudPcs.FirstOrDefault(c => string.Equals(
|
||||
c?["managedDeviceId"]?.ToString()?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase));
|
||||
if (match == null)
|
||||
return notFound;
|
||||
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
CloudPcId = match["id"]?.ToString(),
|
||||
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
|
||||
ServicePlanName = match["servicePlanName"]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> LeavingManagementStates = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"retirePending", "retireIssued", "wipePending", "wipeIssued", "deletePending",
|
||||
};
|
||||
|
||||
public static IntuneDeviceBindingResult ParseManagedDevice(string responseBody, string normalizedId)
|
||||
{
|
||||
var notFound = new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = IntuneDeviceBindingOutcome.NotFound,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
|
||||
};
|
||||
|
||||
JObject? device;
|
||||
try { device = JsonConvert.DeserializeObject<JObject>(responseBody); }
|
||||
catch (JsonException) { return notFound; }
|
||||
|
||||
var returnedId = device?["id"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrEmpty(returnedId) || !string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
|
||||
return notFound;
|
||||
|
||||
var managementState = device?["managementState"]?.ToString();
|
||||
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
|
||||
|
||||
return new IntuneDeviceBindingResult
|
||||
{
|
||||
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
|
||||
IntuneDeviceId = normalizedId,
|
||||
DeviceName = device?["deviceName"]?.ToString(),
|
||||
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
|
||||
ManagementState = managementState,
|
||||
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
|
||||
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
|
||||
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
|
||||
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
|
||||
? device["azureADRegistered"]!.Value<bool>()
|
||||
: null,
|
||||
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
|
||||
ErrorMessage = leaving
|
||||
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseEnrolledDateTime(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
if (!DateTimeOffset.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var parsed))
|
||||
return null;
|
||||
return parsed.Year <= 1 ? null : parsed;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
public static (List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
|
||||
{
|
||||
var json = JObject.Parse(body);
|
||||
var values = json["value"] as JArray ?? new JArray();
|
||||
var scripts = new List<ScriptDisplayNameResolver.ScriptListItem>();
|
||||
foreach (var item in values.OfType<JObject>())
|
||||
{
|
||||
var id = item["id"]?.ToString();
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
scripts.Add(new ScriptDisplayNameResolver.ScriptListItem(id!, item["displayName"]?.ToString(), withFileName ? item["fileName"]?.ToString() : null));
|
||||
}
|
||||
return (scripts, json["@odata.nextLink"]?.ToString());
|
||||
}
|
||||
|
||||
public static string? ReadScriptDisplayName(string body) => JObject.Parse(body)["displayName"]?.ToString();
|
||||
}
|
||||
@@ -45,6 +45,24 @@ public class LiveStreamPrincipalTests
|
||||
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.NegotiatedUserIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiate_under_view_as_binds_the_sign_in_tenant_not_the_viewed_one()
|
||||
{
|
||||
// Removing a Global Admin cuts the connections bound to their sign-in tenant; a view-as tab must be among them.
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var req = BuildRequest(new RequestContext
|
||||
{
|
||||
TenantId = OtherTenantId,
|
||||
UserPrincipalName = OperatorUpn,
|
||||
ViewAs = new ViewAsContext(TenantId, AutopilotMonitor.Shared.Constants.TenantRoles.Admin),
|
||||
});
|
||||
|
||||
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, OperatorUpn) }, signalR.NegotiatedUserIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiate_without_a_principal_key_binds_no_user_id()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>tests/fixtures/local-admin-allow-list/cases.json</c> is the one definition of the allowed-list entry
|
||||
/// grammar; the agent tests run its parse cases against the netstandard2.0 build, the portal runs the file
|
||||
/// against <c>allowedAccounts.ts</c>. Plus the list-level write rules of
|
||||
/// <see cref="TenantConfigValidation.ValidateLocalAdminAllowedAccounts"/>.
|
||||
/// </summary>
|
||||
public class LocalAdminAllowListParityTests
|
||||
{
|
||||
private static readonly Lazy<JObject> Fixture = new(() =>
|
||||
JObject.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "local-admin-allow-list", "cases.json"))));
|
||||
|
||||
public static IEnumerable<object[]> ParseEntries() => Fixture.Value["parse"]!.Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
public static IEnumerable<object[]> WriteEntries() => Fixture.Value["write"]!.Select(c => new object[] { c.Value<string>("entry")! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParseEntries))]
|
||||
public void Parse_matches_the_shared_case(string entry)
|
||||
{
|
||||
var c = Fixture.Value["parse"]!.First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var parsed = LocalAdminAllowList.TryParseEntry(entry, out var pattern, out var role);
|
||||
|
||||
if (c.Value<bool?>("ignored") == true)
|
||||
{
|
||||
Assert.False(parsed);
|
||||
return;
|
||||
}
|
||||
Assert.True(parsed);
|
||||
Assert.Equal(c.Value<string>("pattern"), pattern);
|
||||
Assert.Equal(c.Value<string>("role") == "user" ? LocalAccountRole.StandardUser : LocalAccountRole.Any, role);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WriteEntries))]
|
||||
public void New_entry_validation_matches_the_shared_case(string entry)
|
||||
{
|
||||
var c = Fixture.Value["write"]!.First(x => x.Value<string>("entry") == entry);
|
||||
|
||||
var error = LocalAdminAllowList.ValidateNewEntry(entry);
|
||||
|
||||
if (c.Value<bool>("valid"))
|
||||
Assert.Null(error);
|
||||
else
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_cases_are_present()
|
||||
{
|
||||
Assert.True(Fixture.Value["parse"]!.Count() >= 15, "cases.json lost parse cases.");
|
||||
Assert.True(Fixture.Value["write"]!.Count() >= 25, "cases.json lost write cases.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builtin_catalog_keeps_the_four_rid_accounts_and_the_standard_user_system_accounts()
|
||||
{
|
||||
var rid = LocalAdminAllowList.BuiltIns.Where(b => b.Match == LocalAdminBuiltInMatch.Rid).ToDictionary(b => b.Rid!.Value, b => b.Role);
|
||||
Assert.Equal(LocalAccountRole.Any, rid[500]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[501]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[503]);
|
||||
Assert.Equal(LocalAccountRole.StandardUser, rid[504]);
|
||||
|
||||
string RoleOf(string name) => LocalAdminAllowList.BuiltIns
|
||||
.Single(b => b.Match == LocalAdminBuiltInMatch.AccountName && b.Name == name).Role.ToString();
|
||||
Assert.Equal("StandardUser", RoleOf("WsiAccount"));
|
||||
Assert.Equal("StandardUser", RoleOf("kioskUser0"));
|
||||
Assert.Equal("StandardUser", RoleOf("Administrator"));
|
||||
Assert.Equal("Any", RoleOf("defaultuser0"));
|
||||
}
|
||||
|
||||
// ── list-level write rules ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "[\"adm-*\",\"kiosk|user\"]")]
|
||||
[InlineData("[\"adm-*\"]", "[\"adm-*\",\"kiosk|user\"]")]
|
||||
[InlineData("[\"adm-*\"]", "[]")]
|
||||
[InlineData("[\"adm-*\"]", "")]
|
||||
[InlineData("[\"*\"]", "[\"*\"]")]
|
||||
public void Valid_or_unchanged_lists_pass(string? stored, string candidate)
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, stored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stored_entry_is_never_rechecked_when_another_entry_is_added()
|
||||
{
|
||||
// Lists written before the grammar existed (a bare "*", an Entra SID) stay saveable.
|
||||
Assert.Null(TenantConfigValidation.ValidateLocalAdminAllowedAccounts(
|
||||
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\",\"kiosk|user\"]",
|
||||
"[\"*\",\"S-1-12-1-1-2-3-4\",\"WsiAccount\"]"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"a\":1}", "JSON array")]
|
||||
[InlineData("[1,2]", "JSON array")]
|
||||
[InlineData("not json", "JSON array")]
|
||||
[InlineData("[\"*\"]", "too broad")]
|
||||
[InlineData("[\"kiosk|admin\"]", "is not valid")]
|
||||
[InlineData("[\"kiosk\",\"kiosk|user\"]", "listed twice")]
|
||||
[InlineData("[\"Kiosk\",\"kiosk\"]", "listed twice")]
|
||||
public void Invalid_new_entries_are_rejected(string candidate, string expected)
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts(candidate, null);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expected, error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_entry_repeating_a_stored_pattern_is_rejected()
|
||||
{
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[\"WsiAccount\",\"WsiAccount|user\"]", "[\"WsiAccount\"]");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("listed twice", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_entry_count_is_capped()
|
||||
{
|
||||
var entries = Enumerable.Range(0, LocalAdminAllowList.MaxEntries + 1).Select(i => $"\"svc{i:D3}\"");
|
||||
|
||||
var error = TenantConfigValidation.ValidateLocalAdminAllowedAccounts("[" + string.Join(",", entries) + "]", null);
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains($"at most {LocalAdminAllowList.MaxEntries}", error);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir != null && !File.Exists(Path.Combine(dir, "AutopilotMonitor.sln")))
|
||||
dir = Directory.GetParent(dir)?.FullName;
|
||||
return dir ?? throw new DirectoryNotFoundException("Could not locate repo root (AutopilotMonitor.sln).");
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,7 @@ public class PerPartitionFanOutMergeTests
|
||||
[InlineData(5)]
|
||||
[InlineData(15)]
|
||||
[InlineData(100)]
|
||||
[InlineData(1000)]
|
||||
public async Task Walk_returns_every_row_once_in_global_order_and_never_an_empty_page(int pageSize)
|
||||
{
|
||||
// Uneven partitions: one empty, one dominant, interleaved timestamps and a tie.
|
||||
|
||||
@@ -27,6 +27,8 @@ public class PlatformStatsStorageTests
|
||||
{
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<string> Filters { get; } = new();
|
||||
/// <summary>The projection of every query, in order; null for a query without one.</summary>
|
||||
public List<string[]?> Selects { get; } = new();
|
||||
public List<List<TableTransactionAction>> Submitted { get; } = new();
|
||||
public Func<string, TableEntity[]> RowsFor { get; set; } = _ => Array.Empty<TableEntity>();
|
||||
public Queue<Exception> SubmitFailures { get; } = new();
|
||||
@@ -35,9 +37,10 @@ public class PlatformStatsStorageTests
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(c => c.QueryAsync<TableEntity>(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((string filter, int? _, IEnumerable<string> _, CancellationToken _) =>
|
||||
.Returns((string filter, int? _, IEnumerable<string>? select, CancellationToken _) =>
|
||||
{
|
||||
Filters.Add(filter);
|
||||
Selects.Add(select?.ToArray());
|
||||
var page = Page<TableEntity>.FromValues(RowsFor(filter), null, Mock.Of<Response>());
|
||||
return AsyncPageable<TableEntity>.FromPages(new[] { page });
|
||||
});
|
||||
@@ -54,6 +57,7 @@ public class PlatformStatsStorageTests
|
||||
var service = new Mock<TableServiceClient>();
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns(Table.Object);
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.UsageMetrics)).Returns(Table.Object);
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.UserActivity)).Returns(Table.Object);
|
||||
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -281,6 +285,76 @@ public class PlatformStatsStorageTests
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetTenantActivityDaysAsync(DateTime.UtcNow.Date, DateTime.UtcNow.Date));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_activity_days_project_and_carry_the_success_and_failure_counts()
|
||||
{
|
||||
var h = new Harness
|
||||
{
|
||||
RowsFor = _ => new[]
|
||||
{
|
||||
new TableEntity("2026-10-01", Tenant) { ["SessionsTotal"] = 5, ["SessionsSucceeded"] = 3, ["SessionsFailed"] = 1 },
|
||||
new TableEntity("2026-10-02", Tenant) { ["SessionsTotal"] = 2 },
|
||||
}
|
||||
};
|
||||
|
||||
var days = await h.Sut.GetTenantActivityDaysAsync(
|
||||
new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(new[] { "PartitionKey", "RowKey", "SessionsTotal", "SessionsSucceeded", "SessionsFailed" }, Assert.Single(h.Selects));
|
||||
Assert.Collection(days,
|
||||
d => Assert.Equal((5, 3, 1), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)),
|
||||
d => Assert.Equal((2, 0, 0), (d.SessionsTotal, d.SessionsSucceeded, d.SessionsFailed)));
|
||||
}
|
||||
|
||||
// ---------- portal visits (tenant activity) ----------
|
||||
|
||||
private static TableEntity Login(string tenantId, string upn, string objectId, DateTime? at)
|
||||
{
|
||||
var row = new TableEntity(tenantId, Guid.NewGuid().ToString("N")) { ["Upn"] = upn, ["ObjectId"] = objectId };
|
||||
if (at is { } loginAt) row["LoginAt"] = loginAt;
|
||||
return row;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Portal_visits_are_people_since_the_cutoff_keyed_by_object_id_else_upn()
|
||||
{
|
||||
const string upperTenant = "AAAAAAAA-0000-4000-8000-000000000000";
|
||||
var at = new DateTime(2026, 11, 5, 9, 30, 0, DateTimeKind.Utc);
|
||||
var h = new Harness
|
||||
{
|
||||
RowsFor = _ => new[]
|
||||
{
|
||||
Login(upperTenant, "alice@contoso.example", "OID-ALICE", at),
|
||||
Login(Tenant, "Bob@Contoso.example", "", at), // no object id: the UPN
|
||||
Login(Tenant, "app:11111111-2222-3333-4444-555555555555", "sp-object-id", at), // a service principal
|
||||
Login(Tenant, "", " ", at), // no identity at all
|
||||
Login(Tenant, "carol@contoso.example", "oid-carol", null), // no visit time
|
||||
}
|
||||
};
|
||||
|
||||
var visits = await h.Sut.GetPortalVisitsSinceAsync(new DateTime(2026, 8, 7, 12, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal("LoginAt ge datetime'2026-08-07T12:00:00Z'", Assert.Single(h.Filters));
|
||||
Assert.Equal(new[] { "PartitionKey", "Upn", "ObjectId", "LoginAt" }, Assert.Single(h.Selects));
|
||||
Assert.Collection(visits,
|
||||
v =>
|
||||
{
|
||||
Assert.Equal(upperTenant.ToLowerInvariant(), v.TenantId);
|
||||
Assert.Equal("oid-alice", v.PersonKey);
|
||||
Assert.Equal(at, v.At);
|
||||
Assert.Equal(DateTimeKind.Utc, v.At.Kind);
|
||||
},
|
||||
v => Assert.Equal((Tenant, "bob@contoso.example"), (v.TenantId, v.PersonKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_portal_visit_read_throws_instead_of_reporting_nobody()
|
||||
{
|
||||
var h = new Harness { RowsFor = _ => throw new RequestFailedException(503, "busy") };
|
||||
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetPortalVisitsSinceAsync(DateTime.UtcNow.AddDays(-90)));
|
||||
}
|
||||
|
||||
// ---------- seen device models ----------
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// (otherwise admin SignalR group joins + admin-audience notifications regress), while a pure GlobalReader
|
||||
/// stays read-only. Also covers the cross-tenant gate keying off HasGlobalScope.
|
||||
/// </summary>
|
||||
public class PolicyEnforcementMiddlewareTests
|
||||
public partial class PolicyEnforcementMiddlewareTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
@@ -1201,16 +1201,17 @@ public class PolicyEnforcementMiddlewareTests
|
||||
|
||||
/// <summary>
|
||||
/// Operator reads whose ?tenantId= is a handler-side filter for a Global Admin / Global Reader: session reports
|
||||
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), a
|
||||
/// tenant's MCP usage (its accounts and their tools) and verdict calibration (classifier diagnostics, drift
|
||||
/// alerts). None of it is the managed tenant's telemetry, so a delegated (MSP) reader never reaches them.
|
||||
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), MCP
|
||||
/// usage (a tenant's organization budget by account, the all-tenant overview with every tenant's accounts and
|
||||
/// tools) and verdict calibration (classifier diagnostics, drift alerts). None of it is the managed tenant's
|
||||
/// telemetry, so a delegated (MSP) reader never reaches them.
|
||||
/// </summary>
|
||||
public static TheoryData<string> OperatorFilterReads => new()
|
||||
{
|
||||
"/api/global/session-reports",
|
||||
"/api/global/rule-submissions",
|
||||
"/api/global/metrics/mcp-usage",
|
||||
"/api/global/metrics/mcp-usage/daily",
|
||||
"/api/global/metrics/mcp-usage/organization",
|
||||
"/api/global/metrics/mcp-usage/overview",
|
||||
"/api/global/metrics/verdict-calibration",
|
||||
};
|
||||
|
||||
@@ -1470,6 +1471,8 @@ public class PolicyEnforcementMiddlewareTests
|
||||
[InlineData("GET", "/api/global/distress-reports", TenantScoping.None)]
|
||||
// ops-events is platform-OPERATIONAL data — GA/Reader-only by intent (not a can't-bound limit), so None.
|
||||
[InlineData("GET", "/api/global/ops-events", TenantScoping.None)]
|
||||
// Every tenant's use side by side (names, enrollments, portal users) — an operator view, never an MSP's.
|
||||
[InlineData("GET", "/api/global/metrics/tenant-activity", TenantScoping.None)]
|
||||
// Operator reads with a ?tenantId= filter: OperatorFilterReads above.
|
||||
public void GlobalRoute_DelegatedAccessibility_MatchesContract(string method, string path, TenantScoping expected)
|
||||
{
|
||||
|
||||
+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}";
|
||||
}
|
||||
@@ -158,6 +158,7 @@ internal sealed class InMemoryPushDeviceRepository : IPushDeviceRepository
|
||||
case "P256dh": d.P256dh = (string)value!; break;
|
||||
case "Auth": d.Auth = (string)value!; break;
|
||||
case "VapidKid": d.VapidKid = (string)value!; break;
|
||||
case "Label": d.Label = (string)value!; break;
|
||||
default: throw new InvalidOperationException($"unexpected patch key {key}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@ using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
@@ -18,7 +16,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
|
||||
/// webhook path, one fan-out per send), the validation rules (no destination, open to tenant
|
||||
/// admins — only E-mail stays behind the GA gate — and one Push channel per scope),
|
||||
/// the raw-table deny-list and the dependency redaction.
|
||||
/// and the raw-table deny-list. The dependency redaction lives in CredentialUrlRedactionProcessorTests.
|
||||
/// </summary>
|
||||
public class PushChannelRoutingTests
|
||||
{
|
||||
@@ -182,30 +180,4 @@ public class PushChannelRoutingTests
|
||||
Assert.Contains(Constants.TableNames.PushDevices, Constants.TableNames.CredentialBearing);
|
||||
Assert.Contains(Constants.TableNames.PushPairingGrants, Constants.TableNames.CredentialBearing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
public void Dependency_rows_for_push_services_keep_scheme_and_host_only(string endpoint, string expected)
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = endpoint, Name = "POST " + endpoint, Target = new Uri(endpoint).Host };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dependency_rows_for_other_hosts_are_untouched()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "https://graph.microsoft.com/v1.0/devices/abc", Name = "GET /v1.0/devices/abc" };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices/abc", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices/abc", dependency.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,43 @@ public class PushDeliveryServiceTests
|
||||
Assert.Equal((int)status, row.LastStatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Start_alert_goes_out_at_high_urgency(bool isResume)
|
||||
{
|
||||
// D-424: an Info start below "high" waits at FCM while the phone dozes, and the verdict sharing
|
||||
// its session Topic then replaces it — the start never reaches the phone.
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var alert = NotificationAlertBuilder.BuildEnrollmentStartedAlert(
|
||||
"DESKTOP-4711", "SN-0001", "Dell Inc.", "Latitude 7450", isResume, Constants.PortalSessionUrl("session-1"));
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, alert);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("high", Assert.Single(request.Headers.GetValues("Urgency")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationSeverity.Error, "high")]
|
||||
[InlineData(NotificationSeverity.Warning, "high")]
|
||||
[InlineData(NotificationSeverity.Success, "normal")]
|
||||
[InlineData(NotificationSeverity.Info, "low")]
|
||||
public async Task Other_alerts_take_their_urgency_from_the_severity(NotificationSeverity severity, string expected)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var alert = Alert();
|
||||
alert.EventType = "whats_new";
|
||||
alert.Severity = severity;
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, alert);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal(expected, Assert.Single(request.Headers.GetValues("Urgency")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flood_window_mutes_after_the_cap_with_one_summary_push()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// <summary>
|
||||
/// The 403 a refused pairing answers with: neutral, naming the role the scope needs — the member
|
||||
/// list of the tenant on the tenant routes, the Global Administrator role on the platform routes.
|
||||
/// Plus the label bodies of the portal routes: optional on confirm, required on rename.
|
||||
/// </summary>
|
||||
public class PushPairingFunctionsTests
|
||||
{
|
||||
@@ -39,6 +40,59 @@ public class PushPairingFunctionsTests
|
||||
await AssertForbiddenAsync(response, "Push notifications on the platform need the Global Administrator role.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "iPhone")] // a portal without the name field sends no body
|
||||
[InlineData("{\"label\":\"\"}", "iPhone")]
|
||||
[InlineData("{\"label\":\"iPhone Otto\"}", "iPhone Otto")]
|
||||
public async Task Confirm_reads_an_optional_name(string? body, string expectedLabel)
|
||||
{
|
||||
var h = new PushPairingServiceTests.Harness();
|
||||
var sut = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
|
||||
var pairingId = await RedeemedPairingAsync(h, sut);
|
||||
|
||||
var response = await sut.Confirm(EndpointHarness.Request(TenantId, jsonBody: body).Req, pairingId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var device = h.Repo.Devices.Values.Single();
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
|
||||
Assert.Equal(expectedLabel, device.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_answers_204_and_maps_a_blank_name_to_400_and_an_unknown_device_to_404()
|
||||
{
|
||||
var h = new PushPairingServiceTests.Harness();
|
||||
var pairing = new PushPairingFunctions(h.Sut, NullLogger<PushPairingFunctions>.Instance);
|
||||
var pairingId = await RedeemedPairingAsync(h, pairing);
|
||||
await pairing.Confirm(EndpointHarness.Request(TenantId).Req, pairingId);
|
||||
var deviceId = h.Repo.Devices.Values.Single().DeviceId;
|
||||
var sut = new PushDevicesFunctions(h.Sut, NullLogger<PushDevicesFunctions>.Instance);
|
||||
|
||||
var renamed = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"iPhone IT\"}").Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.NoContent, renamed.StatusCode);
|
||||
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
|
||||
|
||||
var blank = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\" \"}").Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, blank.StatusCode);
|
||||
var noBody = await sut.Rename(EndpointHarness.Request(TenantId).Req, deviceId);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, noBody.StatusCode);
|
||||
Assert.Equal("iPhone IT", h.Repo.Devices.Values.Single().Label);
|
||||
|
||||
var unknown = await sut.Rename(EndpointHarness.Request(TenantId, jsonBody: "{\"label\":\"x\"}").Req, "unknownDevice");
|
||||
Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>A pairing created through the tenant route by the harness caller and redeemed by a device named "iPhone".</summary>
|
||||
private static async Task<string> RedeemedPairingAsync(PushPairingServiceTests.Harness h, PushPairingFunctions sut)
|
||||
{
|
||||
var created = await sut.Create(EndpointHarness.Request(TenantId).Req);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
created.Body.Position = 0;
|
||||
var pairing = (await JsonSerializer.DeserializeAsync<CreatePairingResponse>(created.Body, ApiJsonOptions.Read))!;
|
||||
Assert.True((await h.Sut.RedeemAsync(h.RedeemRequest(pairing.Code, label: "iPhone"))).Ok);
|
||||
return pairing.PairingId;
|
||||
}
|
||||
|
||||
private static PushPairingFunctions RefusingFunctions()
|
||||
{
|
||||
var pairing = new PushPairingServiceTests.Harness();
|
||||
|
||||
@@ -400,6 +400,54 @@ public class PushPairingServiceTests
|
||||
Assert.Empty(h.Repo.Devices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Confirm_takes_the_name_chosen_on_the_computer_and_a_blank_one_keeps_the_devices()
|
||||
{
|
||||
var h = new Harness();
|
||||
var renamed = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(renamed.Value!.Code, label: "iPhone"));
|
||||
Assert.True((await h.Sut.ConfirmAsync(Tenant, renamed.Value.PairingId, Admin, " iPhone \u0007IT ")).Ok);
|
||||
var device = h.Repo.Devices.Values.Single();
|
||||
Assert.Equal("iPhone IT", device.Label);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "CREATE", "PushDevice", device.DeviceId, Admin.Upn,
|
||||
It.Is<Dictionary<string, string>>(d => d["label"] == "iPhone IT" && !d.ContainsKey("previousLabel"))), Times.Once);
|
||||
|
||||
var kept = await h.Sut.CreateAsync(Tenant, Operator);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(kept.Value!.Code, label: "ops phone"));
|
||||
Assert.True((await h.Sut.ConfirmAsync(Tenant, kept.Value.PairingId, Operator, " \u0007 ")).Ok);
|
||||
Assert.Equal("ops phone", h.Repo.Devices.Values.Single(d => d.OwnerUpn == Operator.Upn).Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rename_follows_the_remove_rule_cleans_the_name_and_audits_only_a_change()
|
||||
{
|
||||
var h = new Harness();
|
||||
var adminDevice = await PairAsync(h, Admin);
|
||||
var opsDevice = await PairAsync(h, Operator);
|
||||
|
||||
// An Operator renames only their own device; someone else's answers like a missing one.
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, adminDevice, "taken over", Operator));
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, "Ops phone", Operator));
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.RenameAsync(Tenant, "missing", "x", Admin));
|
||||
|
||||
// A scope admin renames every device of the scope.
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, " iPhone\u0007 Otto " + new string('z', 60), Admin));
|
||||
var renamed = h.Repo.Devices[(TenantId, opsDevice)];
|
||||
Assert.StartsWith("iPhone Otto zz", renamed.Label);
|
||||
Assert.Equal(Constants.Push.MaxLabelLength, renamed.Label.Length);
|
||||
Assert.Equal(renamed.Label, (await h.Sut.ListAsync(Tenant, Admin)).Devices.Single(d => d.DeviceId == opsDevice).Label);
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "PushDevice", opsDevice, Admin.Upn,
|
||||
It.Is<Dictionary<string, string>>(d => d["label"] == renamed.Label && d["previousLabel"] == "Ops phone")), Times.Once);
|
||||
|
||||
// Blank after cleaning is refused; the same name again writes nothing and audits nothing.
|
||||
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, " \u0007 ", Admin));
|
||||
Assert.Equal(PushOpError.InvalidLabel, await h.Sut.RenameAsync(Tenant, opsDevice, null, Admin));
|
||||
Assert.Equal(PushOpError.None, await h.Sut.RenameAsync(Tenant, opsDevice, renamed.Label, Admin));
|
||||
h.Maintenance.Verify(m => m.LogAuditEntryAsync(It.IsAny<string>(), "UPDATE", It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
/// <summary>Create → redeem → confirm: one Active device of <paramref name="owner"/>; returns its id.</summary>
|
||||
private static async Task<string> PairAsync(Harness h, PushCaller owner)
|
||||
{
|
||||
@@ -467,6 +515,8 @@ public class PushPairingServiceTests
|
||||
public VapidKeyRing Keys { get; }
|
||||
public Mock<PushEligibility> Eligibility { get; }
|
||||
public Mock<OpsEventService> OpsEvents { get; }
|
||||
/// <summary>The tenant audit log the pairing service writes to.</summary>
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public RecordingHandler Http { get; } = new();
|
||||
public PushPairingService Sut { get; }
|
||||
public PushDeliveryService Delivery { get; }
|
||||
@@ -499,7 +549,7 @@ public class PushPairingServiceTests
|
||||
.ReturnsAsync((new AutopilotMonitor.Shared.Models.TenantConfiguration { TenantId = TenantId, CompanyName = "Contoso" }, true));
|
||||
|
||||
Sut = new PushPairingService(Repo, Eligibility.Object, settings, Delivery, OpsEvents.Object,
|
||||
Mock.Of<IMaintenanceRepository>(), tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
|
||||
Maintenance.Object, tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<PushPairingService>.Instance, telemetry);
|
||||
|
||||
// A browser-shaped subscription: a fresh P-256 point + 16-byte auth secret.
|
||||
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the Web Push surface of the route policy catalog: the tier and tenant scoping of every push
|
||||
/// route, that the surface is exactly these 22 routes (a 23rd cannot appear unnoticed), that only the
|
||||
/// route, that the surface is exactly these 24 routes (a 25th cannot appear unnoticed), that only the
|
||||
/// five receiver routes skip JWT validation (the phone never signs in), that no push route carries a
|
||||
/// grant-widening opt-in, and that every push path is stamped <c>Cache-Control: no-store</c>.
|
||||
/// </summary>
|
||||
@@ -14,7 +14,7 @@ public class PushRoutePolicyTests
|
||||
{
|
||||
private const string SampleId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
/// <summary>The 22 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
|
||||
/// <summary>The 24 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
|
||||
private static readonly string[] PinnedRoutes =
|
||||
{
|
||||
// Receiver (the phone): one-shot pairing code, then the device token in X-Push-Device-Token.
|
||||
@@ -29,6 +29,7 @@ public class PushRoutePolicyTests
|
||||
"POST push/pairings/{pairingId}/confirm",
|
||||
"POST push/pairings/{pairingId}/reject",
|
||||
"GET push/devices",
|
||||
"PATCH push/devices/{deviceId}",
|
||||
"DELETE push/devices/{deviceId}",
|
||||
"POST push/devices/{deviceId}/test",
|
||||
// Session watches: ?tenantId= like the annotations route.
|
||||
@@ -41,6 +42,7 @@ public class PushRoutePolicyTests
|
||||
"POST global/push/pairings/{pairingId}/confirm",
|
||||
"POST global/push/pairings/{pairingId}/reject",
|
||||
"GET global/push/devices",
|
||||
"PATCH global/push/devices/{deviceId}",
|
||||
"DELETE global/push/devices/{deviceId}",
|
||||
"POST global/push/devices/{deviceId}/test",
|
||||
};
|
||||
@@ -60,6 +62,7 @@ public class PushRoutePolicyTests
|
||||
[InlineData("POST", "push/pairings/{pairingId}/confirm", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/pairings/{pairingId}/reject", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("GET", "push/devices", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("PATCH", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("DELETE", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/devices/{deviceId}/test", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
// Session watches: ?tenantId= (optional, falls back to the JWT tenant; middleware cross-tenant check).
|
||||
@@ -72,6 +75,7 @@ public class PushRoutePolicyTests
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/confirm", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/reject", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("GET", "global/push/devices", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("PATCH", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("DELETE", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/devices/{deviceId}/test", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
public void Push_route_resolves_to_its_tier_and_tenant_scoping(string method, string template, EndpointPolicy policy, TenantScoping scoping)
|
||||
@@ -85,7 +89,7 @@ public class PushRoutePolicyTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_push_surface_is_exactly_the_22_pinned_routes()
|
||||
public void The_push_surface_is_exactly_the_24_pinned_routes()
|
||||
{
|
||||
var expected = PinnedRoutes.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
var actual = PushSurface().Select(Key).OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
|
||||
+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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The setup guide's markers (POST config/{tenantId}/setup-guide) and the done rules the feature
|
||||
/// flags derive from the row.
|
||||
/// </summary>
|
||||
public class SetupGuideFunctionTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 11, 9, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Admin = "admin@contoso.com";
|
||||
|
||||
// ── step parsing ──
|
||||
|
||||
[Theory]
|
||||
[InlineData("settings", "settings")]
|
||||
[InlineData(" Hardware ", "hardware")]
|
||||
[InlineData("DISMISS", "dismiss")]
|
||||
[InlineData("restore", "restore")]
|
||||
[InlineData("validation", null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData(null, null)]
|
||||
public void NormalizeStep_accepts_only_the_four_steps(string? input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, SetupGuideFunction.NormalizeStep(input));
|
||||
}
|
||||
|
||||
// ── ApplyStep ──
|
||||
|
||||
[Fact]
|
||||
public void Settings_step_is_stamped_once()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "settings", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
|
||||
Assert.Equal(Admin, row.UpdatedBy);
|
||||
|
||||
// A second completion keeps the first one on record and writes nothing.
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "settings", "other@contoso.com", Now.AddHours(1)));
|
||||
Assert.Equal(Now, row.SetupGuideSettingsReviewedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_step_is_stamped_once()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideHardwareReviewedAt);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "hardware", Admin, Now.AddMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dismiss_records_who_and_restore_clears_it()
|
||||
{
|
||||
var row = new TenantConfiguration();
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now));
|
||||
Assert.Equal(Now, row.SetupGuideDismissedAt);
|
||||
Assert.Equal(Admin, row.SetupGuideDismissedBy);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "dismiss", Admin, Now.AddMinutes(1)));
|
||||
|
||||
Assert.True(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(2)));
|
||||
Assert.Null(row.SetupGuideDismissedAt);
|
||||
Assert.Null(row.SetupGuideDismissedBy);
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "restore", Admin, Now.AddMinutes(3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_step_writes_nothing()
|
||||
{
|
||||
var row = new TenantConfiguration { UpdatedBy = "earlier" };
|
||||
|
||||
Assert.False(SetupGuideFunction.ApplyStep(row, "validation", Admin, Now));
|
||||
Assert.Equal("earlier", row.UpdatedBy);
|
||||
}
|
||||
|
||||
// ── SetupGuideState.SettingsDone ──
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_open_on_a_new_tenant()
|
||||
{
|
||||
Assert.False(SetupGuideState.SettingsDone(TenantConfiguration.CreateDefault("t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_done_once_reviewed_even_if_nothing_was_applied()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideSettingsReviewedAt = Now;
|
||||
|
||||
Assert.True(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_are_done_when_all_three_are_already_on()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.EnableTimezoneAutoSet = true;
|
||||
config.EnableSoftwareInventoryAnalyzer = true;
|
||||
config.DiagnosticsUploadDestination = "Hosted";
|
||||
config.DiagnosticsUploadMode = "OnFailure";
|
||||
|
||||
Assert.True(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_stay_open_while_one_of_the_three_is_off()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.EnableTimezoneAutoSet = true;
|
||||
config.EnableSoftwareInventoryAnalyzer = true;
|
||||
config.DiagnosticsUploadDestination = "Hosted";
|
||||
config.DiagnosticsUploadMode = "Off";
|
||||
|
||||
Assert.False(SetupGuideState.SettingsDone(config));
|
||||
}
|
||||
|
||||
// ── SetupGuideState.HardwareDone ──
|
||||
|
||||
[Fact]
|
||||
public void Hardware_is_open_with_the_default_whitelist()
|
||||
{
|
||||
Assert.False(SetupGuideState.HardwareDone(TenantConfiguration.CreateDefault("t")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_is_done_once_reviewed()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.SetupGuideHardwareReviewedAt = Now;
|
||||
|
||||
Assert.True(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation,VMware*", "*")]
|
||||
[InlineData("*", "*")]
|
||||
[InlineData("Dell*,HP*,Lenovo*,Microsoft Corporation", "Latitude*")]
|
||||
public void Hardware_is_done_when_the_whitelist_differs_from_the_default(string manufacturers, string models)
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.ManufacturerWhitelist = manufacturers;
|
||||
config.ModelWhitelist = models;
|
||||
|
||||
Assert.True(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_ignores_order_case_and_spacing_of_the_default_entries()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault("t");
|
||||
config.ManufacturerWhitelist = "microsoft corporation, lenovo*,HP*, dell*";
|
||||
|
||||
Assert.False(SetupGuideState.HardwareDone(config));
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,10 @@ public sealed class SharedManifestParityTests
|
||||
["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||
// Evidence keys per condition source: what a {{signal.key}} token can resolve (MCP rule lint).
|
||||
["analyzeRuleEvidenceKeys"] = RuleEvidenceKeys.BySource,
|
||||
// Tenant activity (GET global/metrics/tenant-activity): the lifecycle stages in their test order —
|
||||
// the MCP tool's stage filter enumerates them — and the edition names the portal's plan facet uses.
|
||||
["tenantActivityStages"] = TenantActivityStages.All,
|
||||
["tenantEditions"] = TenantEditionNames.All,
|
||||
// Vocabularies of the diagnosis headline (GET sessions/{id}/diagnosis): the portal's verdict
|
||||
// texts and culprit labels key on the same values the builder writes.
|
||||
["diagnosis"] = new Dictionary<string, object?>
|
||||
@@ -171,6 +175,30 @@ public sealed class SharedManifestParityTests
|
||||
// Limits of feedback and reports (text lengths, the report body cap, the daily feedback cap):
|
||||
// the portal's counters and upload budget read the same numbers the backend enforces.
|
||||
["submissionLimits"] = ConstInts(typeof(Constants.SubmissionLimits)),
|
||||
// Local Admin Analyzer allowed list: the built-in entries and the entry grammar. The portal's
|
||||
// Allowed Local Accounts card renders and checks against these; agent and backend use the class.
|
||||
["localAdminAllowList"] = new Dictionary<string, object?>
|
||||
{
|
||||
["roleSeparator"] = LocalAdminAllowList.RoleSeparator.ToString(),
|
||||
["standardUserQualifier"] = LocalAdminAllowList.StandardUserQualifier,
|
||||
["maxEntries"] = LocalAdminAllowList.MaxEntries,
|
||||
["maxEntryLength"] = LocalAdminAllowList.MaxEntryLength,
|
||||
["minPatternLiterals"] = LocalAdminAllowList.MinPatternLiterals,
|
||||
["forbiddenNameCharacters"] = LocalAdminAllowList.ForbiddenNameCharacters,
|
||||
["builtIns"] = LocalAdminAllowList.BuiltIns.Select(b => new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = b.Name,
|
||||
["match"] = b.Match switch
|
||||
{
|
||||
LocalAdminBuiltInMatch.Rid => "rid",
|
||||
LocalAdminBuiltInMatch.AccountName => "account",
|
||||
LocalAdminBuiltInMatch.ProfileFolder => "folder",
|
||||
_ => throw new InvalidOperationException($"Unmapped built-in match {b.Match}"),
|
||||
},
|
||||
["role"] = b.Role == LocalAccountRole.StandardUser ? "user" : "any",
|
||||
["rid"] = b.Rid,
|
||||
}).ToArray(),
|
||||
},
|
||||
// Every IApiResponse + IApiRequest implementer + [WireContract] type, transitively closed —
|
||||
// the source of lib/generated/wire-types.generated.ts. See WireTypeManifestBuilder.
|
||||
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Azure Tables refuses <c>$top</c> above 1000 with 400 InvalidInput. A computed page size
|
||||
/// handed to <c>maxPerPage</c> unguarded reaches that limit only at the edge (a fan-out reader
|
||||
/// that asked for <c>pageSize + 1</c> answered every <c>pageSize=1000</c> request with an empty
|
||||
/// page). Ratchet: every <c>maxPerPage</c> argument is a literal of at most 1000 or goes through
|
||||
/// <see cref="AzureTablesPaginator.ServerPageSize"/> / <c>Math.Min(…, 1000)</c>.
|
||||
/// </summary>
|
||||
public class TableServerPageSizeGuardTests
|
||||
{
|
||||
private static readonly Regex AllowedForm = new(
|
||||
@"^(\d+|(AzureTablesPaginator\.)?ServerPageSize\(.+\)|Math\.Min\(.+,\s*1000\s*\))$",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline);
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(16, 16)]
|
||||
[InlineData(1000, 1000)]
|
||||
[InlineData(1001, 1000)]
|
||||
[InlineData(int.MaxValue, 1000)]
|
||||
public void ServerPageSize_never_exceeds_the_Azure_limit(int rows, int expected)
|
||||
{
|
||||
Assert.Equal(expected, AzureTablesPaginator.ServerPageSize(rows));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_maxPerPage_argument_stays_within_the_Azure_limit()
|
||||
{
|
||||
var root = FunctionsRoot();
|
||||
var offenders = new List<string>();
|
||||
var found = 0;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||
if (rel.StartsWith("bin/", StringComparison.Ordinal) || rel.StartsWith("obj/", StringComparison.Ordinal)) continue;
|
||||
|
||||
var source = File.ReadAllText(path);
|
||||
foreach (var arg in MaxPerPageArguments(source))
|
||||
{
|
||||
found++;
|
||||
if (!IsAllowed(arg)) offenders.Add($"{rel}: maxPerPage: {arg}");
|
||||
}
|
||||
}
|
||||
|
||||
// Positive control: the scan must see the known call sites, or it proves nothing.
|
||||
Assert.True(found > 20, $"Only {found} maxPerPage arguments found — the scan lost its call sites.");
|
||||
Assert.True(offenders.Count == 0,
|
||||
"maxPerPage beyond Azure's $top limit is possible — pass AzureTablesPaginator.ServerPageSize(n) " +
|
||||
"or a literal of at most 1000:\n " + string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1000", true)]
|
||||
[InlineData("25", true)]
|
||||
[InlineData("1001", false)]
|
||||
[InlineData("limit", false)]
|
||||
[InlineData("pageSize + 1", false)]
|
||||
[InlineData("AzureTablesPaginator.ServerPageSize(limit)", true)]
|
||||
[InlineData("Math.Min(maxResults + 1, 1000)", true)]
|
||||
[InlineData("Math.Min(maxResults, 5000)", false)]
|
||||
public void IsAllowed_accepts_only_bounded_forms(string argument, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, IsAllowed(argument));
|
||||
}
|
||||
|
||||
private static bool IsAllowed(string argument)
|
||||
{
|
||||
if (!AllowedForm.IsMatch(argument)) return false;
|
||||
return !int.TryParse(argument, out var literal) || literal <= AzureTablesPaginator.MaxServerPageSize;
|
||||
}
|
||||
|
||||
/// <summary>The argument text after each <c>maxPerPage:</c>, up to the comma or parenthesis that ends it.</summary>
|
||||
private static IEnumerable<string> MaxPerPageArguments(string source)
|
||||
{
|
||||
const string marker = "maxPerPage:";
|
||||
var at = 0;
|
||||
while ((at = source.IndexOf(marker, at, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var i = at + marker.Length;
|
||||
at = i;
|
||||
// A mention inside a comment ("(maxPerPage: 1)") is not a call site.
|
||||
var lineStart = source.LastIndexOf('\n', i) + 1;
|
||||
if (source.AsSpan(lineStart, i - lineStart).TrimStart().StartsWith("//")) continue;
|
||||
|
||||
var depth = 0;
|
||||
var start = i;
|
||||
for (; i < source.Length; i++)
|
||||
{
|
||||
var c = source[i];
|
||||
if (c == '(') depth++;
|
||||
else if (c == ')') { if (depth == 0) break; depth--; }
|
||||
else if (c == ',' && depth == 0) break;
|
||||
}
|
||||
yield return source[start..i].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static string FunctionsRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Counting rules of the tenant activity (GA → Metrics → Tenant Activity, MCP get_tenant_activity): the window is
|
||||
/// the N full UTC days through the last aggregated day, every signup has exactly one lifecycle stage, dormant and
|
||||
/// never-enrolled agree with the growth figures, the totals add up from the tenant rows, and the portal figures
|
||||
/// count people inside the window only.
|
||||
/// </summary>
|
||||
public class TenantActivityBuilderTests
|
||||
{
|
||||
// Friday; yesterday (2026-11-05) is aggregated unless a test says otherwise.
|
||||
private static readonly DateTime Now = new(2026, 11, 6, 15, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime Today = Now.Date;
|
||||
|
||||
private static string Tid(char c) => $"{new string(c, 8)}-0000-4000-8000-000000000000";
|
||||
|
||||
private static DateTime DaysAgo(int days, int hour = 0) => Today.AddDays(-days).AddHours(hour);
|
||||
|
||||
private static TenantConfiguration Config(char id, DateTime? onboardedAt, string domain = "contoso.example")
|
||||
=> new() { TenantId = Tid(id), DomainName = domain, OnboardedAt = onboardedAt };
|
||||
|
||||
private static TenantStats Stats(char id, long total, DateTime lastUpdated, long succeeded = 0, long events = 0)
|
||||
=> new() { TenantId = Tid(id), TotalEnrollments = total, SuccessfulEnrollments = succeeded, TotalEventsProcessed = events, LastUpdated = lastUpdated };
|
||||
|
||||
private static TenantActivityDay Row(char id, int daysAgo, int total, int succeeded = 0, int failed = 0)
|
||||
=> new() { TenantId = Tid(id), Day = DaysAgo(daysAgo), SessionsTotal = total, SessionsSucceeded = succeeded, SessionsFailed = failed };
|
||||
|
||||
private static PortalVisit Visit(char id, string person, DateTime at)
|
||||
=> new() { TenantId = Tid(id), PersonKey = person, At = at };
|
||||
|
||||
private static GetTenantActivityResponse Build(
|
||||
IReadOnlyList<TenantConfiguration> configs,
|
||||
IReadOnlyList<TenantStats> stats,
|
||||
IReadOnlyList<TenantActivityDay> rows,
|
||||
int days = 30,
|
||||
IReadOnlyList<PortalVisit>? visits = null,
|
||||
bool portalAvailable = true,
|
||||
bool yesterdayAggregated = true)
|
||||
=> TenantActivityBuilder.Build(
|
||||
configs, stats, rows, portalAvailable ? visits ?? Array.Empty<PortalVisit>() : null, Now, days, yesterdayAggregated);
|
||||
|
||||
private static TenantActivityTenantItem Tenant(GetTenantActivityResponse response, char id)
|
||||
=> Assert.Single(response.Tenants, t => t.TenantId == Tid(id));
|
||||
|
||||
// ---------- window ----------
|
||||
|
||||
[Fact]
|
||||
public void Window_is_the_n_full_days_through_yesterday_and_the_previous_window_before_it()
|
||||
{
|
||||
var response = Build(
|
||||
new[] { Config('a', DaysAgo(100)) },
|
||||
new[] { Stats('a', 7, DaysAgo(1, 9)) },
|
||||
new[] { Row('a', 1, 2), Row('a', 30, 1), Row('a', 31, 4) });
|
||||
|
||||
Assert.Equal("2026-10-07", response.WindowStart);
|
||||
Assert.Equal("2026-11-05", response.UsageDaysThrough);
|
||||
Assert.Equal("2026-09-07", response.PreviousWindowStart);
|
||||
Assert.Equal("2026-05-10", response.RowsFrom);
|
||||
var tenant = Tenant(response, 'a');
|
||||
Assert.Equal(3, tenant.Enrollments);
|
||||
Assert.Equal(4, tenant.EnrollmentsPrevious);
|
||||
Assert.Equal(30, response.Daily.Count);
|
||||
Assert.Equal(("2026-10-07", 1), (response.Daily[0].Day, response.Daily[0].Enrollments));
|
||||
Assert.Equal(("2026-11-05", 2), (response.Daily[^1].Day, response.Daily[^1].Enrollments));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "2026-11-05")]
|
||||
[InlineData(7, "2026-10-30")]
|
||||
[InlineData(30, "2026-10-07")]
|
||||
[InlineData(90, "2026-08-08")]
|
||||
public void The_window_holds_exactly_n_days_ending_yesterday(int days, string start)
|
||||
{
|
||||
var response = Build(Array.Empty<TenantConfiguration>(), Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(), days);
|
||||
|
||||
Assert.Equal(start, response.WindowStart);
|
||||
Assert.Equal(days, response.Daily.Count);
|
||||
Assert.Equal(start, response.Daily[0].Day);
|
||||
Assert.Equal("2026-11-05", response.Daily[^1].Day);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Before_yesterday_is_aggregated_the_window_ends_the_day_before_and_yesterdays_counters_keep_a_tenant_active()
|
||||
{
|
||||
// Sessions only yesterday: the counters moved, the daily row is not written yet.
|
||||
var configs = new[] { Config('b', DaysAgo(120)) };
|
||||
var stats = new[] { Stats('b', 3, DaysAgo(1, 22)) };
|
||||
var rows = new[] { Row('b', 40, 1) };
|
||||
|
||||
var early = Build(configs, stats, rows, yesterdayAggregated: false);
|
||||
|
||||
Assert.Equal("2026-11-04", early.UsageDaysThrough);
|
||||
Assert.Equal("2026-10-06", early.WindowStart);
|
||||
var tenant = Tenant(early, 'b');
|
||||
Assert.Equal("2026-11-05", tenant.LastActiveDay);
|
||||
Assert.Equal(TenantActivityStages.Quiet, tenant.Stage);
|
||||
|
||||
// Once aggregated, a day without a row had no sessions: the counter move alone keeps nobody active.
|
||||
Assert.Equal(TenantActivityStages.Dormant, Tenant(Build(configs, stats, rows), 'b').Stage);
|
||||
}
|
||||
|
||||
// ---------- stages ----------
|
||||
|
||||
[Fact]
|
||||
public void Every_signup_has_exactly_one_stage_in_order()
|
||||
{
|
||||
var configs = new[]
|
||||
{
|
||||
Config('n', DaysAgo(10)), // never enrolled
|
||||
Config('d', DaysAgo(120)), // dormant: last sessions 40 days ago
|
||||
Config('w', DaysAgo(20)), // new: first sessions 10 days ago
|
||||
Config('p', DaysAgo(2)), // new: first sessions today, only the counters know
|
||||
Config('c', DaysAgo(150)), // declining: 10 before, 4 now
|
||||
Config('a', DaysAgo(300)), // active, signed up before the rows' horizon
|
||||
Config('q', DaysAgo(100)), // quiet: only today's counters moved in 30 days
|
||||
Config('g', DaysAgo(5), domain: ""), // a read of an unknown id, not a signup
|
||||
};
|
||||
var stats = new[]
|
||||
{
|
||||
Stats('d', 5, DaysAgo(40, 9)),
|
||||
Stats('w', 2, DaysAgo(10, 9)),
|
||||
Stats('p', 1, Today.AddHours(8)),
|
||||
Stats('c', 30, DaysAgo(5, 9)),
|
||||
Stats('a', 100, DaysAgo(1, 9)),
|
||||
Stats('q', 4, Today.AddHours(7)),
|
||||
Stats('g', 9, DaysAgo(1, 9)),
|
||||
};
|
||||
var rows = new[]
|
||||
{
|
||||
Row('d', 40, 2),
|
||||
Row('w', 10, 2),
|
||||
Row('c', 40, 10), Row('c', 5, 4),
|
||||
Row('a', 35, 6), Row('a', 3, 6),
|
||||
Row('q', 50, 1),
|
||||
Row('g', 2, 9),
|
||||
};
|
||||
|
||||
var response = Build(configs, stats, rows);
|
||||
|
||||
Assert.Equal(TenantActivityStages.NeverEnrolled, Tenant(response, 'n').Stage);
|
||||
Assert.Equal(TenantActivityStages.Dormant, Tenant(response, 'd').Stage);
|
||||
Assert.Equal(TenantActivityStages.New, Tenant(response, 'w').Stage);
|
||||
Assert.Equal(TenantActivityStages.New, Tenant(response, 'p').Stage);
|
||||
Assert.Equal(TenantActivityStages.Declining, Tenant(response, 'c').Stage);
|
||||
Assert.Equal(TenantActivityStages.Active, Tenant(response, 'a').Stage);
|
||||
Assert.Equal(TenantActivityStages.Quiet, Tenant(response, 'q').Stage);
|
||||
Assert.DoesNotContain(response.Tenants, t => t.TenantId == Tid('g'));
|
||||
|
||||
var pending = Tenant(response, 'p');
|
||||
Assert.Null(pending.FirstEnrollmentDay);
|
||||
Assert.Equal("2026-11-06", pending.LastActiveDay);
|
||||
Assert.Equal("2026-10-27", Tenant(response, 'w').FirstEnrollmentDay);
|
||||
Assert.Null(Tenant(response, 'a').FirstEnrollmentDay); // signup before the rows' first day: unknown
|
||||
|
||||
var stages = response.Totals.Stages;
|
||||
Assert.Equal((1, 1, 2, 1, 1, 1), (stages.NeverEnrolled, stages.Dormant, stages.New, stages.Declining, stages.Active, stages.Quiet));
|
||||
Assert.Equal(response.Totals.SignedUpTenants,
|
||||
stages.NeverEnrolled + stages.Dormant + stages.New + stages.Declining + stages.Active + stages.Quiet);
|
||||
Assert.Equal(7, response.Totals.SignedUpTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dormant_wins_over_declining_and_a_short_window_leaves_recent_tenants_quiet()
|
||||
{
|
||||
var configs = new[] { Config('c', DaysAgo(150)), Config('q', DaysAgo(150)) };
|
||||
var stats = new[] { Stats('c', 20, DaysAgo(40, 9)), Stats('q', 20, DaysAgo(10, 9)) };
|
||||
var rows = new[] { Row('c', 40, 10), Row('q', 120, 5), Row('q', 10, 3) };
|
||||
|
||||
Assert.Equal(TenantActivityStages.Dormant, Tenant(Build(configs, stats, rows), 'c').Stage);
|
||||
Assert.Equal(TenantActivityStages.Quiet, Tenant(Build(configs, stats, rows, days: 7), 'q').Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_return_after_a_pause_is_not_new_for_a_signup_before_the_rows_horizon()
|
||||
{
|
||||
var response = Build(
|
||||
new[] { Config('a', DaysAgo(200)) },
|
||||
new[] { Stats('a', 40, DaysAgo(2, 9)) },
|
||||
new[] { Row('a', 2, 3) });
|
||||
|
||||
var tenant = Tenant(response, 'a');
|
||||
Assert.Equal(TenantActivityStages.Active, tenant.Stage);
|
||||
Assert.Null(tenant.FirstEnrollmentDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dormant_starts_at_30_days_without_activity_and_matches_the_growth_figures()
|
||||
{
|
||||
var configs = new[] { Config('x', DaysAgo(100)), Config('y', DaysAgo(100)), Config('n', DaysAgo(3)) };
|
||||
var stats = new[] { Stats('x', 2, DaysAgo(29, 9)), Stats('y', 2, DaysAgo(30, 9)) };
|
||||
var rows = new[] { Row('x', 29, 2), Row('y', 30, 2) };
|
||||
|
||||
var activity = Build(configs, stats, rows);
|
||||
var growth = GrowthMetricsBuilder.Build(configs, stats, rows, Now, 30, TimeZoneInfo.Utc, false, Array.Empty<string>());
|
||||
|
||||
Assert.NotEqual(TenantActivityStages.Dormant, Tenant(activity, 'x').Stage);
|
||||
Assert.Equal(TenantActivityStages.Dormant, Tenant(activity, 'y').Stage);
|
||||
Assert.Equal(growth.AllTenants.Dormant, activity.Totals.Stages.Dormant);
|
||||
Assert.Equal(growth.AllTenants.NeverEnrolled, activity.Totals.Stages.NeverEnrolled);
|
||||
Assert.Equal(1, activity.Totals.Stages.Dormant);
|
||||
}
|
||||
|
||||
// ---------- figures ----------
|
||||
|
||||
[Fact]
|
||||
public void Tenant_figures_and_totals_add_up_from_the_window_rows()
|
||||
{
|
||||
var configs = new[] { Config('a', DaysAgo(100)), Config('b', DaysAgo(100)), Config('c', DaysAgo(100)) };
|
||||
var stats = new[]
|
||||
{
|
||||
Stats('a', 50, DaysAgo(1, 9), succeeded: 40, events: 9_000),
|
||||
Stats('b', 8, DaysAgo(3, 9)),
|
||||
Stats('c', 4, DaysAgo(45, 9)),
|
||||
};
|
||||
var rows = new[]
|
||||
{
|
||||
Row('a', 1, 4, succeeded: 3, failed: 1), Row('a', 2, 2, succeeded: 2),
|
||||
Row('a', 40, 4),
|
||||
Row('b', 3, 1),
|
||||
Row('c', 45, 4),
|
||||
};
|
||||
|
||||
var response = Build(configs, stats, rows);
|
||||
|
||||
var a = Tenant(response, 'a');
|
||||
Assert.Equal((6, 4, 5, 1, 2), (a.Enrollments, a.EnrollmentsPrevious, a.Succeeded, a.Failed, a.ActiveDays));
|
||||
Assert.Equal(50, a.TrendPercent);
|
||||
Assert.Equal(83, a.SuccessPercent);
|
||||
Assert.Equal((50L, 40L, 9_000L), (a.LifetimeEnrollments, a.LifetimeSucceeded, a.LifetimeEvents));
|
||||
Assert.Equal(30, a.DailyEnrollments!.Length);
|
||||
Assert.Equal(4, a.DailyEnrollments[^1]);
|
||||
Assert.Equal(2, a.DailyEnrollments[^2]);
|
||||
|
||||
var b = Tenant(response, 'b');
|
||||
Assert.Null(b.TrendPercent); // nothing in the previous window
|
||||
Assert.Null(b.SuccessPercent); // no finished session
|
||||
var c = Tenant(response, 'c');
|
||||
Assert.Equal(-100, c.TrendPercent);
|
||||
Assert.Null(c.DailyEnrollments);
|
||||
|
||||
var totals = response.Totals;
|
||||
Assert.Equal((2, 2, 7, 8), (totals.ActiveTenants, totals.ActiveTenantsPrevious, totals.Enrollments, totals.EnrollmentsPrevious));
|
||||
Assert.Equal(0, totals.ActiveTenantsTrendPercent);
|
||||
Assert.Equal(-13, totals.EnrollmentsTrendPercent);
|
||||
Assert.Equal((5, 1, (int?)83), (totals.Succeeded, totals.Failed, totals.SuccessPercent));
|
||||
Assert.Equal(100, totals.Top10SharePercent);
|
||||
Assert.Equal(totals.Enrollments, response.Daily.Sum(d => d.Enrollments));
|
||||
Assert.Equal(1, response.Daily[^1].ActiveTenants);
|
||||
Assert.Equal(2, response.Daily[^3].ActiveTenants + response.Daily[^1].ActiveTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenants_come_by_window_enrollments_then_lifetime_and_the_top_10_share_needs_enrollments()
|
||||
{
|
||||
var configs = new[] { Config('a', DaysAgo(50)), Config('b', DaysAgo(50)), Config('c', DaysAgo(50)) };
|
||||
var stats = new[] { Stats('a', 1, DaysAgo(45, 9)), Stats('b', 9, DaysAgo(45, 9)), Stats('c', 3, DaysAgo(45, 9)) };
|
||||
|
||||
var response = Build(configs, stats, new[] { Row('a', 45, 1), Row('b', 45, 9), Row('c', 45, 3) });
|
||||
|
||||
Assert.Equal(new[] { Tid('b'), Tid('c'), Tid('a') }, response.Tenants.Select(t => t.TenantId));
|
||||
Assert.Null(response.Totals.Top10SharePercent);
|
||||
Assert.Equal(-100, response.Totals.EnrollmentsTrendPercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_ids_join_case_insensitively()
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = Tid('a').ToUpperInvariant(), DomainName = "contoso.example", OnboardedAt = DaysAgo(60) };
|
||||
var response = Build(new[] { config }, new[] { Stats('a', 5, DaysAgo(1, 9)) }, new[] { Row('a', 1, 5) },
|
||||
visits: new[] { Visit('a', "oid-1", DaysAgo(2, 10)) });
|
||||
|
||||
var tenant = Assert.Single(response.Tenants);
|
||||
Assert.Equal(5, tenant.Enrollments);
|
||||
Assert.Equal(5, tenant.LifetimeEnrollments);
|
||||
Assert.Equal(1, tenant.PortalUsers);
|
||||
}
|
||||
|
||||
// ---------- portal ----------
|
||||
|
||||
[Fact]
|
||||
public void Portal_users_count_people_inside_the_window_and_the_last_visit_takes_the_whole_history()
|
||||
{
|
||||
var visits = new[]
|
||||
{
|
||||
Visit('a', "oid-1", DaysAgo(30)), // the window's first day, midnight
|
||||
Visit('a', "oid-1", DaysAgo(3, 8)), // the same person again
|
||||
Visit('a', "oid-2", DaysAgo(31, 23)), // the day before the window
|
||||
Visit('a', "oid-3", Today.AddHours(9)), // today, after the window
|
||||
Visit('b', "oid-9", DaysAgo(60, 12)), // never enrolled, came back two months ago
|
||||
};
|
||||
|
||||
var response = Build(
|
||||
new[] { Config('a', DaysAgo(100)), Config('b', DaysAgo(80)) },
|
||||
new[] { Stats('a', 3, DaysAgo(1, 9)) },
|
||||
new[] { Row('a', 1, 3) },
|
||||
visits: visits);
|
||||
|
||||
Assert.True(response.PortalActivityAvailable);
|
||||
var a = Tenant(response, 'a');
|
||||
Assert.Equal(1, a.PortalUsers);
|
||||
Assert.Equal(Today.AddHours(9), a.LastPortalVisitAt);
|
||||
var b = Tenant(response, 'b');
|
||||
Assert.Equal(0, b.PortalUsers);
|
||||
Assert.Equal(DaysAgo(60, 12), b.LastPortalVisitAt);
|
||||
Assert.Equal(((int?)1, (int?)1), (response.Totals.PortalUsers, response.Totals.PortalTenants));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_the_sign_in_history_the_portal_figures_are_absent_not_zero()
|
||||
{
|
||||
var response = Build(new[] { Config('a', DaysAgo(100)) }, new[] { Stats('a', 3, DaysAgo(1, 9)) }, new[] { Row('a', 1, 3) },
|
||||
portalAvailable: false);
|
||||
|
||||
Assert.False(response.PortalActivityAvailable);
|
||||
Assert.Null(response.Totals.PortalUsers);
|
||||
Assert.Null(response.Totals.PortalTenants);
|
||||
var tenant = Assert.Single(response.Tenants);
|
||||
Assert.Null(tenant.PortalUsers);
|
||||
Assert.Null(tenant.LastPortalVisitAt);
|
||||
Assert.Equal(3, tenant.Enrollments);
|
||||
}
|
||||
|
||||
// ---------- identity ----------
|
||||
|
||||
[Fact]
|
||||
public void Edition_follows_msp_then_plan_then_a_running_trial()
|
||||
{
|
||||
var msp = Config('m', DaysAgo(10));
|
||||
msp.ManagedByProTenantId = Tid('z');
|
||||
msp.PlanTier = "pro";
|
||||
var pro = Config('p', DaysAgo(10));
|
||||
pro.PlanTier = "pro";
|
||||
var trial = Config('t', DaysAgo(10));
|
||||
trial.TrialExpiresUtc = Now.AddDays(5);
|
||||
var expired = Config('e', DaysAgo(10));
|
||||
expired.TrialExpiresUtc = Now.AddDays(-1);
|
||||
var community = Config('c', DaysAgo(10));
|
||||
|
||||
var response = Build(new[] { msp, pro, trial, expired, community }, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>());
|
||||
|
||||
Assert.Equal(TenantEditionNames.ProMsp, Tenant(response, 'm').Edition);
|
||||
Assert.Equal(TenantEditionNames.Pro, Tenant(response, 'p').Edition);
|
||||
Assert.Equal(TenantEditionNames.ProTrial, Tenant(response, 't').Edition);
|
||||
Assert.Equal(TenantEditionNames.Community, Tenant(response, 'e').Edition);
|
||||
Assert.Equal(TenantEditionNames.Community, Tenant(response, 'c').Edition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tenant_row_carries_its_names_signup_and_counter_time()
|
||||
{
|
||||
var config = Config('a', DaysAgo(30, 10), domain: "fabrikam.example");
|
||||
config.CompanyName = "Fabrikam";
|
||||
var blank = Config('b', DaysAgo(30, 10));
|
||||
blank.CompanyName = " ";
|
||||
|
||||
var response = Build(new[] { config, blank }, new[] { Stats('a', 1, DaysAgo(2, 11)) }, new[] { Row('a', 2, 1) });
|
||||
|
||||
var a = Tenant(response, 'a');
|
||||
Assert.Equal(("fabrikam.example", "Fabrikam"), (a.DomainName, a.CompanyName));
|
||||
Assert.Equal(DaysAgo(30, 10), a.OnboardedAt);
|
||||
Assert.Equal(DaysAgo(2, 11), a.LastCounterUpdateAt);
|
||||
var b = Tenant(response, 'b');
|
||||
Assert.Null(b.CompanyName);
|
||||
Assert.Null(b.LastCounterUpdateAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet: a function takes the tenant whose data it reads from the request context — <c>TenantHelper.GetTenantId(req)</c>
|
||||
/// or <c>RequestContext.TenantId</c> — never from the token. Only those follow "view as tenant", where a Global Admin
|
||||
/// reads another tenant as its member; a data read off the token's <c>tid</c> would silently show the Global Admin's
|
||||
/// own tenant under the viewed tenant's name. The baseline lists the reads that mean the caller's IDENTITY; it only
|
||||
/// shrinks.
|
||||
/// </summary>
|
||||
public class TenantClaimReadGuardTests
|
||||
{
|
||||
private static readonly Regex TokenTenantRead = new(
|
||||
@"\.GetTenantId\(\)|FindFirst\(""tid""\)|identity/claims/tenantid", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Token-tenant reads that identify the caller: file → why.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Infrastructure/AuthFunction.cs"] =
|
||||
"auth/me identifies and onboards the sign-in tenant (its view-as branch runs first), the caller's own What's-new marker and Terms + DPA",
|
||||
["Functions/Infrastructure/McpUserFunction.cs"] = "the MCP front door binds the caller's identity",
|
||||
["Functions/Metrics/McpUsageMetricsFunction.cs"] = "the caller's own MCP usage (me)",
|
||||
["Functions/Rules/PreviewWhitelistFunction.cs"] = "the caller's own tenant's activation notification address",
|
||||
};
|
||||
|
||||
private static string FunctionsRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Functions_read_the_data_tenant_from_the_request_context_not_from_the_token()
|
||||
{
|
||||
var root = FunctionsRoot();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var offenders = new List<string>();
|
||||
|
||||
foreach (var sub in new[] { "Functions", "Pagination" })
|
||||
{
|
||||
foreach (var path in Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
if (!TokenTenantRead.IsMatch(File.ReadAllText(path)))
|
||||
continue;
|
||||
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||
seen.Add(rel);
|
||||
if (!Baseline.ContainsKey(rel))
|
||||
offenders.Add(rel);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"Token tenant read in a function — take the data tenant from TenantHelper.GetTenantId(req) or the request " +
|
||||
"context, or add the file to the baseline with the identity reason:\n " + string.Join("\n ", offenders));
|
||||
|
||||
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"Baseline entries without a token tenant read any more — remove them (the ratchet only shrinks):\n " +
|
||||
string.Join("\n ", stale));
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,10 @@ public class TenantConfigPatchServiceTests
|
||||
[InlineData("intuneDeviceBindingPermissionMissingConfirmedAt")]
|
||||
[InlineData("cloudPcPermissionMissingSince")]
|
||||
[InlineData("cloudPcPermissionMissingConfirmedAt")]
|
||||
[InlineData("setupGuideSettingsReviewedAt")]
|
||||
[InlineData("setupGuideHardwareReviewedAt")]
|
||||
[InlineData("setupGuideDismissedAt")]
|
||||
[InlineData("setupGuideDismissedBy")]
|
||||
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
@@ -485,6 +489,36 @@ public class TenantConfigPatchServiceTests
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[\"*\"]")]
|
||||
[InlineData("[\"kiosk|admin\"]")]
|
||||
[InlineData("[\"S-1-12-1-1-2-3-4\"]")]
|
||||
[InlineData("\"kiosk\"")]
|
||||
public async Task Patch_InvalidAllowedLocalAccount_ValidationFails_NoWrite(string json)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("localAdminAllowedAccountsJson", json)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Contains("allowed local accounts", outcome.Error);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_StandardUserAllowedLocalAccount_Applies()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("localAdminAllowedAccountsJson", "[\"adm-*\",\"kiosk|user\"]")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success, outcome.Error);
|
||||
Assert.Equal("[\"adm-*\",\"kiosk|user\"]", harness.Current!.LocalAdminAllowedAccountsJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_UnrestrictedModeWithoutGate_ExplicitError_NotSilentFlip()
|
||||
{
|
||||
|
||||
@@ -247,6 +247,34 @@ public class TenantConfigTableSerializationTests
|
||||
Assert.Null(mapped.GraphAccessRejectionConfirmedAt);
|
||||
Assert.Null(mapped.IntuneDeviceBindingPermissionMissingSince);
|
||||
Assert.Null(mapped.CloudPcPermissionMissingConfirmedAt);
|
||||
Assert.Null(mapped.SetupGuideSettingsReviewedAt);
|
||||
Assert.Null(mapped.SetupGuideHardwareReviewedAt);
|
||||
Assert.Null(mapped.SetupGuideDismissedAt);
|
||||
Assert.Null(mapped.SetupGuideDismissedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_SetupGuideFields_SurviveStoreAndMap()
|
||||
{
|
||||
var at = new DateTime(2026, 11, 9, 8, 0, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
SetupGuideSettingsReviewedAt = at,
|
||||
SetupGuideHardwareReviewedAt = at.AddMinutes(2),
|
||||
SetupGuideDismissedAt = at.AddDays(1),
|
||||
SetupGuideDismissedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Equal(at, mapped.SetupGuideSettingsReviewedAt);
|
||||
Assert.Equal(at.AddMinutes(2), mapped.SetupGuideHardwareReviewedAt);
|
||||
Assert.Equal(at.AddDays(1), mapped.SetupGuideDismissedAt);
|
||||
Assert.Equal("admin@contoso.com", mapped.SetupGuideDismissedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -393,6 +393,10 @@ public class TypedContractCompletionWireParityTests
|
||||
trialAvailable = true,
|
||||
contactEmailSet = false,
|
||||
companyNameSet = false,
|
||||
setupGuideSettingsDone = true,
|
||||
setupGuideHardwareDone = false,
|
||||
setupGuideDismissed = false,
|
||||
hasEnrollmentSessions = false,
|
||||
entitlements = new
|
||||
{
|
||||
retentionCapDays = 90,
|
||||
@@ -424,6 +428,10 @@ public class TypedContractCompletionWireParityTests
|
||||
TrialAvailable = true,
|
||||
ContactEmailSet = false,
|
||||
CompanyNameSet = false,
|
||||
SetupGuideSettingsDone = true,
|
||||
SetupGuideHardwareDone = false,
|
||||
SetupGuideDismissed = false,
|
||||
HasEnrollmentSessions = false,
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
RetentionCapDays = 90,
|
||||
|
||||
@@ -47,8 +47,6 @@ public class TypedRequestGuardTests
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "agent route (D-200)",
|
||||
// Foreign schema: Azure Monitor common alert schema, not our contract.
|
||||
["Functions/Infrastructure/AzureMonitorAlertWebhookFunction.cs"] = "Azure Monitor alert schema",
|
||||
// Parses the UPSTREAM Kusto response, not the request body.
|
||||
["Functions/Raw/AppInsightsQueryFunction.cs"] = "upstream response parse",
|
||||
// Not request bodies at all: telemetry item payloads (agent wire, D-200) and stored table columns.
|
||||
["Functions/Ingest/TelemetryPayloadParser.cs"] = "telemetry item PayloadJson (agent wire, D-200)",
|
||||
["Functions/Config/ListTenantConfigBackupsFunction.cs"] = "stored backup diff column",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Differential test for the move of the Graph and Entra token-endpoint parsers to System.Text.Json (D-420):
|
||||
/// <see cref="UpstreamResponseCases"/> builds response bodies from a fixed seed per parser, and the production
|
||||
/// parser must yield exactly what the Newtonsoft reference in <see cref="LegacyUpstreamParsers"/> yields: same
|
||||
/// result, or both throw (every caller treats any escaped exception the same way).
|
||||
/// The bodies leave out the inputs of the deliberate differences D1 to D6; those are pinned in
|
||||
/// <see cref="GraphResponseParsingTests"/>. A body cut off mid-object (D7) is compared with the production
|
||||
/// parser's answer to an unreadable body instead: Newtonsoft's DeserializeObject read the part before the cut
|
||||
/// as if the open containers were closed.
|
||||
/// Temporary: removed with <see cref="LegacyUpstreamParsers"/> after a clean production run (backlog). The
|
||||
/// generator and the readings stay for <see cref="UpstreamJsonInvariantTests"/>.
|
||||
/// </summary>
|
||||
public class UpstreamJsonDifferentialTests
|
||||
{
|
||||
private const int CaseCount = 20_000;
|
||||
|
||||
[Fact]
|
||||
public void Token_response_matches_the_reference()
|
||||
=> Compare(seed: 4201, g => g.TokenBody(),
|
||||
b => Describe(GraphTokenService.ReadTokenResponse(b)),
|
||||
b => Describe(LegacyUpstreamParsers.ReadTokenResponse(b)),
|
||||
hit: o => o.StartsWith("token=\"", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Entra_rejection_matches_the_reference()
|
||||
=> Compare(seed: 4202, g => g.EntraBody(),
|
||||
b => Describe(EntraTokenRejection.Parse(400, b)),
|
||||
b => Describe(LegacyUpstreamParsers.ParseEntraRejection(400, b)),
|
||||
hit: o => o.Contains("code=\"AADSTS", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Autopilot_list_matches_the_reference()
|
||||
=> Compare(seed: 4203, g => g.AutopilotBody(Serial),
|
||||
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
|
||||
b => Describe(LegacyUpstreamParsers.FindRegistration(b, Serial)),
|
||||
hit: o => o.StartsWith("id=", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Corporate_page_matches_the_reference()
|
||||
=> Compare(seed: 4204, g => g.CorporateBody(),
|
||||
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
|
||||
b => Describe(LegacyUpstreamParsers.ReadIdentityPage(b, CorporateId)),
|
||||
hit: o => o.StartsWith("found=True", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Association_list_matches_the_reference()
|
||||
=> Compare(seed: 4205, g => g.AssociationBody(Serial),
|
||||
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
|
||||
b => Describe(LegacyUpstreamParsers.ParseAssociation(b, Serial)),
|
||||
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void CloudPc_list_matches_the_reference()
|
||||
=> Compare(seed: 4206, g => g.CloudPcBody(IntuneId),
|
||||
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
|
||||
b => Describe(LegacyUpstreamParsers.ParseCloudPc(b, IntuneId)),
|
||||
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Managed_device_matches_the_reference()
|
||||
=> Compare(seed: 4207, g => g.ManagedDeviceBody(IntuneId),
|
||||
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
|
||||
b => Describe(LegacyUpstreamParsers.ParseManagedDevice(b, IntuneId)),
|
||||
hit: o => !o.StartsWith("outcome=NotFound", StringComparison.Ordinal) && o != "<throws>");
|
||||
|
||||
[Fact]
|
||||
public void Script_page_matches_the_reference()
|
||||
=> Compare(seed: 4208, g => g.ScriptPageBody(),
|
||||
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
|
||||
b => Describe(LegacyUpstreamParsers.ReadScriptPage(b, withFileName: true)),
|
||||
hit: o => o.StartsWith("\"", StringComparison.Ordinal));
|
||||
|
||||
[Fact]
|
||||
public void Script_name_matches_the_reference()
|
||||
=> Compare(seed: 4209, g => g.ScriptNameBody(),
|
||||
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
|
||||
b => Describe(LegacyUpstreamParsers.ReadScriptDisplayName(b)),
|
||||
hit: o => o != "<null>" && o != "<throws>");
|
||||
|
||||
private static void Compare(int seed, Func<UpstreamResponseCases, string> body, Func<string, string> actual, Func<string, string> reference, Func<string, bool> hit)
|
||||
{
|
||||
// The reference formats dates in the current culture before parsing them back; en-US keeps its
|
||||
// whole-second "Z" dates exact. The culture swap on other hosts is pinned in GraphResponseParsingTests.
|
||||
var saved = CultureInfo.CurrentCulture;
|
||||
CultureInfo.CurrentCulture = new CultureInfo("en-US");
|
||||
try
|
||||
{
|
||||
var generator = new UpstreamResponseCases(seed);
|
||||
var unreadable = Outcome(() => actual("not json"));
|
||||
var mismatches = new List<string>();
|
||||
int hits = 0, throws = 0;
|
||||
for (var i = 0; i < CaseCount && mismatches.Count < 5; i++)
|
||||
{
|
||||
var input = body(generator);
|
||||
var got = Outcome(() => actual(input));
|
||||
var want = generator.LastWasCutOff ? unreadable : Outcome(() => reference(input));
|
||||
if (got != want)
|
||||
mismatches.Add($"seed {seed}, case {i}: body {input}\n production: {got}\n reference: {want}");
|
||||
if (hit(want)) hits++;
|
||||
if (want == "<throws>") throws++;
|
||||
}
|
||||
Assert.True(mismatches.Count == 0, "Parser differs from the Newtonsoft reference:\n " + string.Join("\n ", mismatches));
|
||||
|
||||
// The generator must exercise the parser, not compare 20,000 identical misses.
|
||||
Assert.True(hits >= CaseCount / 50, $"only {hits} of {CaseCount} cases reach a result worth comparing");
|
||||
Assert.True(throws <= CaseCount * 7 / 10, $"{throws} of {CaseCount} cases throw; the generator barely reaches the parser");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using Xunit;
|
||||
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Invariant fuzz for the Graph and Entra token-endpoint parsers (D-420), without a reference. Over bodies of
|
||||
/// every value kind, the inputs of D1 to D7 included (<see cref="UpstreamResponseCases"/>), each parser throws
|
||||
/// only what its callers expect, reads a body the same way however it is laid out, and returns no text the body
|
||||
/// did not send; <see cref="UpstreamJson.ParseObjectOrNull"/> yields an object, null or a JsonException for any
|
||||
/// text. Permanent: 500 cases per parser and check.
|
||||
/// </summary>
|
||||
public class UpstreamJsonInvariantTests
|
||||
{
|
||||
private const int CaseCount = 500;
|
||||
|
||||
/// <summary>An exception kind a parser's callers expect, named for the failure message.</summary>
|
||||
private sealed record Thrown(string Name, Func<Exception, bool> Matches);
|
||||
|
||||
// The callers catch JsonException or everything; an unreadable body or next link throws it.
|
||||
private static readonly Thrown Unreadable = new("JsonException", ex => ex is JsonException);
|
||||
|
||||
// A list entry that is no object before the match: the validators answer transient, as before the move.
|
||||
// Only the exact type counts; ObjectDisposedException derives from it and would mean a disposed document.
|
||||
private static readonly Thrown NonObjectEntry = new("InvalidOperationException", ex => ex.GetType() == typeof(InvalidOperationException));
|
||||
|
||||
/// <summary>A parser as the fuzz drives it.</summary>
|
||||
/// <param name="Seed">The generator seed; each check offsets it to see other bodies.</param>
|
||||
/// <param name="Body">The body generator for this parser.</param>
|
||||
/// <param name="Read">The canonical reading of a body.</param>
|
||||
/// <param name="Texts">Every text the result took from the body.</param>
|
||||
/// <param name="MayThrow">The exception kinds that may escape; every one of them must also occur.</param>
|
||||
private sealed record Parser(
|
||||
int Seed,
|
||||
Func<UpstreamResponseCases, string> Body,
|
||||
Func<string, string> Read,
|
||||
Func<string, IEnumerable<string?>> Texts,
|
||||
Thrown[] MayThrow);
|
||||
|
||||
private static readonly Dictionary<string, Parser> Parsers = new()
|
||||
{
|
||||
["token"] = new(4301, g => g.TokenBody(),
|
||||
b => Describe(GraphTokenService.ReadTokenResponse(b)),
|
||||
b => new[] { GraphTokenService.ReadTokenResponse(b).AccessToken },
|
||||
new[] { Unreadable }),
|
||||
["entra"] = new(4302, g => g.EntraBody(),
|
||||
b => Describe(EntraTokenRejection.Parse(400, b)),
|
||||
b => new[] { EntraTokenRejection.Parse(400, b).Description },
|
||||
Array.Empty<Thrown>()),
|
||||
["autopilot"] = new(4303, g => g.AutopilotBody(Serial),
|
||||
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
|
||||
b => AutopilotDeviceValidator.FindRegistration(b, Serial) is { } r
|
||||
? new[] { r.DeviceId, r.GroupTag, r.PurchaseOrderId, r.DisplayName }
|
||||
: Array.Empty<string?>(),
|
||||
new[] { Unreadable, NonObjectEntry }),
|
||||
["corporate"] = new(4304, g => g.CorporateBody(),
|
||||
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
|
||||
// An empty next url is the parser's own "no further page", not text from the body.
|
||||
b => new[] { CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId).NextUrl is { Length: > 0 } next ? next : null },
|
||||
new[] { Unreadable, NonObjectEntry }),
|
||||
["association"] = new(4305, g => g.AssociationBody(Serial),
|
||||
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
|
||||
b =>
|
||||
{
|
||||
var r = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial);
|
||||
return new[] { r.AssociationState, r.DevicePreparationPolicyId, r.PreAssociatedByUserPrincipalName, r.AssignedToUserPrincipalName, r.ManagedDeviceId };
|
||||
},
|
||||
new[] { NonObjectEntry }),
|
||||
["cloudpc"] = new(4306, g => g.CloudPcBody(IntuneId),
|
||||
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
|
||||
b =>
|
||||
{
|
||||
var r = CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId);
|
||||
return new[] { r.CloudPcId, r.ManagedDeviceName, r.ServicePlanName };
|
||||
},
|
||||
new[] { NonObjectEntry }),
|
||||
["managed-device"] = new(4307, g => g.ManagedDeviceBody(IntuneId),
|
||||
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
|
||||
b =>
|
||||
{
|
||||
var r = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId);
|
||||
return new[] { r.DeviceName, r.AzureAdDeviceId, r.ManagementState, r.SerialNumber, r.OwnerType, r.EnrollmentType };
|
||||
},
|
||||
Array.Empty<Thrown>()),
|
||||
["script-page"] = new(4308, g => g.ScriptPageBody(),
|
||||
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
|
||||
b =>
|
||||
{
|
||||
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true);
|
||||
return scripts.SelectMany(s => new[] { s.Id, s.DisplayName, s.FileName }).Append(next);
|
||||
},
|
||||
new[] { Unreadable }),
|
||||
["script-name"] = new(4309, g => g.ScriptNameBody(),
|
||||
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
|
||||
b => new[] { ScriptDisplayNameResolver.ReadScriptDisplayName(b) },
|
||||
new[] { Unreadable }),
|
||||
};
|
||||
|
||||
public static IEnumerable<object[]> ParserNames => Parsers.Keys.Select(name => new object[] { name });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParserNames))]
|
||||
public void A_parser_throws_only_what_its_callers_expect(string name)
|
||||
{
|
||||
var parser = Parsers[name];
|
||||
var cases = new UpstreamResponseCases(parser.Seed, everyValueKind: true);
|
||||
var seen = new HashSet<string>();
|
||||
for (var i = 0; i < CaseCount; i++)
|
||||
{
|
||||
var body = parser.Body(cases);
|
||||
try
|
||||
{
|
||||
parser.Read(body);
|
||||
}
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
|
||||
{
|
||||
var kind = parser.MayThrow.FirstOrDefault(t => t.Matches(ex));
|
||||
Assert.True(kind != null, $"{name}, case {i}: {ex.GetType().Name} escapes ({ex.Message}) for body {body}");
|
||||
seen.Add(kind!.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Every listed kind still occurs, so the list cannot quietly outgrow what the parser does.
|
||||
Assert.Equal(parser.MayThrow.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal), seen.OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParserNames))]
|
||||
public void Layout_never_changes_what_a_parser_reads(string name)
|
||||
{
|
||||
var parser = Parsers[name];
|
||||
var cases = new UpstreamResponseCases(parser.Seed + 100, everyValueKind: true);
|
||||
var layout = new Random(parser.Seed);
|
||||
var compared = 0;
|
||||
for (var i = 0; i < CaseCount; i++)
|
||||
{
|
||||
var body = parser.Body(cases);
|
||||
if (ReadableObject(body) is not { } root)
|
||||
continue;
|
||||
|
||||
var expected = Outcome(() => parser.Read(body));
|
||||
foreach (var spread in new[] { false, true })
|
||||
{
|
||||
var twin = Render(root, layout, spread);
|
||||
var actual = Outcome(() => parser.Read(twin));
|
||||
Assert.True(expected == actual, $"{name}, case {i}: body {body}\n laid out as {twin}\n reads {actual}\n instead of {expected}");
|
||||
}
|
||||
compared++;
|
||||
}
|
||||
|
||||
Assert.True(compared >= CaseCount / 2, $"only {compared} of {CaseCount} bodies were readable objects");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParserNames))]
|
||||
public void A_parser_returns_only_text_the_body_sent(string name)
|
||||
{
|
||||
var parser = Parsers[name];
|
||||
var cases = new UpstreamResponseCases(parser.Seed + 200, everyValueKind: true);
|
||||
var checkedTexts = 0;
|
||||
for (var i = 0; i < CaseCount; i++)
|
||||
{
|
||||
var body = parser.Body(cases);
|
||||
string?[] texts;
|
||||
try
|
||||
{
|
||||
texts = parser.Texts(body).ToArray();
|
||||
}
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
|
||||
{
|
||||
continue; // which exceptions may escape is the first check's business
|
||||
}
|
||||
|
||||
var sent = ReadableObject(body) is { } root ? SentTexts(root) : new List<string>();
|
||||
foreach (var text in texts.OfType<string>())
|
||||
{
|
||||
// Trimming and cutting keep a text inside what was sent; an empty text needs a blank one.
|
||||
var fromBody = text.Length == 0
|
||||
? sent.Any(string.IsNullOrWhiteSpace)
|
||||
: sent.Any(s => s.Contains(text, StringComparison.Ordinal));
|
||||
Assert.True(fromBody, $"{name}, case {i}: \"{text}\" is no text the body sent: {body}");
|
||||
checkedTexts++;
|
||||
}
|
||||
}
|
||||
|
||||
// The corporate page returns its next url as the only text, so the bar is low.
|
||||
Assert.True(checkedTexts >= CaseCount / 50, $"only {checkedTexts} texts came back from {CaseCount} bodies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseObjectOrNull_yields_an_object_or_null_or_throws_JsonException_for_any_text()
|
||||
{
|
||||
var edits = new Random(4310);
|
||||
int objects = 0, nulls = 0, refused = 0;
|
||||
foreach (var parser in Parsers.Values)
|
||||
{
|
||||
var cases = new UpstreamResponseCases(parser.Seed + 300, everyValueKind: true);
|
||||
for (var i = 0; i < CaseCount / 2; i++)
|
||||
{
|
||||
var body = parser.Body(cases);
|
||||
foreach (var text in new[] { body, Mutate(body, edits) })
|
||||
{
|
||||
try
|
||||
{
|
||||
if (UpstreamJson.ParseObjectOrNull(text) is { } json)
|
||||
{
|
||||
Assert.Equal(JsonValueKind.Object, json.ValueKind);
|
||||
objects++;
|
||||
}
|
||||
else
|
||||
{
|
||||
nulls++;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
refused++;
|
||||
}
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
|
||||
{
|
||||
Assert.Fail($"{ex.GetType().Name} ({ex.Message}) for text {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(objects > 0 && nulls > 0 && refused > 0, $"objects {objects}, nulls {nulls}, refused {refused}");
|
||||
}
|
||||
|
||||
private static JsonElement? ReadableObject(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
return UpstreamJson.ParseObjectOrNull(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every string value in the body, decoded; one whose escapes are no valid UTF-16 is skipped.</summary>
|
||||
private static List<string> SentTexts(JsonElement root)
|
||||
{
|
||||
var texts = new List<string>();
|
||||
Collect(root);
|
||||
return texts;
|
||||
|
||||
void Collect(JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var member in element.EnumerateObject())
|
||||
Collect(member.Value);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
Collect(item);
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
try { texts.Add(element.GetString()!); }
|
||||
catch (InvalidOperationException) { }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The body written again in another layout: compact, or spread out with block and line comments and
|
||||
/// trailing commas. Object members change their order unless a name repeats (the last one must stay last);
|
||||
/// values keep their raw text, escapes and number form included.
|
||||
/// </summary>
|
||||
private static string Render(JsonElement root, Random rng, bool spread)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
Write(root);
|
||||
return text.ToString();
|
||||
|
||||
void Write(JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
var members = element.EnumerateObject().ToList();
|
||||
if (members.Select(m => m.Name).Distinct(StringComparer.Ordinal).Count() == members.Count)
|
||||
members = members.OrderBy(_ => rng.Next()).ToList();
|
||||
text.Append(spread ? "{ /* object */\n " : "{");
|
||||
for (var i = 0; i < members.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
text.Append(spread ? (rng.Next(2) == 0 ? ",// next\n " : " ,\t/* next */ ") : ",");
|
||||
text.Append(JsonSerializer.Serialize(members[i].Name)).Append(spread ? " : " : ":");
|
||||
Write(members[i].Value);
|
||||
}
|
||||
text.Append(spread && members.Count > 0 ? ",\n}" : "}");
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
var items = element.EnumerateArray().ToList();
|
||||
text.Append(spread ? "[\n " : "[");
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
text.Append(spread ? " , " : ",");
|
||||
Write(items[i]);
|
||||
}
|
||||
text.Append(spread && items.Count > 0 ? ",\n]" : "]");
|
||||
break;
|
||||
default:
|
||||
text.Append(element.GetRawText());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One random edit: a character removed, inserted or replaced, or the text cut short.</summary>
|
||||
private static string Mutate(string text, Random rng)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
return "{";
|
||||
const string alphabet = "{}[]\",:/*\\ \n0123456789-+.eEtrufalsné";
|
||||
var at = rng.Next(text.Length);
|
||||
var c = alphabet[rng.Next(alphabet.Length)].ToString();
|
||||
return rng.Next(4) switch
|
||||
{
|
||||
0 => text.Remove(at, 1),
|
||||
1 => text.Insert(at, c),
|
||||
2 => text.Substring(0, at),
|
||||
_ => text.Remove(at, 1).Insert(at, c),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The contract of <see cref="UpstreamJson"/> (D-420), the reader under every Graph and Entra token-endpoint
|
||||
/// parser: one test group per member, then the culture matrix. The parser-level contract is in
|
||||
/// <see cref="GraphResponseParsingTests"/>.
|
||||
/// </summary>
|
||||
public class UpstreamJsonTests
|
||||
{
|
||||
private static JsonElement Obj(string json) => UpstreamJson.ParseObjectOrNull(json)!.Value;
|
||||
|
||||
// ── ParseObjectOrNull ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\r\n\t")]
|
||||
[InlineData("null")]
|
||||
[InlineData(" null ")]
|
||||
[InlineData("/*c*/ null")]
|
||||
public void ParseObjectOrNull_reads_a_blank_body_and_a_null_root_as_no_object(string? body)
|
||||
=> Assert.Null(UpstreamJson.ParseObjectOrNull(body));
|
||||
|
||||
[Theory]
|
||||
[InlineData("{}")]
|
||||
[InlineData(" { \"a\" : 1 } ")]
|
||||
[InlineData("{\"a\":1,}")]
|
||||
[InlineData("{/*c*/\"a\":[1,2,],// line\n}")]
|
||||
[InlineData("// leading\n{\"a\":1}")]
|
||||
public void ParseObjectOrNull_reads_one_object_with_comments_and_trailing_commas(string body)
|
||||
=> Assert.Equal(JsonValueKind.Object, UpstreamJson.ParseObjectOrNull(body)!.Value.ValueKind);
|
||||
|
||||
[Fact]
|
||||
public void ParseObjectOrNull_returns_an_element_that_outlives_the_parse()
|
||||
{
|
||||
// The document is disposed inside the call; only a cloned root stays readable.
|
||||
var json = Obj("""{"a":"b","list":[{"c":"d"}]}""");
|
||||
GC.Collect();
|
||||
Assert.Equal("b", json.GetProperty("a").GetString());
|
||||
Assert.Equal("d", json.GetProperty("list")[0].GetProperty("c").GetString());
|
||||
}
|
||||
|
||||
// Everything that is not exactly one object throws JsonException, the type the callers catch. The syntax
|
||||
// only Newtonsoft tolerated (single quotes, bare names, NaN, hex, undefined, raw line breaks in strings)
|
||||
// is malformed too; Graph and Entra never send it.
|
||||
[Theory]
|
||||
[InlineData("[]")]
|
||||
[InlineData("[{}]")]
|
||||
[InlineData("42")]
|
||||
[InlineData("\"text\"")]
|
||||
[InlineData("true")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("<html>busy</html>")]
|
||||
[InlineData("{")]
|
||||
[InlineData("{\"a\":}")]
|
||||
[InlineData("{} x")]
|
||||
[InlineData("{}{}")]
|
||||
[InlineData("{} /* unterminated")]
|
||||
[InlineData("{'a':'b'}")]
|
||||
[InlineData("{a:1}")]
|
||||
[InlineData("{\"a\":NaN}")]
|
||||
[InlineData("{\"a\":0x1F}")]
|
||||
[InlineData("{\"a\":undefined}")]
|
||||
[InlineData("{\"a\":\"line\nbreak\"}")]
|
||||
[InlineData("{\"a\":1,,}")]
|
||||
public void ParseObjectOrNull_throws_JsonException_for_anything_but_one_object(string body)
|
||||
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(body));
|
||||
|
||||
[Fact]
|
||||
public void ParseObjectOrNull_throws_JsonException_for_nesting_beyond_the_depth_limit()
|
||||
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(
|
||||
"{\"a\":" + new string('[', 100) + new string(']', 100) + "}"));
|
||||
|
||||
// ── GetString ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"a":"value"}""", "value")]
|
||||
[InlineData("""{"a":""}""", "")]
|
||||
[InlineData("""{"a":" padded "}""", " padded ")]
|
||||
[InlineData("""{"a":"quote\" back\\ slash\n tab\t é 😀"}""", "quote\" back\\ slash\n tab\t é 😀")]
|
||||
[InlineData("""{"a":"2026-01-01T00:00:00Z"}""", "2026-01-01T00:00:00Z")]
|
||||
[InlineData("""{"a":"/Date(1713096021000)/"}""", "/Date(1713096021000)/")]
|
||||
[InlineData("""{"a":"42"}""", "42")]
|
||||
[InlineData("""{"a":"first","a":"last"}""", "last")]
|
||||
public void GetString_reads_text_as_sent(string json, string expected)
|
||||
=> Assert.Equal(expected, UpstreamJson.GetString(Obj(json), "a"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{}""")]
|
||||
[InlineData("""{"a":null}""")]
|
||||
[InlineData("""{"a":42}""")]
|
||||
[InlineData("""{"a":4.2}""")]
|
||||
[InlineData("""{"a":true}""")]
|
||||
[InlineData("""{"a":false}""")]
|
||||
[InlineData("""{"a":{}}""")]
|
||||
[InlineData("""{"a":[]}""")]
|
||||
[InlineData("""{"a":["x"]}""")]
|
||||
[InlineData("""{"A":"other case"}""")]
|
||||
[InlineData("""{"a ":"other name"}""")]
|
||||
public void GetString_reads_anything_but_text_under_the_exact_name_as_absent(string json)
|
||||
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("null")]
|
||||
[InlineData("42")]
|
||||
[InlineData("\"text\"")]
|
||||
[InlineData("[]")]
|
||||
public void GetString_on_a_list_entry_that_is_no_object_throws_InvalidOperationException(string entry)
|
||||
{
|
||||
// The validators rely on this: a non-object entry before the match keeps ending the lookup as transient.
|
||||
var item = Obj($$"""{"value":[{{entry}}]}""").GetProperty("value")[0];
|
||||
Assert.Throws<InvalidOperationException>(() => UpstreamJson.GetString(item, "a"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"a":"x\ud800y"}""")]
|
||||
[InlineData("""{"a":"\udc00"}""")]
|
||||
[InlineData("""{"a":"\ud800A"}""")]
|
||||
public void GetString_reads_text_with_a_broken_surrogate_escape_as_absent(string json)
|
||||
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
|
||||
|
||||
// ── Items ───────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Items_yields_every_array_entry_in_order_whatever_its_kind()
|
||||
=> Assert.Equal(
|
||||
new[] { JsonValueKind.Object, JsonValueKind.Null, JsonValueKind.Number, JsonValueKind.String, JsonValueKind.Array },
|
||||
UpstreamJson.Items(Obj("""{"value":[{},null,1,"x",[]]}"""), "value").Select(i => i.ValueKind));
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{}""")]
|
||||
[InlineData("""{"value":null}""")]
|
||||
[InlineData("""{"value":{}}""")]
|
||||
[InlineData("""{"value":"oops"}""")]
|
||||
[InlineData("""{"value":42}""")]
|
||||
[InlineData("""{"value":[]}""")]
|
||||
[InlineData("""{"Value":[1]}""")]
|
||||
public void Items_is_empty_unless_the_property_is_a_non_empty_array(string json)
|
||||
=> Assert.Empty(UpstreamJson.Items(Obj(json), "value"));
|
||||
|
||||
// ── GetNextLink ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{}""", null)]
|
||||
[InlineData("""{"@odata.nextLink":null}""", null)]
|
||||
[InlineData("""{"@odata.nextLink":""}""", "")]
|
||||
[InlineData("""{"@odata.nextLink":"https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb"}""", "https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb")]
|
||||
[InlineData("""{"@odata.nextlink":"https://graph.microsoft.com/beta/other-case"}""", null)]
|
||||
public void GetNextLink_reads_the_link_or_its_absence(string json, string? expected)
|
||||
=> Assert.Equal(expected, UpstreamJson.GetNextLink(Obj(json)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("5")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("[\"https://graph.microsoft.com/beta/x\"]")]
|
||||
[InlineData("\"https://graph.microsoft.com/beta/x\\ud800\"")]
|
||||
public void GetNextLink_throws_for_a_link_that_is_not_text(string link)
|
||||
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.GetNextLink(Obj($$"""{"@odata.nextLink":{{link}}}""")));
|
||||
|
||||
// ── GetInt64 ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("3599", 3599L)]
|
||||
[InlineData("0", 0L)]
|
||||
[InlineData("-5", -5L)]
|
||||
[InlineData("9223372036854775807", long.MaxValue)]
|
||||
[InlineData("-9223372036854775808", long.MinValue)]
|
||||
[InlineData("\"3599\"", 3599L)]
|
||||
[InlineData("\" 3599 \"", 3599L)]
|
||||
[InlineData("\"+3599\"", 3599L)]
|
||||
[InlineData("\"-1\"", -1L)]
|
||||
public void GetInt64_reads_a_whole_number_or_its_text(string value, long expected)
|
||||
=> Assert.Equal(expected, UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("3599.5")]
|
||||
[InlineData("3599.0")]
|
||||
[InlineData("7e2")]
|
||||
[InlineData("9223372036854775808")]
|
||||
[InlineData("99999999999999999999")]
|
||||
[InlineData("\"3599.0\"")]
|
||||
[InlineData("\"3,599\"")]
|
||||
[InlineData("\"3.599\"")]
|
||||
[InlineData("\"0x10\"")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\"abc\"")]
|
||||
[InlineData("\"\u0661\u0662\u0663\"")]
|
||||
[InlineData("\"\\ud800\"")]
|
||||
[InlineData("null")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("[]")]
|
||||
public void GetInt64_reads_anything_but_a_whole_number_as_absent(string value)
|
||||
=> Assert.Null(UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
|
||||
|
||||
[Fact]
|
||||
public void GetInt64_reads_a_missing_property_as_absent()
|
||||
=> Assert.Null(UpstreamJson.GetInt64(Obj("{}"), "n"));
|
||||
|
||||
// ── GetBoolean ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("true", true)]
|
||||
[InlineData("false", false)]
|
||||
[InlineData("null", null)]
|
||||
[InlineData("\"true\"", null)]
|
||||
[InlineData("\"false\"", null)]
|
||||
[InlineData("1", null)]
|
||||
[InlineData("0", null)]
|
||||
[InlineData("{}", null)]
|
||||
public void GetBoolean_reads_only_true_and_false(string value, bool? expected)
|
||||
=> Assert.Equal(expected, UpstreamJson.GetBoolean(Obj($$"""{"b":{{value}}}"""), "b"));
|
||||
|
||||
[Fact]
|
||||
public void GetBoolean_reads_a_missing_property_as_absent()
|
||||
=> Assert.Null(UpstreamJson.GetBoolean(Obj("{}"), "b"));
|
||||
|
||||
// ── GetUtcDateTime ──────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-05-08T12:16:49Z", 0L)]
|
||||
[InlineData("2026-05-08T12:16:49.1234567Z", 1234567L)]
|
||||
[InlineData("2026-05-08T12:16:49.5Z", 5000000L)]
|
||||
[InlineData("2026-05-08T14:16:49+02:00", 0L)]
|
||||
[InlineData("2026-05-08T07:16:49.1234567-05:00", 1234567L)]
|
||||
[InlineData("2026-05-08T12:16:49", 0L)]
|
||||
public void GetUtcDateTime_reads_the_instant_in_utc_with_full_precision(string value, long fractionTicks)
|
||||
{
|
||||
var date = UpstreamJson.GetUtcDateTime(Obj($$"""{"d":"{{value}}"}"""), "d");
|
||||
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(fractionTicks), date);
|
||||
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUtcDateTime_reads_a_date_without_time_as_its_utc_midnight()
|
||||
=> Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(Obj("""{"d":"2026-05-08"}"""), "d"));
|
||||
|
||||
// Graph sends 0001-01-01 for a date it does not have: absence, not a date two thousand years ago.
|
||||
[Theory]
|
||||
[InlineData("\"0001-01-01T00:00:00Z\"")]
|
||||
[InlineData("\"0001-01-01T00:00:00\"")]
|
||||
[InlineData("\"0001-01-01\"")]
|
||||
[InlineData("\"\"")]
|
||||
[InlineData("\" \"")]
|
||||
[InlineData("\"not-a-date\"")]
|
||||
[InlineData("\"2026-13-01T00:00:00Z\"")]
|
||||
[InlineData("\"/Date(1713096021000)/\"")]
|
||||
[InlineData("null")]
|
||||
[InlineData("20260508")]
|
||||
[InlineData("true")]
|
||||
[InlineData("{}")]
|
||||
public void GetUtcDateTime_reads_an_unset_or_unusable_value_as_absent(string value)
|
||||
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj($$"""{"d":{{value}}}"""), "d"));
|
||||
|
||||
[Fact]
|
||||
public void GetUtcDateTime_reads_a_missing_property_as_absent()
|
||||
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj("{}"), "d"));
|
||||
|
||||
// ── Culture matrix ──────────────────────────────────────────────────────────
|
||||
|
||||
// No member may depend on the host culture: day-first, Thai Buddhist calendar and the en-US default all
|
||||
// read the same body the same way.
|
||||
[Theory]
|
||||
[InlineData("en-US")]
|
||||
[InlineData("de-DE")]
|
||||
[InlineData("en-GB")]
|
||||
[InlineData("th-TH")]
|
||||
public void Every_member_reads_the_same_in_any_culture(string culture)
|
||||
{
|
||||
var saved = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
||||
var json = Obj("""{"d":"2026-05-08T12:16:49.1234567Z","day":"2026-05-08","n":"3599","t":"2026-05-08T12:16:49Z","f":"1.5"}""");
|
||||
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), UpstreamJson.GetUtcDateTime(json, "d"));
|
||||
Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(json, "day"));
|
||||
Assert.Equal(3599L, UpstreamJson.GetInt64(json, "n"));
|
||||
Assert.Equal("2026-05-08T12:16:49Z", UpstreamJson.GetString(json, "t"));
|
||||
Assert.Null(UpstreamJson.GetInt64(json, "f"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One canonical text per result of the Graph and Entra token-endpoint parsers (D-420), so two readings of a
|
||||
/// body compare as strings: the differential test compares production with the Newtonsoft reference, the
|
||||
/// invariant fuzz compares a body with its reformatted twin.
|
||||
/// </summary>
|
||||
internal static class UpstreamParserReadings
|
||||
{
|
||||
internal const string Serial = "PF3XKQ7";
|
||||
internal const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
|
||||
|
||||
internal static readonly string CorporateId =
|
||||
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
|
||||
|
||||
/// <summary>The reading, or "<throws>" when the parser throws.</summary>
|
||||
internal static string Outcome(Func<string> run)
|
||||
{
|
||||
try { return run(); }
|
||||
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { return "<throws>"; }
|
||||
}
|
||||
|
||||
private static string Text(string? value) => value is null ? "<null>" : "\"" + value + "\"";
|
||||
|
||||
private static string Date(DateTime? value) => value is { } d ? d.ToString("O", CultureInfo.InvariantCulture) + "/" + d.Kind : "<null>";
|
||||
|
||||
// Every consumer reads the token with IsNullOrEmpty, so an empty and an absent token describe alike.
|
||||
internal static string Describe((string? AccessToken, long? ExpiresInSeconds) r) =>
|
||||
$"token={(string.IsNullOrEmpty(r.AccessToken) ? "<none>" : Text(r.AccessToken))} expires={r.ExpiresInSeconds?.ToString(CultureInfo.InvariantCulture) ?? "<null>"}";
|
||||
|
||||
internal static string Describe(EntraTokenRejection r) => $"status={r.StatusCode} code={Text(r.ErrorCode)} description={Text(r.Description)}";
|
||||
|
||||
internal static string Describe(AutopilotMonitor.Shared.Models.AutopilotRegistration? r) => r is null
|
||||
? "<not registered>"
|
||||
: $"id={Text(r.DeviceId)} tag={Text(r.GroupTag)} po={Text(r.PurchaseOrderId)} name={Text(r.DisplayName)} user={r.UserAssigned}";
|
||||
|
||||
internal static string Describe((bool Found, string NextUrl) r) => $"found={r.Found} next={Text(r.NextUrl)}";
|
||||
|
||||
internal static string Describe(DeviceAssociationResult r) =>
|
||||
$"valid={r.IsValid} transient={r.IsTransient} serial={Text(r.SerialNumber)} state={Text(r.AssociationState)} policy={Text(r.DevicePreparationPolicyId)} " +
|
||||
$"preBy={Text(r.PreAssociatedByUserPrincipalName)} assigned={Text(r.AssignedToUserPrincipalName)} pre={Date(r.PreAssociationDateTime)} " +
|
||||
$"at={Date(r.AssociationDateTime)} device={Text(r.ManagedDeviceId)} error={Text(r.ErrorMessage)}";
|
||||
|
||||
internal static string Describe(CloudPcValidationResult r) =>
|
||||
$"valid={r.IsValid} transient={r.IsTransient} device={Text(r.IntuneDeviceId)} cpc={Text(r.CloudPcId)} name={Text(r.ManagedDeviceName)} " +
|
||||
$"plan={Text(r.ServicePlanName)} error={Text(r.ErrorMessage)}";
|
||||
|
||||
internal static string Describe(IntuneDeviceBindingResult r) =>
|
||||
$"outcome={r.Outcome} device={Text(r.IntuneDeviceId)} name={Text(r.DeviceName)} aad={Text(r.AzureAdDeviceId)} state={Text(r.ManagementState)} " +
|
||||
$"serial={Text(r.SerialNumber)} owner={Text(r.OwnerType)} enrollment={Text(r.EnrollmentType)} registered={r.AzureAdRegistered?.ToString() ?? "<null>"} " +
|
||||
$"enrolled={(r.EnrolledDateTime is { } e ? e.ToString("O", CultureInfo.InvariantCulture) : "<null>")} error={Text(r.ErrorMessage)}";
|
||||
|
||||
// A null or empty next link both end the full pull, so they describe alike.
|
||||
internal static string Describe((List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) r) =>
|
||||
string.Join(";", r.Scripts.Select(s => $"{Text(s.Id)}={Text(s.DisplayName)}/{Text(s.FileName)}")) + $" next={Text(string.IsNullOrEmpty(r.NextUrl) ? null : r.NextUrl)}";
|
||||
|
||||
internal static string Describe(string? scriptName) => scriptName ?? "<null>";
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic Graph and Entra token-endpoint response bodies for the parser tests of D-420. They vary root
|
||||
/// kind, empty, broken and cut-off bodies, trailing content, comments, trailing commas, whitespace, property
|
||||
/// order, unknown and duplicate properties, mixed list entries, text with escapes and Unicode, paging links,
|
||||
/// lifetimes as number or numeric text, and error-code variants.
|
||||
/// By default they leave out the inputs of the deliberate differences D1 to D6, so the old and the new parser
|
||||
/// must agree on them. With <c>everyValueKind</c> they add those inputs too: JSON null, numbers, booleans,
|
||||
/// objects and arrays in text fields, date-shaped text, dates with fractions and offsets, escapes that are no
|
||||
/// valid UTF-16, unusable lifetimes and error codes.
|
||||
/// </summary>
|
||||
internal sealed class UpstreamResponseCases
|
||||
{
|
||||
// The same few literals come back thousands of times; serializing each once keeps the fuzz fast. Declared
|
||||
// first: the pools below call Str while the type initializes.
|
||||
private static readonly ConcurrentDictionary<string, string> Serialized = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly Random _rng;
|
||||
private readonly bool _everyValueKind;
|
||||
|
||||
public UpstreamResponseCases(int seed, bool everyValueKind = false)
|
||||
{
|
||||
_rng = new Random(seed);
|
||||
_everyValueKind = everyValueKind;
|
||||
}
|
||||
|
||||
/// <summary>Whether the last body was the object cut off halfway (D7).</summary>
|
||||
public bool LastWasCutOff { get; private set; }
|
||||
|
||||
// Plain text that is never a date in either library and never JSON null.
|
||||
private static readonly string[] PlainTexts =
|
||||
{
|
||||
Str("value"), Str(" padded "), Str("UPPER lower 123"), "\"Ünïcødé ☃ ε\"", Str("quote\" back\\ slash\nnew line"),
|
||||
Str(new string('x', 300)), Str(""), Str(" "), Str("a/b?c=d&e"), Str("é\u0001\u007f"), Str("2026"), Str("12:16"),
|
||||
};
|
||||
|
||||
// What D1, D3 and D4 read differently: JSON null, other kinds, date-shaped text, an escape that is no valid UTF-16.
|
||||
private static readonly string[] DeliberateTexts =
|
||||
{
|
||||
"null", "42", "-1.5", "true", "{}", "[]", "[\"x\"]", Str("2026-01-01T00:00:00Z"), Str("/Date(1713096021000)/"), "\"x\\ud800y\"",
|
||||
};
|
||||
|
||||
// A whole-second UTC date, a date without time, or a value both read as no date.
|
||||
private static readonly string[] Dates =
|
||||
{
|
||||
Str("2026-05-08T12:16:49Z"), Str("2025-12-31T23:59:59Z"), Str("2026-05-08"), Str("0001-01-01T00:00:00Z"),
|
||||
Str(""), Str("not-a-date"), "null",
|
||||
};
|
||||
|
||||
// What D2 reads differently (fraction, offset, no offset) and dates that are no text.
|
||||
private static readonly string[] DeliberateDates =
|
||||
{
|
||||
Str("2026-05-08T12:16:49.1234567Z"), Str("2026-05-08T14:16:49+02:00"), Str("2026-05-08T12:16:49"), "20260508", "true",
|
||||
};
|
||||
|
||||
private T Pick<T>(params T[] items) => items[_rng.Next(items.Length)];
|
||||
|
||||
/// <summary>One of <paramref name="values"/>; with every value kind, one of those or of <paramref name="deliberate"/>.</summary>
|
||||
private string Value(string[] values, params string[] deliberate)
|
||||
{
|
||||
var i = _rng.Next(values.Length + (_everyValueKind ? deliberate.Length : 0));
|
||||
return i < values.Length ? values[i] : deliberate[i - values.Length];
|
||||
}
|
||||
|
||||
private bool Chance(int percent) => _rng.Next(100) < percent;
|
||||
|
||||
private static string Str(string s) => Serialized.GetOrAdd(s, value => System.Text.Json.JsonSerializer.Serialize(value));
|
||||
|
||||
private string Ws() => Pick("", "", "", " ", "\n", "\r\n ", "\t");
|
||||
|
||||
private string Text() => Value(PlainTexts, DeliberateTexts);
|
||||
|
||||
private string Url() => Pick(
|
||||
Str("https://graph.microsoft.com/beta/next?$skiptoken=abc"), Str("https://graph.microsoft.com/v1.0/x?$top=5"));
|
||||
|
||||
private string DateValue() => Value(Dates, DeliberateDates);
|
||||
|
||||
/// <summary>A property: (name, json) or (name, null) when the property is left out.</summary>
|
||||
private (string Name, string? Json) Prop(string name, string json, int missingPercent = 15)
|
||||
=> (name, Chance(missingPercent) ? null : json);
|
||||
|
||||
private string Obj(params (string Name, string? Json)[] props)
|
||||
{
|
||||
var parts = props.Where(p => p.Json != null).Select(p => $"{Str(p.Name)}:{Ws()}{p.Json}").ToList();
|
||||
if (Chance(10))
|
||||
parts.Insert(_rng.Next(parts.Count + 1), $"{Str("unknownProperty")}:{Pick("1", "\"x\"", "{}", "[]", "null", "true")}");
|
||||
if (Chance(5) && parts.Count > 0)
|
||||
{
|
||||
// Duplicate key with the same value: whichever occurrence wins, the meaning stays.
|
||||
parts.Insert(0, parts[_rng.Next(parts.Count)]);
|
||||
}
|
||||
if (Chance(30))
|
||||
parts = parts.OrderBy(_ => _rng.Next()).ToList();
|
||||
var separator = Pick(",", ",", ", ", ",\n ", ", /*c*/ ", ",// line\n");
|
||||
var trailing = parts.Count > 0 && Chance(5) ? "," : "";
|
||||
return "{" + Ws() + string.Join(separator, parts) + trailing + Ws() + "}";
|
||||
}
|
||||
|
||||
private string ValueList(Func<string> item)
|
||||
{
|
||||
switch (_rng.Next(100))
|
||||
{
|
||||
case < 5: return "null";
|
||||
case < 9: return Pick("\"oops\"", "{}", "42");
|
||||
case < 14: return "[]";
|
||||
}
|
||||
var entries = new List<string>();
|
||||
var count = _rng.Next(1, 6);
|
||||
for (var i = 0; i < count; i++)
|
||||
entries.Add(_rng.Next(100) switch { < 85 => item(), < 90 => "null", < 94 => Pick("1", "\"x\"", "true"), < 97 => "[]", _ => "{}" });
|
||||
return "[" + Ws() + string.Join("," + Ws(), entries) + Ws() + "]";
|
||||
}
|
||||
|
||||
/// <summary>The body around an object: mostly the object itself, else a root or shape both refuse.</summary>
|
||||
private string Root(string objectJson)
|
||||
{
|
||||
var pick = _rng.Next(100);
|
||||
LastWasCutOff = pick is >= 92 and < 95;
|
||||
return pick switch
|
||||
{
|
||||
< 82 => Ws() + objectJson + Ws(),
|
||||
< 84 => "",
|
||||
< 86 => " ",
|
||||
< 88 => "null",
|
||||
< 90 => "[" + objectJson + "]",
|
||||
< 92 => Pick("42", "\"text\"", "true"),
|
||||
< 95 => objectJson.Substring(0, Math.Max(1, objectJson.Length / 2)),
|
||||
< 98 => objectJson + Pick(" x", " {}", " 1"),
|
||||
_ => Pick("<html>busy</html>", "not json"),
|
||||
};
|
||||
}
|
||||
|
||||
private string Matching(string target) => Value(
|
||||
new[] { Str(target), Str(target.ToLowerInvariant()), Str(" " + target + " "), Str(target + "X"), Str("OTHER"), Str(""), "12345", "true" },
|
||||
"null", "{}", "[" + Str(target) + "]", "\"" + target + "\\ud800\"");
|
||||
|
||||
public string TokenBody() => Root(Obj(
|
||||
Prop("access_token", Value(new[] { Str("eyJ0eXAi.token"), Str("t"), Str(""), "null" }, "42", "{}", "\"t\\ud800\"")),
|
||||
Prop("expires_in", Value(
|
||||
new[] { "3599", "0", "-5", "7200", "9223372036854775807", "\"3599\"", "\" 3599 \"", "\"+3599\"", "\"-1\"", "null" },
|
||||
"\"abc\"", "\"\"", "3599.5", "7e2", "true", "99999999999999999999", "{}", "[]", "\"\\ud800\"")),
|
||||
Prop("token_type", Str("Bearer"))));
|
||||
|
||||
public string EntraBody() => Root(Obj(
|
||||
Prop("error", Pick(Str("invalid_client"), Str("unauthorized_client"))),
|
||||
Prop("error_description", Value(
|
||||
new[]
|
||||
{
|
||||
Str("AADSTS7000229: The client application is missing a service principal.\r\nTrace ID: x"),
|
||||
Str("AADSTS53003: Access has been blocked by Conditional Access policies."), Str("no code here"), Str("AADSTS: no digits"),
|
||||
Str(new string('d', 400)), Str(" "), "null", "42", "true", "{}", "[]",
|
||||
},
|
||||
Str("2026-11-01T08:00:00Z"), "\"AADSTS53003: broken \\ud800\"")),
|
||||
Prop("error_codes", Value(
|
||||
new[] { "[7000229]", "[53003,7000229]", "[\"7000229\",53003]", "[7000229.5]", "[7e6]", "[-1]", "[]", "7000229", "null", "[" + _rng.Next(1, 99_999_999) + "]" },
|
||||
"[99999999999999999999,53003]", "[1e3]")),
|
||||
Prop("timestamp", Str("2026-11-05 08:00:00Z"))));
|
||||
|
||||
public string AutopilotBody(string serial) => Root(Obj(
|
||||
Prop("@odata.context", Str("https://graph.microsoft.com/v1.0/$metadata#deviceManagement/windowsAutopilotDeviceIdentities")),
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("id", Pick(Text(), Str("ap-1"), "null", "42")),
|
||||
Prop("serialNumber", Matching(serial), 10),
|
||||
Prop("groupTag", Pick(Text(), "null", "true")),
|
||||
Prop("purchaseOrderIdentifier", Pick(Text(), "null", "{}")),
|
||||
Prop("displayName", Pick(Text(), "null", "[]")),
|
||||
Prop("userPrincipalName", Pick(Text(), "null", Str("u@contoso.com"))))), 5)));
|
||||
|
||||
public string CorporateBody()
|
||||
{
|
||||
var identifier = Value(
|
||||
new[]
|
||||
{
|
||||
Str("Microsoft Corporation,Virtual Machine,1234-5678"), Str("MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"),
|
||||
Str("microsoft corporation, virtual machine, 1234 5678"), Str("Other,Model,1"), Str(""), "null", "42",
|
||||
},
|
||||
"[" + Str("Microsoft Corporation,Virtual Machine,1234-5678") + "]", "true", "\"Microsoft Corporation,Virtual Machine,1234-5678\\ud800\"");
|
||||
return Root(Obj(
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("importedDeviceIdentityType", Value(new[] { Str("manufacturerModelSerial"), Str("MANUFACTURERMODELSERIAL"), Str("serialNumber"), "null" }, "42")),
|
||||
Prop("importedDeviceIdentifier", identifier))), 5),
|
||||
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "true", "{}", "\"https://graph.microsoft.com/beta/next\\ud800\""), 50)));
|
||||
}
|
||||
|
||||
public string AssociationBody(string serial) => Root(Obj(
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("serialNumber", Matching(serial), 10),
|
||||
Prop("associationState", Pick(Str("associated"), Str("preAssociated"), Text())),
|
||||
Prop("devicePreparationPolicyId", Text()),
|
||||
Prop("preassociatedByUserPrincipalName", Text()),
|
||||
Prop("assignedToUserPrincipalName", Text()),
|
||||
Prop("preassociationDateTime", DateValue()),
|
||||
Prop("associationDateTime", DateValue()),
|
||||
Prop("managedDeviceId", Text()))), 5)));
|
||||
|
||||
public string CloudPcBody(string intuneId) => Root(Obj(
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("id", Text()),
|
||||
Prop("managedDeviceId", Matching(intuneId), 10),
|
||||
Prop("managedDeviceName", Text()),
|
||||
Prop("servicePlanName", Text()))), 5)));
|
||||
|
||||
public string ManagedDeviceBody(string intuneId) => Root(Obj(
|
||||
Prop("id", Matching(intuneId), 10),
|
||||
Prop("deviceName", Text()),
|
||||
Prop("azureADDeviceId", Text()),
|
||||
Prop("managementState", Pick(Str("managed"), Str("retirePending"), Str("WIPEISSUED"), Str("deletePending"), Text())),
|
||||
Prop("serialNumber", Pick(Text(), "null")),
|
||||
Prop("managedDeviceOwnerType", Pick(Str("company"), Text(), "null")),
|
||||
Prop("deviceEnrollmentType", Pick(Str("windowsAzureADJoin"), Text(), "null")),
|
||||
Prop("azureADRegistered", Value(new[] { "true", "false", "null", "\"true\"", "1" }, "\"false\"", "0", "{}")),
|
||||
Prop("enrolledDateTime", DateValue())));
|
||||
|
||||
public string ScriptPageBody() => Root(Obj(
|
||||
Prop("value", ValueList(() => Obj(
|
||||
Prop("id", Value(new[] { Str("s1"), Str("4f9e2c1a-0000-0000-0000-000000000001"), Str(""), "null" }, "42", "{}", "\"s1\\ud800\""), 10),
|
||||
Prop("displayName", Text()),
|
||||
Prop("fileName", Text()))), 5),
|
||||
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "{}", "\"https://graph.microsoft.com/beta/p2\\ud800\""), 50)));
|
||||
|
||||
public string ScriptNameBody() => Root(Obj(
|
||||
Prop("id", Str("s1")),
|
||||
Prop("displayName", Text()),
|
||||
Prop("fileName", Text())));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What runs while a Global Admin views a tenant as its member (read-only "view as"). The class follows the
|
||||
/// method — GET reads, everything else writes and is refused — so a new route fails closed. Every exception is
|
||||
/// listed here with its reason: a POST that only reads, or a GET that starts an action. A new exception needs a
|
||||
/// deliberate line, and a GET that writes must either be listed or skip its write under view-as in the handler
|
||||
/// (RecomputeTriggerGate, the consent-status auto-flip).
|
||||
/// </summary>
|
||||
public class ViewAsAccessCatalogTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> Exceptions = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["PUT auth/me/whats-new-seen"] = "the caller's own What's-new marker under the JWT tenant; opening What's new sends it",
|
||||
["POST realtime/negotiate"] = "a live-update subscription, nothing stored",
|
||||
["POST realtime/groups/join"] = "a live-update subscription, nothing stored",
|
||||
["POST realtime/groups/leave"] = "a live-update subscription, nothing stored",
|
||||
["POST diagnostics/download-ticket"] = "a stateless HMAC ticket for a read",
|
||||
["POST rules/analyze/dryrun"] = "evaluates a draft rule against a session and persists nothing",
|
||||
["POST rules/gather/test-pattern"] = "a regex against pasted lines, no tenant data",
|
||||
["POST tenants/{tenantId}/scripts/display-names"] = "a read sent as POST for its body size",
|
||||
["POST tenants/{tenantId}/graph-permissions/refresh"] = "clears the in-memory Graph cache only",
|
||||
["GET config/{tenantId}/autopilot-device-validation/consent-url"] = "starts the admin-consent flow (ops events with the caller's UPN)",
|
||||
};
|
||||
|
||||
private static ViewAsAccess MethodDefault(string method) => method == "GET" ? ViewAsAccess.Read : ViewAsAccess.Write;
|
||||
|
||||
[Fact]
|
||||
public void Every_class_that_differs_from_the_method_default_is_listed_with_a_reason()
|
||||
{
|
||||
var actual = EndpointAccessPolicyCatalog.Entries
|
||||
.Where(e => e.ViewAsAccess != MethodDefault(e.HttpMethod))
|
||||
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
|
||||
.OrderBy(k => k, StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal(Exceptions.Keys.OrderBy(k => k, StringComparer.Ordinal), actual);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", ViewAsAccess.Read)]
|
||||
[InlineData("post", ViewAsAccess.Write)]
|
||||
[InlineData("PUT", ViewAsAccess.Write)]
|
||||
[InlineData("PATCH", ViewAsAccess.Write)]
|
||||
[InlineData("DELETE", ViewAsAccess.Write)]
|
||||
public void An_unclassified_route_follows_its_method(string method, ViewAsAccess expected)
|
||||
=> Assert.Equal(expected, new EndpointPolicyEntry(method, "some/route", EndpointPolicy.MemberRead).ViewAsAccess);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The pieces of "view as tenant" outside the policy decision: the header parse, the data scope every handler
|
||||
/// reads (<see cref="TenantHelper"/>), the recompute gate that guards two GET actions, and the places where the
|
||||
/// real caller — not the viewed tenant — must count (presence, the request row, every other telemetry item).
|
||||
/// </summary>
|
||||
public class ViewAsTests
|
||||
{
|
||||
private const string Home = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Viewed = "22222222-2222-2222-2222-222222222222";
|
||||
private const string GaUpn = "ga@vendor.example";
|
||||
|
||||
private static RequestContext ViewingContext(string role = Constants.TenantRoles.Admin) => new()
|
||||
{
|
||||
TenantId = Viewed,
|
||||
TargetTenantId = Viewed,
|
||||
UserPrincipalName = GaUpn,
|
||||
UserRole = role,
|
||||
IsTenantAdmin = role == Constants.TenantRoles.Admin,
|
||||
ViewAs = new ViewAsContext(Home, role),
|
||||
};
|
||||
|
||||
// ── header parse ─────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void No_tenant_header_is_an_ordinary_request(string? tenantHeader)
|
||||
=> Assert.Null(ViewAsTarget.FromHeaders(tenantHeader, "Viewer"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-guid", null)]
|
||||
[InlineData("00000000-0000-0000-0000-000000000000", null)]
|
||||
[InlineData(Viewed, "GlobalAdmin")]
|
||||
[InlineData(Viewed, "Member")]
|
||||
public void Unusable_headers_are_invalid(string tenantHeader, string? roleHeader)
|
||||
=> Assert.Same(ViewAsTarget.Invalid, ViewAsTarget.FromHeaders(tenantHeader, roleHeader));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, Constants.TenantRoles.Admin)]
|
||||
[InlineData("viewer", Constants.TenantRoles.Viewer)]
|
||||
[InlineData(" OPERATOR ", Constants.TenantRoles.Operator)]
|
||||
public void A_tenant_id_is_normalized_and_the_role_defaults_to_admin(string? roleHeader, string expectedRole)
|
||||
{
|
||||
var request = ViewAsTarget.FromHeaders(" 22222222-2222-2222-2222-22222222222A ", roleHeader);
|
||||
|
||||
Assert.Equal(new ViewAsTarget("22222222-2222-2222-2222-22222222222a", expectedRole), request);
|
||||
Assert.True(request!.IsValid);
|
||||
}
|
||||
|
||||
// ── data scope and identity ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TenantHelper_answers_the_viewed_tenant_under_view_as_and_the_token_tenant_otherwise()
|
||||
{
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", Home), new Claim("upn", GaUpn) }, "test"));
|
||||
|
||||
Assert.Equal(Viewed, TenantHelper.GetTenantId(Request(principal, ViewingContext())));
|
||||
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, new RequestContext { TenantId = Home })));
|
||||
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, context: null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_identity_tenant_stays_the_home_tenant()
|
||||
{
|
||||
Assert.Equal(Home, ViewingContext().IdentityTenantId());
|
||||
Assert.Equal(Viewed, new RequestContext { TenantId = Viewed }.IdentityTenantId());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void View_as_never_triggers_a_recompute_although_its_admin_owns_the_viewed_tenant()
|
||||
{
|
||||
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext(), Viewed));
|
||||
Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext() with { ViewAs = null }, Viewed));
|
||||
}
|
||||
|
||||
// ── counting at the real caller ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void View_as_records_no_presence_in_the_viewed_tenant()
|
||||
{
|
||||
Assert.False(UserPresenceMiddleware.Records(ViewingContext()));
|
||||
Assert.True(UserPresenceMiddleware.Records(ViewingContext() with { ViewAs = null }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_request_row_of_a_view_as_request_counts_at_the_home_tenant_as_global_admin()
|
||||
{
|
||||
var ctx = ViewingContext();
|
||||
var properties = new Dictionary<string, string>();
|
||||
|
||||
RequestTelemetryMiddleware.StampCaller(properties, ctx,
|
||||
RequestTelemetryMiddleware.ResolveTenantId(ctx.IdentityTenantId(), new Dictionary<object, object>(), "/api/sessions", null));
|
||||
|
||||
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
|
||||
Assert.Equal(GaUpn, properties[RequestRowDimensions.UserId]);
|
||||
Assert.Equal(Constants.GlobalRoles.GlobalAdmin, properties[RequestRowDimensions.UserRole]);
|
||||
Assert.Equal(Viewed, properties[RequestRowDimensions.ViewAsTenantId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_request_row_carries_no_view_as_dimension()
|
||||
{
|
||||
var ctx = ViewingContext() with { TenantId = Home, ViewAs = null };
|
||||
var properties = new Dictionary<string, string>();
|
||||
|
||||
RequestTelemetryMiddleware.StampCaller(properties, ctx, ctx.TenantId);
|
||||
|
||||
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, properties[RequestRowDimensions.UserRole]);
|
||||
Assert.False(properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Telemetry_items_are_stamped_only_inside_the_view_as_request_flow()
|
||||
{
|
||||
var sink = new CapturingProcessor();
|
||||
var processor = new ViewAsTelemetryProcessor(sink);
|
||||
|
||||
await InViewAsFlow(() => processor.Process(new EventTelemetry("inside")));
|
||||
processor.Process(new EventTelemetry("outside"));
|
||||
|
||||
var inside = (EventTelemetry)sink.Items[0];
|
||||
var outside = (EventTelemetry)sink.Items[1];
|
||||
Assert.Equal(Viewed, inside.Properties[RequestRowDimensions.ViewAsTenantId]);
|
||||
Assert.False(outside.Properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
|
||||
}
|
||||
|
||||
/// <summary>The scope opens inside an awaited method, like the middleware; it ends with that flow.</summary>
|
||||
private static async Task InViewAsFlow(Action emit)
|
||||
{
|
||||
await Task.Yield();
|
||||
ViewAsTelemetryProcessor.Enter(Viewed);
|
||||
emit();
|
||||
}
|
||||
|
||||
private sealed class CapturingProcessor : ITelemetryProcessor
|
||||
{
|
||||
public List<ITelemetry> Items { get; } = new();
|
||||
public void Process(ITelemetry item) => Items.Add(item);
|
||||
}
|
||||
|
||||
private static HttpRequestData Request(ClaimsPrincipal principal, RequestContext? context)
|
||||
{
|
||||
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
|
||||
if (context != null)
|
||||
items[RequestContext.ItemsKey] = context;
|
||||
var functionContext = new Mock<FunctionContext>();
|
||||
functionContext.SetupGet(c => c.Items).Returns(items);
|
||||
return new Mock<HttpRequestData>(functionContext.Object).Object;
|
||||
}
|
||||
}
|
||||
@@ -39,23 +39,26 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="Azure.Monitor.Query" Version="1.5.0" />
|
||||
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.0.1" />
|
||||
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.51.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.4" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.25.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.5" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.1.0" />
|
||||
<PackageReference Include="Azure.Data.Tables" Version="12.13.0" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
|
||||
<PackageReference Include="Azure.Storage.Queues" Version="12.28.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.16.1" />
|
||||
<!-- Transitive via Microsoft.Identity.Web's token cache, lifted past GHSA-846f-f764-76mx and
|
||||
GHSA-2fmv-rv2c-59x7. Remove once Microsoft.Identity.Web is 4.17.0 or later (it brings 10.0.13). -->
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+5
@@ -34,6 +34,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// exactly when more than <c>pageSize</c> were read in total: if no partition reached its limit,
|
||||
/// every partition is exhausted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That limit is a row count, not an Azure page size: Azure Tables refuses <c>$top</c> above
|
||||
/// 1000, so a reader passes <c>AzureTablesPaginator.ServerPageSize(limit)</c> as
|
||||
/// <c>maxPerPage</c> and keeps reading across server pages until it has its limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class PerPartitionFanOutMerge
|
||||
{
|
||||
|
||||
+9
@@ -42,6 +42,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
"GraphAccessRejectedSince", "GraphAccessRejectedCode", "GraphAccessRejectionConfirmedAt",
|
||||
"IntuneDeviceBindingPermissionMissingSince", "IntuneDeviceBindingPermissionMissingConfirmedAt",
|
||||
"CloudPcPermissionMissingSince", "CloudPcPermissionMissingConfirmedAt",
|
||||
"SetupGuideSettingsReviewedAt", "SetupGuideHardwareReviewedAt", "SetupGuideDismissedAt", "SetupGuideDismissedBy",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AdminBackupNoiseProperties = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -743,6 +744,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "DpaAcceptancePending", config.DpaAcceptancePending },
|
||||
{ "DpaAcceptedBy", config.DpaAcceptedBy },
|
||||
{ "DpaAcceptedAt", config.DpaAcceptedAt },
|
||||
{ "SetupGuideSettingsReviewedAt", config.SetupGuideSettingsReviewedAt },
|
||||
{ "SetupGuideHardwareReviewedAt", config.SetupGuideHardwareReviewedAt },
|
||||
{ "SetupGuideDismissedAt", config.SetupGuideDismissedAt },
|
||||
{ "SetupGuideDismissedBy", config.SetupGuideDismissedBy },
|
||||
{ "ContactEmail", config.ContactEmail },
|
||||
{ "CompanyName", config.CompanyName },
|
||||
{ "Disabled", config.Disabled },
|
||||
@@ -865,6 +870,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
DpaAcceptancePending = entity.GetBoolean("DpaAcceptancePending") ?? false,
|
||||
DpaAcceptedBy = entity.GetString("DpaAcceptedBy"),
|
||||
DpaAcceptedAt = entity.GetDateTime("DpaAcceptedAt"),
|
||||
SetupGuideSettingsReviewedAt = entity.GetDateTime("SetupGuideSettingsReviewedAt"),
|
||||
SetupGuideHardwareReviewedAt = entity.GetDateTime("SetupGuideHardwareReviewedAt"),
|
||||
SetupGuideDismissedAt = entity.GetDateTime("SetupGuideDismissedAt"),
|
||||
SetupGuideDismissedBy = entity.GetString("SetupGuideDismissedBy"),
|
||||
ContactEmail = entity.GetString("ContactEmail"),
|
||||
CompanyName = entity.GetString("CompanyName"),
|
||||
Disabled = entity.GetBoolean("Disabled") ?? false,
|
||||
|
||||
+5
-2
@@ -3,9 +3,11 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
@@ -63,12 +65,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, int maxResults = 100)
|
||||
public async Task<List<DistressReportEntry>> GetDistressReportsAsync(string tenantId, DistressErrorType errorType, int maxResults = 100)
|
||||
{
|
||||
var pk = tenantId.ToLowerInvariant();
|
||||
var result = new List<DistressReportEntry>();
|
||||
var filter = TableClient.CreateQueryFilter($"PartitionKey eq {pk} and ErrorType eq {errorType.ToString()}");
|
||||
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(e => e.PartitionKey == pk, maxPerPage: maxResults))
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(maxResults)))
|
||||
{
|
||||
result.Add(MapToEntry(entity));
|
||||
if (result.Count >= maxResults)
|
||||
|
||||
+6
@@ -66,6 +66,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<TenantStats?> GetTenantStatsAsync(string tenantId)
|
||||
=> _storage.GetTenantStatsAsync(tenantId);
|
||||
|
||||
public Task<bool> TenantHasEnrollmentsAsync(string tenantId)
|
||||
=> _storage.TenantHasEnrollmentsAsync(tenantId);
|
||||
|
||||
public Task<List<TenantStats>> GetAllTenantStatsAsync()
|
||||
=> _storage.GetAllTenantStatsAsync();
|
||||
|
||||
@@ -93,6 +96,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
|
||||
=> _storage.GetSignInIdentitiesByUpnAsync(upn);
|
||||
|
||||
public Task<List<PortalVisit>> GetPortalVisitsSinceAsync(DateTime sinceUtc)
|
||||
=> _storage.GetPortalVisitsSinceAsync(sinceUtc);
|
||||
|
||||
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
|
||||
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
|
||||
|
||||
|
||||
+20
-28
@@ -86,36 +86,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
OpsEventQueryFilters? filters = null)
|
||||
{
|
||||
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
try
|
||||
// Single-category path: PK-targeted query, RowKey is reverse-tick →
|
||||
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
// Single-category path: PK-targeted query, RowKey is reverse-tick →
|
||||
// Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
|
||||
if (!string.IsNullOrEmpty(category))
|
||||
{
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _table,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
var filter = BuildFilter(category, dateFrom, dateTo, filters);
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: _table,
|
||||
filter: filter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
|
||||
var page = new List<OpsEventEntry>(entities.Count);
|
||||
foreach (var entity in entities) page.Add(MapToEntry(entity));
|
||||
return new RawPage<OpsEventEntry>(page, nextRawToken);
|
||||
}
|
||||
var page = new List<OpsEventEntry>(entities.Count);
|
||||
foreach (var entity in entities) page.Add(MapToEntry(entity));
|
||||
return new RawPage<OpsEventEntry>(page, nextRawToken);
|
||||
}
|
||||
|
||||
// All-category path: per-partition fan-out + merge-sort. Azure pages
|
||||
// cross-partition queries by (PK asc, RK asc), so without this fan-out
|
||||
// the first page would come entirely from the alphabetically-first
|
||||
// category — defeating "newest first globally". Mirrors the same
|
||||
// pattern used for cross-tenant session queries.
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get ops events page");
|
||||
return RawPage<OpsEventEntry>.Empty;
|
||||
}
|
||||
// All-category path: per-partition fan-out + merge-sort. Azure pages
|
||||
// cross-partition queries by (PK asc, RK asc), so without this fan-out
|
||||
// the first page would come entirely from the alphabetically-first
|
||||
// category — defeating "newest first globally". Mirrors the same
|
||||
// pattern used for cross-tenant session queries.
|
||||
return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation, filters);
|
||||
}
|
||||
|
||||
// Fixed list, owned by OpsEventCategory.All so it cannot drift from the vocabulary. It did
|
||||
@@ -135,7 +127,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
var filter = BuildFilterWithRowKeyBound(category, dateFrom, dateTo, bound, filters);
|
||||
var read = new List<(string RowKey, OpsEventEntry Item)>();
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: limit, cancellationToken: ct))
|
||||
await foreach (var e in _table.QueryAsync<TableEntity>(filter: filter, maxPerPage: AzureTablesPaginator.ServerPageSize(limit), cancellationToken: ct))
|
||||
{
|
||||
read.Add((e.RowKey, MapToEntry(e)));
|
||||
if (read.Count >= limit) break;
|
||||
|
||||
+35
-84
@@ -88,44 +88,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
|
||||
|
||||
var records = new List<UserUsageRecord>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
records.Add(MapToRecord(entity));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
|
||||
|
||||
var records = new List<UserUsageRecord>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
records.Add(MapToRecord(entity));
|
||||
}
|
||||
|
||||
var grouped = records
|
||||
.GroupBy(r => r.Date)
|
||||
.Select(g => new UserUsageDailySummary
|
||||
{
|
||||
Date = g.Key,
|
||||
TenantId = tenantId,
|
||||
TotalRequests = g.Sum(r => r.RequestCount),
|
||||
UniqueUsers = g.Select(r => r.UserId).Distinct().Count(),
|
||||
UniqueEndpoints = g.Select(r => r.Endpoint).Distinct().Count(),
|
||||
})
|
||||
.OrderByDescending(s => s.Date)
|
||||
.ToList();
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OData filter for a per-user usage query. <paramref name="userId"/> is a route
|
||||
/// segment (AAD oid) and the dates are query params, so all values MUST be OData-escaped before
|
||||
@@ -135,42 +97,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
internal static string BuildUserUsageFilter(string userId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(userId)}'";
|
||||
return AppendDateFilter(filter, dateFrom, dateTo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OData filter for a per-tenant usage query. Same escaping requirement as
|
||||
/// <see cref="BuildUserUsageFilter"/>; returns null when no tenant/date scope is supplied.
|
||||
/// </summary>
|
||||
internal static string? BuildTenantUsageFilter(string? tenantId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
string? filter = !string.IsNullOrEmpty(tenantId)
|
||||
? $"TenantId eq '{ODataSanitizer.EscapeValue(tenantId)}'"
|
||||
: null;
|
||||
return AppendDateFilter(filter, dateFrom, dateTo);
|
||||
}
|
||||
|
||||
private static string AppendDateFilter(string? existingFilter, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = existingFilter ?? "";
|
||||
|
||||
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
|
||||
// so the value must still be OData-escaped before interpolation.
|
||||
if (!string.IsNullOrEmpty(dateFrom))
|
||||
{
|
||||
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
|
||||
// so the value must still be OData-escaped before interpolation.
|
||||
var dateVal = ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""));
|
||||
var clause = $"Date ge '{dateVal}'";
|
||||
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
|
||||
}
|
||||
|
||||
filter += $" and Date ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
|
||||
if (!string.IsNullOrEmpty(dateTo))
|
||||
{
|
||||
var dateVal = ODataSanitizer.EscapeValue(dateTo.Replace("-", ""));
|
||||
var clause = $"Date le '{dateVal}'";
|
||||
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
|
||||
}
|
||||
filter += $" and Date le '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}'";
|
||||
|
||||
return string.IsNullOrEmpty(filter) ? null! : filter;
|
||||
return filter;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteRecordsOlderThanAsync(string dateCutoff)
|
||||
@@ -251,20 +186,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return records;
|
||||
|
||||
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(filter: BuildTenantCounterFilter(tenantId, dateFrom, dateTo)))
|
||||
{
|
||||
records.Add(new TenantUsageRecord
|
||||
{
|
||||
TenantId = entity.PartitionKey,
|
||||
UserId = entity.GetString("UserId") ?? string.Empty,
|
||||
UserPrincipalName = entity.GetString("UserPrincipalName") ?? string.Empty,
|
||||
Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0],
|
||||
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
|
||||
LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime,
|
||||
});
|
||||
}
|
||||
records.Add(MapTenantRecord(entity));
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<TenantUsageRecord>> GetAllTenantUsageAsync(string dateFrom, string dateTo)
|
||||
{
|
||||
var records = new List<TenantUsageRecord>();
|
||||
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(filter: BuildDayRangeFilter(dateFrom, dateTo)))
|
||||
records.Add(MapTenantRecord(entity));
|
||||
return records;
|
||||
}
|
||||
|
||||
private static TenantUsageRecord MapTenantRecord(TableEntity entity) => new()
|
||||
{
|
||||
TenantId = entity.PartitionKey,
|
||||
UserId = entity.GetString("UserId") ?? string.Empty,
|
||||
UserPrincipalName = entity.GetString("UserPrincipalName") ?? string.Empty,
|
||||
Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0],
|
||||
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
|
||||
LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Partition + RowKey range filter for McpTenantUsage: the RowKey starts with the yyyyMMdd day, so a
|
||||
/// date window is a key range ("{to}~" sorts after every "{to}_{oid}"); no property scan.
|
||||
@@ -272,11 +215,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
internal static string BuildTenantCounterFilter(string tenantId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
|
||||
var days = BuildDayRangeFilter(dateFrom, dateTo);
|
||||
return days.Length == 0 ? filter : $"{filter} and {days}";
|
||||
}
|
||||
|
||||
/// <summary>The RowKey day range of McpTenantUsage without a partition — empty when neither bound is set.</summary>
|
||||
internal static string BuildDayRangeFilter(string? dateFrom, string? dateTo)
|
||||
{
|
||||
var clauses = new List<string>(2);
|
||||
if (!string.IsNullOrEmpty(dateFrom))
|
||||
filter += $" and RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
|
||||
clauses.Add($"RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'");
|
||||
if (!string.IsNullOrEmpty(dateTo))
|
||||
filter += $" and RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'";
|
||||
return filter;
|
||||
clauses.Add($"RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'");
|
||||
return string.Join(" and ", clauses);
|
||||
}
|
||||
|
||||
public async Task<int> DeleteTenantRecordsOlderThanAsync(string dateCutoff)
|
||||
|
||||
+15
-5
@@ -162,8 +162,7 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
// here (flag-gated, probe-verified, idempotent) makes the status below resolve the newly
|
||||
// homed app naturally. Unbudgeted on purpose: this runs in the post-redirect callback,
|
||||
// where the consent-propagation retry chain is exactly what we want to ride.
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
var flip = await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName);
|
||||
var flip = await TryAutoFlipAsync(requestCtx);
|
||||
|
||||
var result = await _graphTokenService.GetConsentStatusAsync(requestCtx.TargetTenantId);
|
||||
|
||||
@@ -237,9 +236,7 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
// Consent-driven homing auto-flip BEFORE the fresh snapshot: the "detect existing
|
||||
// access" / reconcile path never touches consent-status, so this is its flip hook. A
|
||||
// budget-exhausted probe is transient ⇒ no flip; the frontend already renders "retry".
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
flip = await _appHomingService.TryAutoFlipToPrimaryAsync(
|
||||
tenantConfig, requestCtx.UserPrincipalName, budgetCts.Token);
|
||||
flip = await TryAutoFlipAsync(requestCtx, budgetCts.Token);
|
||||
|
||||
GraphPermissionSnapshot snapshot;
|
||||
try
|
||||
@@ -276,6 +273,19 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The consent-driven homing auto-flip. It writes the tenant's homing with the caller as actor plus an
|
||||
/// audit entry, so it never runs while a Global Admin only views the tenant (read-only "view as").
|
||||
/// </summary>
|
||||
private async Task<AppHomingAutoFlipResult> TryAutoFlipAsync(RequestContext requestCtx, CancellationToken ct = default)
|
||||
{
|
||||
if (requestCtx.ViewAs != null)
|
||||
return new AppHomingAutoFlipResult(AppHomingAutoFlipOutcome.NotEligible);
|
||||
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
return await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision for the access-check probe: does the SP snapshot prove the core validation
|
||||
/// role is effectively granted? Extracted so the reconcile decision is unit testable without
|
||||
|
||||
+43
-3
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -16,15 +18,25 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private readonly ILogger<GetTenantFeatureFlagsFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AppHomingService _appHomingService;
|
||||
private readonly IMetricsRepository _metricsRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Tenants whose enrollment counter was seen above zero. The counter only grows, so a seen
|
||||
/// "true" costs no further read on this instance. An offboarded and re-onboarded tenant starts
|
||||
/// at zero again; until this instance recycles, its setup guide then stays hidden here.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, bool> KnownEnrolledTenants = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public GetTenantFeatureFlagsFunction(
|
||||
ILogger<GetTenantFeatureFlagsFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AppHomingService appHomingService)
|
||||
AppHomingService appHomingService,
|
||||
IMetricsRepository metricsRepository)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_appHomingService = appHomingService;
|
||||
_metricsRepository = metricsRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -50,9 +62,13 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
|
||||
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
var appHomingFunnelActive = await _appHomingService.IsFunnelEligibleAsync(config);
|
||||
// Only the setup guide needs the enrollment history; a hidden guide skips the read.
|
||||
var hasEnrollmentSessions = config.SetupGuideDismissedAt == null
|
||||
? await HasEnrollmentSessionsAsync(requestCtx.TargetTenantId)
|
||||
: null;
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive));
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow, appHomingFunnelActive, hasEnrollmentSessions));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -61,13 +77,31 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Null when the counter could not be read: the guide then stays hidden rather than guessing.</summary>
|
||||
private async Task<bool?> HasEnrollmentSessionsAsync(string tenantId)
|
||||
{
|
||||
if (KnownEnrolledTenants.ContainsKey(tenantId)) return true;
|
||||
try
|
||||
{
|
||||
var has = await _metricsRepository.TenantHasEnrollmentsAsync(tenantId);
|
||||
if (has) KnownEnrolledTenants.TryAdd(tenantId, true);
|
||||
return has;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Feature flags: enrollment history of tenant {TenantId} unknown", tenantId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects the member-readable subset of <see cref="TenantConfiguration"/>.
|
||||
/// Pulled out as a static method so unit tests can verify field-level mapping
|
||||
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
|
||||
/// read-time edition resolution (trial expiry degrades automatically).
|
||||
/// </summary>
|
||||
internal static TenantFeatureFlagsResponse BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
|
||||
internal static TenantFeatureFlagsResponse BuildPayload(
|
||||
TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false, bool? hasEnrollmentSessions = null)
|
||||
{
|
||||
// Edition + source in one resolution: Pro from the plan, from a trial, or conferred by the
|
||||
// managing tenant ("msp" — shown as "Pro (MSP)", entitlements without the delegation right).
|
||||
@@ -126,6 +160,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
ContactEmailSet = !string.IsNullOrWhiteSpace(config.ContactEmail),
|
||||
// Same contract for the second half of the contact profile: boolean only.
|
||||
CompanyNameSet = !string.IsNullOrWhiteSpace(config.CompanyName),
|
||||
// Setup guide checklist (dashboard card, sidebar entry): done states and the
|
||||
// dismissal. Booleans only, derived from the row and the enrollment counter.
|
||||
SetupGuideSettingsDone = SetupGuideState.SettingsDone(config),
|
||||
SetupGuideHardwareDone = SetupGuideState.HardwareDone(config),
|
||||
SetupGuideDismissed = config.SetupGuideDismissedAt != null,
|
||||
HasEnrollmentSessions = hasEnrollmentSessions,
|
||||
Entitlements = new TenantFeatureEntitlements
|
||||
{
|
||||
RetentionCapDays = entitlements.RetentionCapDays,
|
||||
|
||||
@@ -223,7 +223,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return true;
|
||||
}, "plan", "plan tier change");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
@@ -406,28 +406,6 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private static IEnumerable<string> KnownUsagePlanNames(IEnumerable<PlanTierDefinition> definitions)
|
||||
=> definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The response for an <see cref="TenantConfigurationService.UpdateAsync"/> outcome that wrote
|
||||
/// nothing for a reason other than the caller's own decision; null for Updated and Declined.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData?> UpdateFailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
||||
{
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
||||
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
||||
case TenantConfigUpdateStatus.Conflict:
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
||||
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure verdict for the self-service trial start; null = allowed. Order matters and is
|
||||
/// test-pinned: the terminal conditions (trial consumed, already Pro) win over the
|
||||
@@ -511,7 +489,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return true;
|
||||
}, "plan", "self-service trial start");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
if (deny is { } refused)
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, refused.Code, refused.Message);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// The setup guide's markers on the tenant row. The guide applies settings through the field
|
||||
/// PATCH like any settings card; this endpoint only stamps what the PATCH cannot: that an admin
|
||||
/// finished a step, or hid the guide for the whole tenant. Server time and the caller's UPN.
|
||||
/// </summary>
|
||||
public class SetupGuideFunction
|
||||
{
|
||||
internal const string StepSettings = "settings";
|
||||
internal const string StepHardware = "hardware";
|
||||
internal const string StepDismiss = "dismiss";
|
||||
internal const string StepRestore = "restore";
|
||||
|
||||
private readonly ILogger<SetupGuideFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public SetupGuideFunction(
|
||||
ILogger<SetupGuideFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/setup-guide — TenantAdminOrGA (catalog-enforced), refused in
|
||||
/// view-as. Body <c>{ "step": "settings" | "hardware" | "dismiss" | "restore" }</c>. A step that
|
||||
/// is already recorded writes nothing and answers with the current markers.
|
||||
/// </summary>
|
||||
[Function("RecordSetupGuideStep")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/setup-guide")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var read = await req.ReadAsync<SetupGuideStepRequest>();
|
||||
if (read.Error != null) return read.Error;
|
||||
|
||||
var step = NormalizeStep(read.Value!.Step);
|
||||
if (step == null)
|
||||
return await req.BadRequestAsync("step must be one of: settings, hardware, dismiss, restore.");
|
||||
|
||||
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var update = await _configService.UpdateAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
row => ApplyStep(row, step, caller, nowUtc),
|
||||
"setup-guide",
|
||||
$"setup guide: {step}");
|
||||
|
||||
if (await TenantConfigUpdateResponses.FailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
"SetupGuide",
|
||||
requestCtx.TargetTenantId,
|
||||
caller,
|
||||
new Dictionary<string, string> { { "Step", step } });
|
||||
}
|
||||
|
||||
return await req.OkAsync(new SetupGuideStateResponse
|
||||
{
|
||||
Success = true,
|
||||
SettingsReviewedAt = config.SetupGuideSettingsReviewedAt,
|
||||
HardwareReviewedAt = config.SetupGuideHardwareReviewedAt,
|
||||
DismissedAt = config.SetupGuideDismissedAt,
|
||||
DismissedBy = config.SetupGuideDismissedBy,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "RecordSetupGuideStep");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The known step in lower case, or null.</summary>
|
||||
internal static string? NormalizeStep(string? step)
|
||||
{
|
||||
var value = step?.Trim().ToLowerInvariant();
|
||||
return value is StepSettings or StepHardware or StepDismiss or StepRestore ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps <paramref name="step"/> on the fresh row inside the conditional write. False (nothing to
|
||||
/// write) when the step is already recorded — the first completion stays the one on record.
|
||||
/// </summary>
|
||||
internal static bool ApplyStep(TenantConfiguration row, string step, string caller, DateTime nowUtc)
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case StepSettings:
|
||||
if (row.SetupGuideSettingsReviewedAt != null) return false;
|
||||
row.SetupGuideSettingsReviewedAt = nowUtc;
|
||||
break;
|
||||
case StepHardware:
|
||||
if (row.SetupGuideHardwareReviewedAt != null) return false;
|
||||
row.SetupGuideHardwareReviewedAt = nowUtc;
|
||||
break;
|
||||
case StepDismiss:
|
||||
if (row.SetupGuideDismissedAt != null) return false;
|
||||
row.SetupGuideDismissedAt = nowUtc;
|
||||
row.SetupGuideDismissedBy = caller;
|
||||
break;
|
||||
case StepRestore:
|
||||
if (row.SetupGuideDismissedAt == null) return false;
|
||||
row.SetupGuideDismissedAt = null;
|
||||
row.SetupGuideDismissedBy = null;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,11 @@ public class AuthFunction
|
||||
return await req.BadRequestAsync("Missing required claims");
|
||||
}
|
||||
|
||||
// A Global Admin viewing a tenant as its member: that tenant's standing, none of the sign-in side effects.
|
||||
var requestContext = req.GetRequestContext();
|
||||
if (requestContext.ViewAs != null)
|
||||
return await GetViewAsUserAsync(req, principal, requestContext, upn, displayName, objectId);
|
||||
|
||||
// --- Parallel data fetch: all independent queries run concurrently ---
|
||||
// Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500.
|
||||
// This is intentional — the auth-decision queries are required; the What's-new marker read
|
||||
@@ -231,6 +236,53 @@ public class AuthFunction
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// auth/me while a Global Admin views a tenant as its member: the viewed tenant's standing (suspension,
|
||||
/// activation, Terms + DPA, plan) for the emulated member, through the same <see cref="BuildAuthResult"/>.
|
||||
/// None of the sign-in side effects run — onboarding, re-enable, client-id tracking, auto-admin, the login
|
||||
/// record and the push sign-in belong to the tenant's own members. Per-user state stays the caller's own:
|
||||
/// What's-new marks, MCP access and the homed app registration come from the home tenant.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseData> GetViewAsUserAsync(
|
||||
HttpRequestData req, System.Security.Claims.ClaimsPrincipal principal, RequestContext ctx,
|
||||
string upn, string? displayName, string? objectId)
|
||||
{
|
||||
var viewedTenantId = ctx.TenantId;
|
||||
var homeTenantId = ctx.ViewAs!.HomeTenantId;
|
||||
|
||||
var viewedConfigTask = _tenantConfigService.TryGetConfigurationAsync(viewedTenantId);
|
||||
var homeConfigTask = _tenantConfigService.TryGetConfigurationAsync(homeTenantId);
|
||||
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(viewedTenantId);
|
||||
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, homeTenantId, objectId, principal.GetAppRoles());
|
||||
var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(homeTenantId, upn);
|
||||
await Task.WhenAll(viewedConfigTask, homeConfigTask, isApprovedTask, mcpCheckTask, whatsNewSeenTask);
|
||||
|
||||
var (viewedConfig, _) = viewedConfigTask.Result;
|
||||
var (homeConfig, _) = homeConfigTask.Result;
|
||||
var whatsNewSeen = whatsNewSeenTask.Result;
|
||||
|
||||
var decision = BuildAuthResult(
|
||||
viewedConfig, isGlobalAdmin: false, isGlobalReader: false, isApprovedTask.Result,
|
||||
new MemberRoleInfo { Role = ctx.ViewAs.Role }, mcpCheckTask.Result, hasTenantAdmins: true,
|
||||
viewedTenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
|
||||
delegatedTenantIds: null,
|
||||
homedApp: _appRegistry.ResolveForTenant(homeConfig).IsLegacy ? "legacy" : "primary",
|
||||
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
|
||||
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc,
|
||||
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false,
|
||||
viewAs: new AuthMeViewAs
|
||||
{
|
||||
TenantId = viewedTenantId,
|
||||
TenantName = TenantDisplayName.Of(viewedConfig, viewedTenantId),
|
||||
Role = ctx.ViewAs.Role,
|
||||
HomeTenantId = homeTenantId,
|
||||
});
|
||||
|
||||
var response = req.CreateResponse(decision.StatusCode);
|
||||
await response.WriteAsJsonAsync(decision.Body);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PUT /api/auth/me/whats-new-seen
|
||||
/// Marks the caller's What's-new channel as seen.
|
||||
@@ -733,7 +785,8 @@ public class AuthFunction
|
||||
DateTime? whatsNewSeenPlatformUtc = null,
|
||||
DateTime? whatsNewSeenAgentUtc = null,
|
||||
bool mcpClientRegistrationEnabled = false,
|
||||
bool isExternalIdentity = false)
|
||||
bool isExternalIdentity = false,
|
||||
AuthMeViewAs? viewAs = null)
|
||||
{
|
||||
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
|
||||
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
|
||||
@@ -836,7 +889,14 @@ public class AuthFunction
|
||||
McpClientRegistrationEnabled = mcpClientRegistrationEnabled,
|
||||
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
|
||||
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc,
|
||||
DpaAcceptancePending = tenantConfig.DpaAcceptancePending
|
||||
DpaAcceptancePending = tenantConfig.DpaAcceptancePending,
|
||||
// Navbar label, for the callers the config GET serves too (a tenant role, or a Global
|
||||
// Admin); a roleless member only gets the Progress Portal. In view-as tenantConfig is
|
||||
// the viewed tenant's row and the role the emulated one.
|
||||
CompanyName = (role != null || isGlobalAdmin) && !string.IsNullOrWhiteSpace(tenantConfig.CompanyName)
|
||||
? tenantConfig.CompanyName.Trim()
|
||||
: null,
|
||||
ViewAs = viewAs
|
||||
}, needsAutoAdmin);
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -56,10 +56,11 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
// revocation enforceable, and only inside the tenant whose grant was revoked
|
||||
// (LiveStreamRevoker). Both values come from the middleware-validated JWT, never from
|
||||
// client-controlled data. Without a principal key the connection has no user id: every
|
||||
// revocable join gate needs one.
|
||||
// revocable join gate needs one. Under view-as the binding stays the sign-in tenant
|
||||
// (IdentityTenantId): removing a Global Admin must cut their view-as tab as well.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var principalKey = string.IsNullOrEmpty(requestCtx.UserPrincipalName) ? null : requestCtx.UserPrincipalName;
|
||||
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.TenantId, principalKey);
|
||||
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.IdentityTenantId(), principalKey);
|
||||
if (negotiation == null)
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable, "SignalR negotiation unavailable");
|
||||
|
||||
@@ -148,8 +148,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
// Gate 5: Tenant existence check (cached — cheap O(1) lookup)
|
||||
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
// Gate 5: Tenant existence check (cached — cheap O(1) lookup). The config also decides
|
||||
// the effective error type below.
|
||||
var (tenantConfig, exists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (!exists)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -223,11 +224,14 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
var espPhase = SanitizeToken(report.EspPhase, MaxEspPhaseLength, EspPhasePattern);
|
||||
var deviceBootUtc = SanitizeDeviceBoot(report.DeviceBootUtc, report.Timestamp);
|
||||
|
||||
var errorType = ResolveErrorType(
|
||||
report.ErrorType, report.HttpStatusCode, manufacturer, model, report.CertSourceState, tenantConfig);
|
||||
|
||||
// Persist to Table Storage
|
||||
var entry = new DistressReportEntry
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ErrorType = report.ErrorType.ToString(),
|
||||
ErrorType = errorType.ToString(),
|
||||
Manufacturer = manufacturer,
|
||||
Model = model,
|
||||
SerialNumber = serialNumber,
|
||||
@@ -256,28 +260,24 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// Notify tenant channels for hardware whitelist rejections (opt-in). Durable via
|
||||
// the notification-dispatch queue — a fire-and-forget send after the response was
|
||||
// not counted as open work by the host and could be dropped on scale-in.
|
||||
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
|
||||
if (errorType == DistressErrorType.HardwareNotAllowed
|
||||
&& _hardwareThrottle.ShouldNotify(tenantId, manufacturer, model))
|
||||
{
|
||||
try
|
||||
{
|
||||
var (config, configExists) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (configExists)
|
||||
var hwChannels = tenantConfig.GetNotificationChannels()
|
||||
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
|
||||
.ToList();
|
||||
if (hwChannels.Count > 0)
|
||||
{
|
||||
var hwChannels = config!.GetNotificationChannels()
|
||||
.Where(c => c.Enabled && c.NotifyOnHardwareRejection)
|
||||
.ToList();
|
||||
if (hwChannels.Count > 0)
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
|
||||
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
|
||||
{
|
||||
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
|
||||
await _notificationProducer.EnqueueAsync(new NotificationDispatchEnvelope
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
|
||||
Alert = alert,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
TenantId = tenantId,
|
||||
ChannelIds = hwChannels.Select(c => c.Id).ToList(),
|
||||
Alert = alert,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
@@ -289,7 +289,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// In-app bell notification on first-ever hardware rejection per (tenant, manufacturer, model).
|
||||
// Lifetime dedup via tracker table — independent of the webhook 24h in-memory throttle so
|
||||
// tenant admins see a one-shot persistent notification even if the webhook is not configured.
|
||||
if (report.ErrorType == DistressErrorType.HardwareNotAllowed
|
||||
if (errorType == DistressErrorType.HardwareNotAllowed
|
||||
&& !string.IsNullOrEmpty(manufacturer) && !string.IsNullOrEmpty(model))
|
||||
{
|
||||
try
|
||||
@@ -318,7 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// No webhook — volume is tiny and the remediation is not time-critical. Reports
|
||||
// without a serial number cannot be deduped and fire no bell; they still appear
|
||||
// in the panel.
|
||||
if (report.ErrorType == DistressErrorType.TpmPssUnsupported
|
||||
if (errorType == DistressErrorType.TpmPssUnsupported
|
||||
&& !string.IsNullOrEmpty(serialNumber))
|
||||
{
|
||||
try
|
||||
@@ -343,8 +343,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
|
||||
// Structured log (Warning, not Critical — data is unverified)
|
||||
_logger.LogWarning(
|
||||
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
|
||||
"AgentDistress [{ErrorType}] reported={ReportedErrorType} tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} os={OsBuild} esp={EspPhase} oobe={OobeState} boot={DeviceBootUtc} tpm={TpmVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
errorType, report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
|
||||
report.HttpStatusCode, agentVersion, osBuild ?? "n/a", espPhase ?? "n/a", oobeState ?? "n/a",
|
||||
deviceBootUtc?.ToString("O") ?? "n/a", tpmVersion ?? "n/a",
|
||||
certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
|
||||
@@ -354,7 +354,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_telemetryClient.TrackEvent("AgentDistressReport", new Dictionary<string, string>
|
||||
{
|
||||
["TenantId"] = tenantId,
|
||||
["ErrorType"] = report.ErrorType.ToString(),
|
||||
["ErrorType"] = errorType.ToString(),
|
||||
["ReportedErrorType"] = report.ErrorType.ToString(),
|
||||
["Manufacturer"] = manufacturer ?? string.Empty,
|
||||
["Model"] = model ?? string.Empty,
|
||||
["SerialNumber"] = serialNumber ?? string.Empty,
|
||||
@@ -387,6 +388,31 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error type to store and act on. The agent reports every backend 403 as
|
||||
/// <see cref="DistressErrorType.DeviceNotRegistered"/>, but <c>SecurityValidator</c> checks the
|
||||
/// hardware whitelist before it looks up the device's registration: a 403 for hardware
|
||||
/// outside the whitelist is a hardware rejection, and only as one does it reach the bell,
|
||||
/// the webhook and the rejected-hardware list. The conditions follow the validator's order:
|
||||
/// a suspended tenant, a tenant without any validation and bootstrap-token auth (no
|
||||
/// certificate) are refused or let through before the hardware check, so their 403 stays as
|
||||
/// reported. If that order changes, this has to change with it.
|
||||
/// </summary>
|
||||
internal static DistressErrorType ResolveErrorType(
|
||||
DistressErrorType reported, int? httpStatusCode, string? manufacturer, string? model,
|
||||
DistressCertSourceState? certSourceState, TenantConfiguration config)
|
||||
{
|
||||
if (reported != DistressErrorType.DeviceNotRegistered || httpStatusCode != 403) return reported;
|
||||
if (string.IsNullOrEmpty(manufacturer) || string.IsNullOrEmpty(model)) return reported;
|
||||
if (config.IsCurrentlyDisabled()) return reported;
|
||||
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests) return reported;
|
||||
if (certSourceState == DistressCertSourceState.NoCertInStore) return reported;
|
||||
|
||||
var hardware = HardwareWhitelistValidator.ValidateHardware(
|
||||
manufacturer, model, config.GetManufacturerWhitelist(), config.GetModelWhitelist());
|
||||
return hardware.IsValid ? reported : DistressErrorType.HardwareNotAllowed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a distress report timestamp is within acceptable range.
|
||||
/// Rejects timestamps older than 24 hours or more than 5 minutes in the future.
|
||||
|
||||
+25
-78
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -7,6 +6,7 @@ using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static AutopilotMonitor.Functions.Functions.Metrics.TenantLifecycleFacts;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
@@ -50,13 +50,15 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var includeTenants = string.Equals(query["includeTenants"], "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var yesterday = nowUtc.Date.AddDays(-1);
|
||||
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
|
||||
var stats = await _metricsRepo.GetAllTenantStatsAsync();
|
||||
var activity = await _metricsRepo.GetTenantActivityDaysAsync(
|
||||
GrowthMetricsBuilder.UsageReadStart(nowUtc.Date, days), nowUtc.Date.AddDays(-1));
|
||||
GrowthMetricsBuilder.UsageReadStart(nowUtc.Date, days), yesterday);
|
||||
var yesterdayAggregated = await _metricsRepo.HasUsageMetricsSnapshotAsync(TenantLifecycleFacts.Key(yesterday));
|
||||
|
||||
return await req.OkAsync(GrowthMetricsBuilder.Build(
|
||||
configs, stats, activity, nowUtc, days, timeZone, includeTenants, excludedTenantIds));
|
||||
configs, stats, activity, nowUtc, days, timeZone, includeTenants, excludedTenantIds, yesterdayAggregated));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -77,8 +79,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
internal const int DailyBucketMaxDays = 90;
|
||||
internal const int PeakBaselineDays = 14;
|
||||
internal const int PeakMinSignups = 3;
|
||||
/// <summary>The activity segments look this far back, whatever the window.</summary>
|
||||
internal const int ActivityDays = 30;
|
||||
|
||||
/// <summary>
|
||||
/// OnboardedAt is the row's insert time from this day on; its one-time backfill gave the older rows
|
||||
@@ -90,7 +90,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1));
|
||||
|
||||
/// <summary>First UTC day of the daily usage rows the response needs: the window and the activity look-back.</summary>
|
||||
internal static DateTime UsageReadStart(DateTime today, int days) => InclusiveWindowStart(today, Math.Max(days, ActivityDays));
|
||||
internal static DateTime UsageReadStart(DateTime today, int days)
|
||||
=> InclusiveWindowStart(today, Math.Max(days, TenantLifecycleFacts.ActivityDays));
|
||||
|
||||
// IANA ids only ("Europe/Berlin", "America/Argentina/Buenos_Aires", "Etc/GMT+1"). On Linux the id
|
||||
// becomes a path below the zoneinfo folder, so dots and rooted paths never reach the lookup.
|
||||
@@ -127,20 +128,10 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class TenantFacts
|
||||
{
|
||||
public TenantConfiguration Config { get; init; } = default!;
|
||||
public DateTime? SignupUtc { get; init; }
|
||||
public bool Enrolled { get; init; }
|
||||
public bool Succeeded { get; init; }
|
||||
public DateTime? LastActiveDay { get; init; }
|
||||
public DateTime? FirstEnrollmentDay { get; init; }
|
||||
public bool FirstEnrollmentPending { get; init; }
|
||||
public int? DaysToFirstEnrollment => FirstEnrollmentDay is { } first && SignupUtc is { } signup
|
||||
? (int)(first - signup.Date).TotalDays
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <param name="yesterdayAggregated">
|
||||
/// Whether yesterday's daily rows exist yet (its platform row); before the first maintenance run after
|
||||
/// midnight UTC they do not, and the counters alone tell who was active yesterday.
|
||||
/// </param>
|
||||
internal static GrowthMetricsResponse Build(
|
||||
IReadOnlyList<TenantConfiguration> configs,
|
||||
IReadOnlyList<TenantStats> stats,
|
||||
@@ -149,34 +140,24 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
int days,
|
||||
TimeZoneInfo timeZone,
|
||||
bool includeTenants,
|
||||
IReadOnlyCollection<string> excludedTenantIds)
|
||||
IReadOnlyCollection<string> excludedTenantIds,
|
||||
bool yesterdayAggregated = true)
|
||||
{
|
||||
var today = nowUtc.Date;
|
||||
var windowStart = InclusiveWindowStart(today, days);
|
||||
var previousStart = windowStart.AddDays(-days);
|
||||
var statsByTenant = stats
|
||||
.GroupBy(s => s.TenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var activeDaysByTenant = activityDays
|
||||
.GroupBy(a => a.TenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(a => a.Day.Date).Distinct().OrderBy(d => d).ToList(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var usageDaysThrough = TenantLifecycleFacts.UsageDaysThrough(today, yesterdayAggregated);
|
||||
|
||||
// A configuration row without a domain is a read of an unknown tenant id, not a signup.
|
||||
var signedUp = configs.Where(c => !string.IsNullOrEmpty(c.DomainName)).ToList();
|
||||
var signedUp = TenantLifecycleFacts.SignedUp(configs);
|
||||
var excluded = new HashSet<string>(excludedTenantIds, StringComparer.OrdinalIgnoreCase);
|
||||
var tenants = signedUp
|
||||
.Where(c => !excluded.Contains(c.TenantId))
|
||||
.Select(c => Facts(c, statsByTenant, activeDaysByTenant, today))
|
||||
.ToList();
|
||||
var tenants = TenantLifecycleFacts.For(
|
||||
signedUp.Where(c => !excluded.Contains(c.TenantId)),
|
||||
stats, activityDays, usageDaysThrough, UsageReadStart(today, days));
|
||||
var window = tenants
|
||||
.Where(t => t.SignupUtc is { } s && s.Date >= windowStart && s.Date <= today)
|
||||
.OrderBy(t => t.SignupUtc)
|
||||
.ToList();
|
||||
var activeFrom7 = today.AddDays(-6);
|
||||
var activeFrom30 = today.AddDays(-(ActivityDays - 1));
|
||||
|
||||
return new GrowthMetricsResponse
|
||||
{
|
||||
@@ -185,7 +166,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
WindowEnd = Key(today),
|
||||
TimeZone = timeZone.Id,
|
||||
ExactSignupTimesFrom = Key(ExactSignupTimesFrom),
|
||||
UsageDaysThrough = Key(today.AddDays(-1)),
|
||||
UsageDaysThrough = Key(usageDaysThrough),
|
||||
ExcludedTenants = signedUp.Count - tenants.Count,
|
||||
Signups = BuildSignups(tenants, window, windowStart, previousStart, today, days, timeZone),
|
||||
Cohorts = window
|
||||
@@ -197,7 +178,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
Signups = g.Count(),
|
||||
Enrolled = g.Count(t => t.Enrolled),
|
||||
Succeeded = g.Count(t => t.Succeeded),
|
||||
ActiveLast7Days = g.Count(t => t.LastActiveDay >= activeFrom7),
|
||||
ActiveLast7Days = g.Count(t => t.ActiveSince(activeFrom7)),
|
||||
MedianDaysToFirstEnrollment = Median(g.Select(t => t.DaysToFirstEnrollment)),
|
||||
})
|
||||
.ToList(),
|
||||
@@ -220,9 +201,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
NeverEnrolled = tenants.Count(t => !t.Enrolled),
|
||||
Enrolled = tenants.Count(t => t.Enrolled),
|
||||
EnrolledWithoutSuccess = tenants.Count(t => t.Enrolled && !t.Succeeded),
|
||||
Active7Days = tenants.Count(t => t.LastActiveDay >= activeFrom7),
|
||||
Active30Days = tenants.Count(t => t.LastActiveDay >= activeFrom30),
|
||||
Dormant = tenants.Count(t => t.Enrolled && !(t.LastActiveDay >= activeFrom30)),
|
||||
Active7Days = tenants.Count(t => t.ActiveSince(activeFrom7)),
|
||||
Active30Days = tenants.Count(t => t.ActiveSince(TenantLifecycleFacts.ActivityFrom(today))),
|
||||
Dormant = tenants.Count(t => t.IsDormant(today)),
|
||||
},
|
||||
Plans = new GrowthPlans
|
||||
{
|
||||
@@ -252,38 +233,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
};
|
||||
}
|
||||
|
||||
private static TenantFacts Facts(
|
||||
TenantConfiguration config,
|
||||
IReadOnlyDictionary<string, TenantStats> statsByTenant,
|
||||
IReadOnlyDictionary<string, List<DateTime>> activeDaysByTenant,
|
||||
DateTime today)
|
||||
{
|
||||
DateTime? signup = config.OnboardedAt is { } at ? Utc(at) : null;
|
||||
statsByTenant.TryGetValue(config.TenantId, out var counters);
|
||||
var activeDays = activeDaysByTenant.TryGetValue(config.TenantId, out var found) ? found : new List<DateTime>();
|
||||
// The daily rows end yesterday; counters that moved today mean sessions today.
|
||||
var activeToday = counters != null && Utc(counters.LastUpdated) >= today;
|
||||
DateTime? lastActive = activeToday ? today : activeDays.Count > 0 ? activeDays[^1] : null;
|
||||
var enrolled = counters?.TotalEnrollments > 0;
|
||||
var firstDay = signup is { } s
|
||||
? activeDays.Where(d => d >= s.Date).Select(d => (DateTime?)d).FirstOrDefault()
|
||||
: null;
|
||||
|
||||
return new TenantFacts
|
||||
{
|
||||
Config = config,
|
||||
SignupUtc = signup,
|
||||
Enrolled = enrolled,
|
||||
Succeeded = counters?.SuccessfulEnrollments > 0,
|
||||
LastActiveDay = lastActive,
|
||||
FirstEnrollmentDay = firstDay,
|
||||
FirstEnrollmentPending = enrolled && firstDay == null && activeToday,
|
||||
};
|
||||
}
|
||||
|
||||
private static GrowthSignups BuildSignups(
|
||||
IReadOnlyList<TenantFacts> tenants,
|
||||
IReadOnlyList<TenantFacts> window,
|
||||
IReadOnlyList<TenantLifecycleFacts> tenants,
|
||||
IReadOnlyList<TenantLifecycleFacts> window,
|
||||
DateTime windowStart,
|
||||
DateTime previousStart,
|
||||
DateTime today,
|
||||
@@ -345,12 +297,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
private static DateTime WeekStart(DateTime day) => day.AddDays(-(((int)day.DayOfWeek + 6) % 7));
|
||||
|
||||
private static DateTime Utc(DateTime value)
|
||||
=> value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
|
||||
private static string PlanTierOf(TenantConfiguration config)
|
||||
=> string.IsNullOrEmpty(config.PlanTier) ? "free" : config.PlanTier;
|
||||
|
||||
private static string Key(DateTime day) => day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static AutopilotMonitor.Functions.Functions.Metrics.TenantLifecycleFacts;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Operator-only tenant activity (GA → Metrics → Tenant Activity and the MCP tool get_tenant_activity): how
|
||||
/// every signed-up tenant used the product in the last N full UTC days against the N days before —
|
||||
/// enrollments, success, active days, lifecycle stage and portal users. The configuration rows, the per-tenant
|
||||
/// counters, the daily usage rows and the portal sign-in history are joined here, once for both consumers.
|
||||
/// </summary>
|
||||
public class GetTenantActivityFunction
|
||||
{
|
||||
private readonly ILogger<GetTenantActivityFunction> _logger;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
|
||||
public GetTenantActivityFunction(
|
||||
ILogger<GetTenantActivityFunction> logger,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
IMetricsRepository metricsRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_metricsRepo = metricsRepo;
|
||||
}
|
||||
|
||||
[Function("GetTenantActivity")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/tenant-activity")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var days = QueryParams.Int(req.Query["days"], TenantActivityBuilder.DefaultWindowDays, 1, TenantActivityBuilder.MaxWindowDays);
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var yesterday = nowUtc.Date.AddDays(-1);
|
||||
|
||||
// The configuration rows, the counters and the daily rows are not fail-soft (an empty list
|
||||
// would read as "no tenant was active"); the sign-in history is optional.
|
||||
var configsTask = _tenantConfigService.GetAllConfigurationsAsync();
|
||||
var statsTask = _metricsRepo.GetAllTenantStatsAsync();
|
||||
var rowsTask = _metricsRepo.GetTenantActivityDaysAsync(TenantActivityBuilder.RowsFrom(nowUtc.Date), yesterday);
|
||||
var aggregatedTask = _metricsRepo.HasUsageMetricsSnapshotAsync(Key(yesterday));
|
||||
var visitsTask = ReadPortalVisitsAsync(nowUtc);
|
||||
await Task.WhenAll(configsTask, statsTask, rowsTask, aggregatedTask, visitsTask);
|
||||
|
||||
return await req.OkAsync(TenantActivityBuilder.Build(
|
||||
await configsTask, await statsTask, await rowsTask, await visitsTask, nowUtc, days, await aggregatedTask));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetTenantActivity");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The portal visits of the sign-in history, or null when it could not be read.</summary>
|
||||
private async Task<IReadOnlyList<PortalVisit>?> ReadPortalVisitsAsync(DateTime nowUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _metricsRepo.GetPortalVisitsSinceAsync(nowUtc.AddDays(-TenantActivityBuilder.PortalHistoryDays));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[TenantActivity] portal activity read failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure response builder — internal static so the counting rules are pinned by tests. The DTOs live in
|
||||
/// AutopilotMonitor.Shared.Models (Models/Metrics/TenantActivityApiModels.cs); the facts every tenant shares
|
||||
/// with the growth figures come from <see cref="TenantLifecycleFacts"/>.
|
||||
/// </summary>
|
||||
internal static class TenantActivityBuilder
|
||||
{
|
||||
internal const int DefaultWindowDays = 30;
|
||||
|
||||
/// <summary>The window and the previous window together fit into the 180 days the daily rows keep.</summary>
|
||||
internal const int MaxWindowDays = DataLifetimes.UsageMetricsDays / 2;
|
||||
|
||||
/// <summary>The sign-in history keeps 90 days; the last portal visit is taken from all of it.</summary>
|
||||
internal const int PortalHistoryDays = 90;
|
||||
|
||||
/// <summary>A tenant is declining from this many enrollments in the previous window on.</summary>
|
||||
internal const int DecliningMinPrevious = 5;
|
||||
|
||||
internal const int TopShareTenants = 10;
|
||||
|
||||
private static readonly List<TenantActivityDay> NoRows = new();
|
||||
private static readonly List<PortalVisit> NoVisits = new();
|
||||
|
||||
/// <summary>First day of the daily rows the builder reads: all the rows keep, for the first day with sessions.</summary>
|
||||
internal static DateTime RowsFrom(DateTime today) => today.AddDays(-DataLifetimes.UsageMetricsDays);
|
||||
|
||||
private sealed record Window(int Days, DateTime Start, DateTime Through, DateTime PreviousStart)
|
||||
{
|
||||
public bool Contains(DateTime day) => day >= Start && day <= Through;
|
||||
public bool ContainsPrevious(DateTime day) => day >= PreviousStart && day < Start;
|
||||
}
|
||||
|
||||
/// <param name="visits">The portal visits of the sign-in history; null when it could not be read.</param>
|
||||
/// <param name="yesterdayAggregated">Whether yesterday's daily rows exist yet (its platform row).</param>
|
||||
internal static GetTenantActivityResponse Build(
|
||||
IReadOnlyList<TenantConfiguration> configs,
|
||||
IReadOnlyList<TenantStats> stats,
|
||||
IReadOnlyList<TenantActivityDay> rows,
|
||||
IReadOnlyList<PortalVisit>? visits,
|
||||
DateTime nowUtc,
|
||||
int days,
|
||||
bool yesterdayAggregated)
|
||||
{
|
||||
var today = nowUtc.Date;
|
||||
var through = UsageDaysThrough(today, yesterdayAggregated);
|
||||
var start = through.AddDays(-(days - 1));
|
||||
var window = new Window(days, start, through, start.AddDays(-days));
|
||||
var rowsFrom = RowsFrom(today);
|
||||
|
||||
var facts = For(SignedUp(configs), stats, rows, through, rowsFrom);
|
||||
var signedUpIds = new HashSet<string>(facts.Select(f => f.Config.TenantId), StringComparer.OrdinalIgnoreCase);
|
||||
var countedRows = rows
|
||||
.Where(r => signedUpIds.Contains(r.TenantId) && (window.Contains(r.Day) || window.ContainsPrevious(r.Day)))
|
||||
.ToList();
|
||||
var rowsByTenant = countedRows
|
||||
.GroupBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
var visitsByTenant = visits?
|
||||
.GroupBy(v => v.TenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tenants = facts
|
||||
.Select(f => Tenant(f, window, rowsByTenant, visitsByTenant, today, nowUtc))
|
||||
.OrderByDescending(t => t.Enrollments)
|
||||
.ThenByDescending(t => t.LifetimeEnrollments)
|
||||
.ThenBy(t => t.TenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new GetTenantActivityResponse
|
||||
{
|
||||
WindowDays = days,
|
||||
WindowStart = Key(window.Start),
|
||||
UsageDaysThrough = Key(window.Through),
|
||||
PreviousWindowStart = Key(window.PreviousStart),
|
||||
RowsFrom = Key(rowsFrom),
|
||||
PortalActivityAvailable = visits != null,
|
||||
Totals = Totals(tenants, visits != null),
|
||||
Daily = Daily(countedRows.Where(r => window.Contains(r.Day)), window),
|
||||
Tenants = tenants,
|
||||
};
|
||||
}
|
||||
|
||||
private static TenantActivityTenantItem Tenant(
|
||||
TenantLifecycleFacts facts,
|
||||
Window window,
|
||||
IReadOnlyDictionary<string, List<TenantActivityDay>> rowsByTenant,
|
||||
IReadOnlyDictionary<string, List<PortalVisit>>? visitsByTenant,
|
||||
DateTime today,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
var config = facts.Config;
|
||||
var tenantRows = rowsByTenant.TryGetValue(config.TenantId, out var found) ? found : NoRows;
|
||||
var inWindow = tenantRows.Where(r => window.Contains(r.Day)).ToList();
|
||||
var enrollments = inWindow.Sum(r => r.SessionsTotal);
|
||||
var previous = tenantRows.Where(r => window.ContainsPrevious(r.Day)).Sum(r => r.SessionsTotal);
|
||||
var succeeded = inWindow.Sum(r => r.SessionsSucceeded);
|
||||
var failed = inWindow.Sum(r => r.SessionsFailed);
|
||||
|
||||
int[]? daily = null;
|
||||
if (enrollments > 0)
|
||||
{
|
||||
daily = new int[window.Days];
|
||||
foreach (var row in inWindow)
|
||||
daily[(int)(row.Day.Date - window.Start).TotalDays] += row.SessionsTotal;
|
||||
}
|
||||
|
||||
int? portalUsers = null;
|
||||
DateTime? lastVisit = null;
|
||||
if (visitsByTenant != null)
|
||||
{
|
||||
var tenantVisits = visitsByTenant.TryGetValue(config.TenantId, out var v) ? v : NoVisits;
|
||||
var windowEnd = window.Through.AddDays(1);
|
||||
portalUsers = tenantVisits
|
||||
.Where(x => x.At >= window.Start && x.At < windowEnd)
|
||||
.Select(x => x.PersonKey)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Count();
|
||||
lastVisit = tenantVisits.Count > 0 ? tenantVisits.Max(x => x.At) : null;
|
||||
}
|
||||
|
||||
var counters = facts.Counters;
|
||||
return new TenantActivityTenantItem
|
||||
{
|
||||
TenantId = config.TenantId,
|
||||
DomainName = config.DomainName,
|
||||
CompanyName = string.IsNullOrWhiteSpace(config.CompanyName) ? null : config.CompanyName,
|
||||
Edition = FeatureEntitlementCatalog.Resolve(config, nowUtc).FacetName,
|
||||
Stage = StageOf(facts, enrollments, previous, window, today),
|
||||
OnboardedAt = facts.SignupUtc,
|
||||
Enrollments = enrollments,
|
||||
EnrollmentsPrevious = previous,
|
||||
TrendPercent = TrendPercent(enrollments, previous),
|
||||
Succeeded = succeeded,
|
||||
Failed = failed,
|
||||
SuccessPercent = Percent(succeeded, succeeded + failed),
|
||||
ActiveDays = inWindow.Select(r => r.Day.Date).Distinct().Count(),
|
||||
DailyEnrollments = daily,
|
||||
FirstEnrollmentDay = facts.FirstEnrollmentDay is { } first ? Key(first) : null,
|
||||
LastActiveDay = facts.LastActiveDay is { } last ? Key(last) : null,
|
||||
LastCounterUpdateAt = counters != null && counters.LastUpdated > DateTime.MinValue ? Utc(counters.LastUpdated) : null,
|
||||
LifetimeEnrollments = counters?.TotalEnrollments ?? 0,
|
||||
LifetimeSucceeded = counters?.SuccessfulEnrollments ?? 0,
|
||||
LifetimeEvents = counters?.TotalEventsProcessed ?? 0,
|
||||
PortalUsers = portalUsers,
|
||||
LastPortalVisitAt = lastVisit,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The first of <see cref="TenantActivityStages.All"/> that applies.</summary>
|
||||
private static string StageOf(TenantLifecycleFacts facts, int enrollments, int previous, Window window, DateTime today)
|
||||
{
|
||||
if (!facts.Enrolled) return TenantActivityStages.NeverEnrolled;
|
||||
if (facts.IsDormant(today)) return TenantActivityStages.Dormant;
|
||||
if (facts.FirstEnrollmentPending || facts.FirstEnrollmentDay is { } first && window.Contains(first))
|
||||
return TenantActivityStages.New;
|
||||
if (previous >= DecliningMinPrevious && enrollments * 2 <= previous) return TenantActivityStages.Declining;
|
||||
return enrollments > 0 ? TenantActivityStages.Active : TenantActivityStages.Quiet;
|
||||
}
|
||||
|
||||
private static TenantActivityTotals Totals(IReadOnlyList<TenantActivityTenantItem> tenants, bool portalAvailable)
|
||||
{
|
||||
var enrollments = tenants.Sum(t => t.Enrollments);
|
||||
var previous = tenants.Sum(t => t.EnrollmentsPrevious);
|
||||
var active = tenants.Count(t => t.Enrollments > 0);
|
||||
var activePrevious = tenants.Count(t => t.EnrollmentsPrevious > 0);
|
||||
var succeeded = tenants.Sum(t => t.Succeeded);
|
||||
var failed = tenants.Sum(t => t.Failed);
|
||||
int CountStage(string stage) => tenants.Count(t => t.Stage == stage);
|
||||
|
||||
return new TenantActivityTotals
|
||||
{
|
||||
SignedUpTenants = tenants.Count,
|
||||
ActiveTenants = active,
|
||||
ActiveTenantsPrevious = activePrevious,
|
||||
ActiveTenantsTrendPercent = TrendPercent(active, activePrevious),
|
||||
Enrollments = enrollments,
|
||||
EnrollmentsPrevious = previous,
|
||||
EnrollmentsTrendPercent = TrendPercent(enrollments, previous),
|
||||
Succeeded = succeeded,
|
||||
Failed = failed,
|
||||
SuccessPercent = Percent(succeeded, succeeded + failed),
|
||||
// The tenants come sorted by enrollments, most first.
|
||||
Top10SharePercent = Percent(tenants.Take(TopShareTenants).Sum(t => t.Enrollments), enrollments),
|
||||
Stages = new TenantActivityStageCounts
|
||||
{
|
||||
NeverEnrolled = CountStage(TenantActivityStages.NeverEnrolled),
|
||||
Dormant = CountStage(TenantActivityStages.Dormant),
|
||||
New = CountStage(TenantActivityStages.New),
|
||||
Declining = CountStage(TenantActivityStages.Declining),
|
||||
Active = CountStage(TenantActivityStages.Active),
|
||||
Quiet = CountStage(TenantActivityStages.Quiet),
|
||||
},
|
||||
PortalUsers = portalAvailable ? tenants.Sum(t => t.PortalUsers ?? 0) : null,
|
||||
PortalTenants = portalAvailable ? tenants.Count(t => t.PortalUsers > 0) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<TenantActivityDailyItem> Daily(IEnumerable<TenantActivityDay> windowRows, Window window)
|
||||
{
|
||||
var byDay = windowRows
|
||||
.GroupBy(r => r.Day.Date)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
var daily = new List<TenantActivityDailyItem>(window.Days);
|
||||
for (var day = window.Start; day <= window.Through; day = day.AddDays(1))
|
||||
{
|
||||
var dayRows = byDay.TryGetValue(day, out var found) ? found : NoRows;
|
||||
daily.Add(new TenantActivityDailyItem
|
||||
{
|
||||
Day = Key(day),
|
||||
Enrollments = dayRows.Sum(r => r.SessionsTotal),
|
||||
Succeeded = dayRows.Sum(r => r.SessionsSucceeded),
|
||||
Failed = dayRows.Sum(r => r.SessionsFailed),
|
||||
ActiveTenants = dayRows.Select(r => r.TenantId).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
});
|
||||
}
|
||||
return daily;
|
||||
}
|
||||
|
||||
/// <summary><paramref name="part"/> of <paramref name="whole"/> in whole percent; null when the whole is zero.</summary>
|
||||
internal static int? Percent(long part, long whole)
|
||||
=> whole > 0 ? (int)Math.Round(100.0 * part / whole, MidpointRounding.AwayFromZero) : null;
|
||||
|
||||
/// <summary>Change from <paramref name="previous"/> to <paramref name="current"/> in whole percent; null without a previous value.</summary>
|
||||
internal static int? TrendPercent(int current, int previous)
|
||||
=> previous > 0 ? (int)Math.Round(100.0 * (current - previous) / previous, MidpointRounding.AwayFromZero) : null;
|
||||
}
|
||||
}
|
||||
+62
-93
@@ -22,17 +22,70 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
private readonly IUserUsageRepository _userUsageRepo;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly McpQuotaService _quotaService;
|
||||
private readonly McpUsageOverviewService _overviewService;
|
||||
|
||||
public McpUsageMetricsFunction(
|
||||
ILogger<McpUsageMetricsFunction> logger,
|
||||
IUserUsageRepository userUsageRepo,
|
||||
McpUserService mcpUserService,
|
||||
McpQuotaService quotaService)
|
||||
McpQuotaService quotaService,
|
||||
McpUsageOverviewService overviewService)
|
||||
{
|
||||
_logger = logger;
|
||||
_userUsageRepo = userUsageRepo;
|
||||
_mcpUserService = mcpUserService;
|
||||
_quotaService = quotaService;
|
||||
_overviewService = overviewService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage/overview?days=&platformAdmins= — the all-tenant MCP usage overview of
|
||||
/// the Global Admin metrics page: tenants with accounts and quota state, the tool ranking, quota refusals,
|
||||
/// per-minute peaks and the daily trend (see <see cref="McpUsageOverviewService"/>). <c>days</c> = 1–90 (the
|
||||
/// telemetry keeps 90 days), default 30; <c>platformAdmins=include</c> counts Global Admins and Global Readers,
|
||||
/// left out by default.
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpUsageOverview")]
|
||||
public async Task<HttpResponseData> GetGlobalUsageOverview(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/overview")] HttpRequestData req)
|
||||
{
|
||||
var query = ParseOverviewQuery(req.Query["days"], req.Query["platformAdmins"]);
|
||||
if (query.Error != null)
|
||||
{
|
||||
return await req.BadRequestAsync(query.Error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var overview = await _overviewService.BuildAsync(query.Days, query.IncludePlatformAdmins);
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(overview);
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the overview's query string. <c>days</c> defaults to 30 and must be an integer from 1 to
|
||||
/// <see cref="McpUsageOverviewService.MaxDays"/>; <c>platformAdmins</c> is <c>include</c> or <c>exclude</c>
|
||||
/// (the default). Anything else names the problem in <c>Error</c>.
|
||||
/// </summary>
|
||||
internal static (int Days, bool IncludePlatformAdmins, string? Error) ParseOverviewQuery(string? rawDays, string? platformAdmins)
|
||||
{
|
||||
if (!QueryParams.TryInt(rawDays, "days", 1, McpUsageOverviewService.MaxDays, out var days, out var error))
|
||||
{
|
||||
return (0, false, error);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(platformAdmins) && platformAdmins is not ("include" or "exclude"))
|
||||
{
|
||||
return (0, false, "platformAdmins must be include or exclude");
|
||||
}
|
||||
|
||||
return (days ?? 30, platformAdmins == "include", null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -65,7 +118,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
// Effective quota state: resolved plan (per-user override → tenant edition),
|
||||
// limits (SectionUsagePlans definition → catalog fallback) and current counters.
|
||||
var quota = await _quotaService.CheckAsync(userId, upn, tenantId);
|
||||
// A read: viewing the page is not an MCP request and must not count as one.
|
||||
var quota = await _quotaService.ReadStateAsync(userId, upn, tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetMyMcpUsageResponse
|
||||
@@ -86,11 +140,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
TenantMonthlyLimit = quota.TenantMonthlyLimit,
|
||||
TenantDailyUsed = quota.TenantDailyUsed,
|
||||
TenantMonthlyUsed = quota.TenantMonthlyUsed,
|
||||
PurchasedDelegatedSlots = WithSlots(quota.PurchasedDelegatedSlots, quota.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotDailyLimit),
|
||||
SlotMonthlyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotMonthlyLimit),
|
||||
SlotTenantDailyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantDailyLimit),
|
||||
SlotTenantMonthlyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantMonthlyLimit),
|
||||
PurchasedDelegatedSlots = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotDailyLimit),
|
||||
SlotMonthlyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotMonthlyLimit),
|
||||
SlotTenantDailyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantDailyLimit),
|
||||
SlotTenantMonthlyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantMonthlyLimit),
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
@@ -164,7 +218,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
DateFrom = dateFrom,
|
||||
DateTo = dateTo,
|
||||
Users = AggregateOrganizationUsage(records, dateFrom, dateTo, today, monthStart),
|
||||
Quota = BuildOrganizationQuota(records, limits, today, monthStart),
|
||||
Quota = McpQuotaNodes.BuildOrganizationQuota(records, limits, today, monthStart),
|
||||
Daily = BuildOrganizationDaily(records, dateFrom, dateTo),
|
||||
});
|
||||
return response;
|
||||
@@ -190,37 +244,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return byDay.Select(kv => new McpOrganizationDailyItem { Date = kv.Key, Requests = kv.Value }).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the tenant's organization windows from the same counter rows — today's and this month's total over
|
||||
/// every account charged to the tenant, against the tenant plan's limits (0 = unlimited).
|
||||
/// </summary>
|
||||
internal static McpOrganizationQuotaNode BuildOrganizationQuota(
|
||||
IEnumerable<TenantUsageRecord> records, McpQuotaService.TenantPlanLimits limits, string today, string monthStart)
|
||||
{
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
foreach (var record in records)
|
||||
{
|
||||
if (record.Date == today) dailyUsed += record.RequestCount;
|
||||
if (string.CompareOrdinal(record.Date, monthStart) >= 0 && string.CompareOrdinal(record.Date, today) <= 0)
|
||||
monthlyUsed += record.RequestCount;
|
||||
}
|
||||
|
||||
return new McpOrganizationQuotaNode
|
||||
{
|
||||
TenantPlan = limits.TenantPlan,
|
||||
DailyLimit = limits.TenantDailyLimit,
|
||||
MonthlyLimit = limits.TenantMonthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
PurchasedDelegatedSlots = WithSlots(limits.PurchasedDelegatedSlots, limits.PurchasedDelegatedSlots),
|
||||
SlotDailyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantDailyLimit),
|
||||
SlotMonthlyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantMonthlyLimit),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The slot breakdown is only worth sending when a slot was actually purchased.</summary>
|
||||
private static int? WithSlots(int purchasedSlots, int value) => purchasedSlots > 0 ? value : null;
|
||||
|
||||
/// <summary>
|
||||
/// Pure: folds the organization counter rows into one item per account, ordered by this month's usage.
|
||||
/// </summary>
|
||||
@@ -318,59 +341,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage?tenantId=&dateFrom=&dateTo= — Global usage
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpUsage")]
|
||||
public async Task<HttpResponseData> GetGlobalUsage(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage")] HttpRequestData req)
|
||||
{
|
||||
_logger.LogInformation("Global MCP usage requested");
|
||||
|
||||
try
|
||||
{
|
||||
var tenantId = req.Query["tenantId"];
|
||||
var dateFrom = req.Query["dateFrom"];
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
var records = await _userUsageRepo.GetUsageByTenantAsync(tenantId ?? "", dateFrom, dateTo);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage/daily?tenantId=&dateFrom=&dateTo= — Daily summaries
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpUsageDaily")]
|
||||
public async Task<HttpResponseData> GetGlobalUsageDaily(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/daily")] HttpRequestData req)
|
||||
{
|
||||
_logger.LogInformation("Global MCP daily usage requested");
|
||||
|
||||
try
|
||||
{
|
||||
var tenantId = req.Query["tenantId"];
|
||||
var dateFrom = req.Query["dateFrom"];
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
var summaries = await _userUsageRepo.GetDailySummaryAsync(tenantId, dateFrom, dateTo);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
/// <summary>
|
||||
/// What the growth figures and the tenant activity both know about one signed-up tenant, from its
|
||||
/// configuration row, its lifetime counters (PlatformStats tenant/current) and the daily UsageMetrics rows.
|
||||
/// One place, so both answer "enrolled", "last active", "first day with sessions" and "dormant" identically —
|
||||
/// the GA page and the growth report are checked against each other.
|
||||
/// </summary>
|
||||
internal sealed class TenantLifecycleFacts
|
||||
{
|
||||
/// <summary>A tenant without activity in this many UTC days, today included, is dormant.</summary>
|
||||
internal const int ActivityDays = 30;
|
||||
|
||||
private static readonly List<DateTime> NoDays = new();
|
||||
|
||||
public TenantConfiguration Config { get; init; } = default!;
|
||||
public TenantStats? Counters { get; init; }
|
||||
public DateTime? SignupUtc { get; init; }
|
||||
/// <summary>The lifetime counter holds at least one enrollment.</summary>
|
||||
public bool Enrolled { get; init; }
|
||||
/// <summary>The lifetime counter holds at least one Succeeded session.</summary>
|
||||
public bool Succeeded { get; init; }
|
||||
/// <summary>
|
||||
/// Latest UTC day with sessions: the day the counters last moved when that lies after the last daily row
|
||||
/// (sessions the rows have not seen yet), else the last daily row.
|
||||
/// </summary>
|
||||
public DateTime? LastActiveDay { get; init; }
|
||||
/// <summary>
|
||||
/// First UTC day with sessions since signup; null while only the counters have seen them (pending) and for
|
||||
/// a signup before the rows' first day (unknown).
|
||||
/// </summary>
|
||||
public DateTime? FirstEnrollmentDay { get; init; }
|
||||
/// <summary>Enrolled without a daily row since signup, but the counters moved after the last row.</summary>
|
||||
public bool FirstEnrollmentPending { get; init; }
|
||||
|
||||
public int? DaysToFirstEnrollment => FirstEnrollmentDay is { } first && SignupUtc is { } signup
|
||||
? (int)(first - signup.Date).TotalDays
|
||||
: null;
|
||||
|
||||
/// <summary>Active on <paramref name="day"/> or later.</summary>
|
||||
public bool ActiveSince(DateTime day) => LastActiveDay >= day;
|
||||
|
||||
/// <summary>Enrolled, but no activity in the last <see cref="ActivityDays"/> UTC days, today included.</summary>
|
||||
public bool IsDormant(DateTime today) => Enrolled && !ActiveSince(ActivityFrom(today));
|
||||
|
||||
/// <summary>First day of the dormant look-back: today and the <see cref="ActivityDays"/> − 1 days before.</summary>
|
||||
internal static DateTime ActivityFrom(DateTime today) => today.AddDays(-(ActivityDays - 1));
|
||||
|
||||
/// <summary>
|
||||
/// The last day the daily rows cover: yesterday once it is aggregated (its platform row exists), else the
|
||||
/// day before — the first maintenance run after midnight UTC writes yesterday's rows.
|
||||
/// </summary>
|
||||
internal static DateTime UsageDaysThrough(DateTime today, bool yesterdayAggregated)
|
||||
=> today.AddDays(yesterdayAggregated ? -1 : -2);
|
||||
|
||||
/// <summary>A configuration row without a domain is a read of an unknown tenant id, not a signup.</summary>
|
||||
internal static List<TenantConfiguration> SignedUp(IEnumerable<TenantConfiguration> configs)
|
||||
=> configs.Where(c => !string.IsNullOrEmpty(c.DomainName)).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The facts of every given tenant. <paramref name="rows"/> are the daily rows from <paramref name="rowsFrom"/>
|
||||
/// through <paramref name="usageDaysThrough"/>; a first day with sessions before <paramref name="rowsFrom"/>
|
||||
/// cannot be told, so it stays unknown for an older signup. Tenant ids join case-insensitively.
|
||||
/// </summary>
|
||||
internal static List<TenantLifecycleFacts> For(
|
||||
IEnumerable<TenantConfiguration> configs,
|
||||
IReadOnlyList<TenantStats> stats,
|
||||
IReadOnlyList<TenantActivityDay> rows,
|
||||
DateTime usageDaysThrough,
|
||||
DateTime rowsFrom)
|
||||
{
|
||||
var countersByTenant = stats
|
||||
.GroupBy(s => s.TenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var daysByTenant = rows
|
||||
.GroupBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(r => r.Day.Date).Distinct().OrderBy(d => d).ToList(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
return configs
|
||||
.Select(c => Of(c, countersByTenant, daysByTenant, usageDaysThrough, rowsFrom))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static TenantLifecycleFacts Of(
|
||||
TenantConfiguration config,
|
||||
IReadOnlyDictionary<string, TenantStats> countersByTenant,
|
||||
IReadOnlyDictionary<string, List<DateTime>> daysByTenant,
|
||||
DateTime usageDaysThrough,
|
||||
DateTime rowsFrom)
|
||||
{
|
||||
DateTime? signup = config.OnboardedAt is { } at ? Utc(at) : null;
|
||||
countersByTenant.TryGetValue(config.TenantId, out var counters);
|
||||
var days = daysByTenant.TryGetValue(config.TenantId, out var found) ? found : NoDays;
|
||||
// The rows end at usageDaysThrough; counters that moved after it saw sessions the rows have not.
|
||||
DateTime? countersDay = counters != null ? Utc(counters.LastUpdated).Date : null;
|
||||
var movedAfterRows = countersDay > usageDaysThrough;
|
||||
DateTime? lastActive = movedAfterRows ? countersDay : days.Count > 0 ? days[^1] : null;
|
||||
var enrolled = counters?.TotalEnrollments > 0;
|
||||
var knowable = signup is { } s && s.Date >= rowsFrom;
|
||||
var firstDay = knowable
|
||||
? days.Where(d => d >= signup!.Value.Date).Select(d => (DateTime?)d).FirstOrDefault()
|
||||
: null;
|
||||
|
||||
return new TenantLifecycleFacts
|
||||
{
|
||||
Config = config,
|
||||
Counters = counters,
|
||||
SignupUtc = signup,
|
||||
Enrolled = enrolled,
|
||||
Succeeded = counters?.SuccessfulEnrollments > 0,
|
||||
LastActiveDay = lastActive,
|
||||
FirstEnrollmentDay = firstDay,
|
||||
FirstEnrollmentPending = knowable && enrolled && firstDay == null && movedAfterRows,
|
||||
};
|
||||
}
|
||||
|
||||
internal static DateTime Utc(DateTime value)
|
||||
=> value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
|
||||
internal static string Key(DateTime day) => day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,7 +11,7 @@ namespace AutopilotMonitor.Functions.Functions.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Device management from the portal: list (own devices for everyone on the tier, every device
|
||||
/// of the scope for its admins), remove (wipe push, then the row is gone) and test. Tenant routes
|
||||
/// of the scope for its admins), rename, remove (wipe push, then the row is gone) and test. Tenant routes
|
||||
/// under <c>push/devices…</c> (TenantAdminOrOperator), platform routes under
|
||||
/// <c>global/push/devices…</c> (GlobalAdminOnly).
|
||||
/// </summary>
|
||||
@@ -36,6 +37,12 @@ public class PushDevicesFunctions
|
||||
string deviceId)
|
||||
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
|
||||
|
||||
[Function("RenamePushDevice")]
|
||||
public Task<HttpResponseData> Rename(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "push/devices/{deviceId}")] HttpRequestData req,
|
||||
string deviceId)
|
||||
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req));
|
||||
|
||||
[Function("TestPushDevice")]
|
||||
public Task<HttpResponseData> Test(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/devices/{deviceId}/test")] HttpRequestData req,
|
||||
@@ -53,6 +60,12 @@ public class PushDevicesFunctions
|
||||
string deviceId)
|
||||
=> DeleteCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
|
||||
|
||||
[Function("RenamePlatformPushDevice")]
|
||||
public Task<HttpResponseData> RenamePlatform(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "global/push/devices/{deviceId}")] HttpRequestData req,
|
||||
string deviceId)
|
||||
=> RenameCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req));
|
||||
|
||||
[Function("TestPlatformPushDevice")]
|
||||
public Task<HttpResponseData> TestPlatform(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/push/devices/{deviceId}/test")] HttpRequestData req,
|
||||
@@ -88,6 +101,25 @@ public class PushDevicesFunctions
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> RenameCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!PushFunctionSupport.IsPlausibleId(deviceId))
|
||||
return await req.NotFoundAsync("Device not found.");
|
||||
var read = await req.ReadAsync<RenamePushDeviceRequest>(PushFunctionSupport.LabelBodyMaxBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var error = await _pairing.RenameAsync(ctx.Scope, deviceId, read.Value!.Label, ctx.Caller);
|
||||
if (error != PushOpError.None)
|
||||
return await PushFunctionSupport.ErrorAsync(req, error, error == PushOpError.NotFound ? "Device not found." : null);
|
||||
return req.CreateResponse(HttpStatusCode.NoContent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "RenamePushDevice");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> TestCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace AutopilotMonitor.Functions.Functions.Push;
|
||||
/// </summary>
|
||||
internal static class PushFunctionSupport
|
||||
{
|
||||
/// <summary>Body cap of the portal routes that carry only a device label.</summary>
|
||||
public const long LabelBodyMaxBytes = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// The tenant scope of a <c>push/…</c> route: the caller's own tenant. A Global Admin passes
|
||||
/// the TenantAdminOrOperator tier by bypass, so pairing re-checks the member role (K2/K5) and
|
||||
@@ -57,6 +60,7 @@ internal static class PushFunctionSupport
|
||||
PushOpError.NotConfigured => req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable,
|
||||
"The push channel is not configured on the platform."),
|
||||
PushOpError.Conflict => req.ConflictAsync(message ?? "The operation conflicts with the current state."),
|
||||
PushOpError.InvalidLabel => req.BadRequestAsync(message ?? "Enter a device name."),
|
||||
_ => req.InternalServerErrorAsync(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
|
||||
new InvalidOperationException("unmapped push error"), "Push"),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -114,7 +115,10 @@ public class PushPairingFunctions
|
||||
{
|
||||
if (!PushFunctionSupport.IsPlausibleId(pairingId))
|
||||
return await req.NotFoundAsync("Pairing not found.");
|
||||
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller);
|
||||
// The body is optional: without one the device keeps the name it sent.
|
||||
var read = await req.ReadOptionalAsync<ConfirmPairingRequest>(PushFunctionSupport.LabelBodyMaxBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller, read.Value?.Label);
|
||||
return result.Ok
|
||||
? await req.OkAsync(result.Value!)
|
||||
: await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message ?? "Pairing not found.");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user