mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-30 15:35:48 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7c6b11909 | |||
| 74d489b51e | |||
| dff69b4d73 | |||
| 9c654983d1 | |||
| 0bc82fd719 | |||
| 783b390448 | |||
| 5c75155293 | |||
| 7588286787 | |||
| 0d6797275c | |||
| 17f98f5c56 | |||
| f53fdf86f6 | |||
| b57f9312ce | |||
| 5136d33c2c | |||
| 5589ca42c7 | |||
| 8f9cf6e153 | |||
| eebbc895c8 | |||
| 7d19a71230 | |||
| 77569e0dfb | |||
| 7a457b5d63 | |||
| 1ea366c419 | |||
| bba8a0d6b4 | |||
| 6abe450388 | |||
| fe193268ea | |||
| ef6cf8e3f4 | |||
| e06a687ddd | |||
| 1ee13de7a8 | |||
| 8cb061784e | |||
| 7fd4a4fe62 | |||
| 8929f1c5b4 | |||
| ff04137d0a | |||
| ff8bc7927f | |||
| 8a53c6233f | |||
| f3a4ce6ed8 | |||
| 42dae1b38c | |||
| b58830ddcb | |||
| caac165172 | |||
| d25f2286b4 | |||
| 1dd4a477c5 |
+1
-1
Submodule .claude/commands updated: c67c475a62...0efa7c79ca
@@ -1,13 +1,50 @@
|
||||
name: Bootstrap script gates
|
||||
|
||||
# Quality gate for the customer-facing PowerShell scripts, factored out so it exists
|
||||
# once and both callers run the identical checks:
|
||||
# * pester.yml -- pull requests (reports, does not block the merge button)
|
||||
# * publish-scripts.yml -- pushes to main, where it BLOCKS the upload to the
|
||||
# download alias. Devices execute these scripts as SYSTEM
|
||||
# during enrollment, so a red suite must never ship.
|
||||
# Quality gate for the customer-facing PowerShell scripts. Devices execute them as SYSTEM
|
||||
# during enrollment, so a red suite must never ship. It runs in two roles:
|
||||
# * on its own, on every pull request and push to main that changes an input of these
|
||||
# checks (reports, does not block the merge button);
|
||||
# * called as the gates job that BLOCKS every signing or upload of bootstrap code:
|
||||
# publish-scripts.yml, build-agent.yml (stable releases), build-msi.yml. Those runs
|
||||
# show under the caller.
|
||||
#
|
||||
# Both path lists are the checks' complete inputs and stay identical. They cover every
|
||||
# push path of publish-scripts.yml, which leaves the check of a [skip publish] push to
|
||||
# this workflow.
|
||||
#
|
||||
# No concurrency group: it would also bind the runs called by the publishers, and a
|
||||
# cancel there would block a publish.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Get-Endpoints.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
# The integration tests run the generated OOBE script and the MSI's custom action; the
|
||||
# contract tests also read the MSI build.
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Get-Endpoints.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -254,6 +254,12 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# A stable release publishes the customer scripts as well, so it waits for the gates their
|
||||
# own publish waits for. Next to the build; skipped when nothing goes out as stable.
|
||||
gates:
|
||||
if: inputs.publish_as_stable
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool
|
||||
# run here -- never a restore or build. The build output arrives as data. The runner
|
||||
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
|
||||
@@ -263,7 +269,9 @@ jobs:
|
||||
# sign nor write storage, whatever its permissions say.
|
||||
release:
|
||||
name: Sign and publish
|
||||
needs: [reserve, build]
|
||||
needs: [reserve, build, gates]
|
||||
# A skipped gates job (no stable publish) lets the release run; a failed or cancelled one stops it.
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' && (needs.gates.result == 'success' || needs.gates.result == 'skipped') }}
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
@@ -726,9 +734,9 @@ jobs:
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:STABLE_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:STABLE_MANIFEST -ContentType "application/json"
|
||||
|
||||
# Customer-facing scripts: same publisher publish-scripts.yml runs on every push
|
||||
# to main, so a release lands a set that is consistent with what is already live
|
||||
# (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Customer-facing scripts: same publisher and same gates (the gates job) as
|
||||
# publish-scripts.yml, so a release lands a set that is consistent with what is
|
||||
# already live (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Runs AFTER version.json so its bootstrapVersion read sees this build's manifest.
|
||||
# Signed with the same identity: the device-side loader verifies the publisher
|
||||
# of what it downloads, and the publisher refuses to upload unsigned scripts.
|
||||
|
||||
@@ -34,6 +34,12 @@ env:
|
||||
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
|
||||
|
||||
jobs:
|
||||
# The loader executes as SYSTEM on every enrolling device, and the MSI's custom action runs
|
||||
# it: nothing is signed before the bootstrap gates pass on this commit, since a signed copy
|
||||
# of ours is trusted wherever it lands.
|
||||
gates:
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
# Signs the loader BEFORE the build: wix embeds the file as it finds it, so a loader signed
|
||||
# afterwards would leave the copy inside the MSI unsigned. Devices under an AllSigned policy
|
||||
# need the embedded loader to carry a signature of its own.
|
||||
@@ -42,6 +48,7 @@ jobs:
|
||||
# here. The `release` environment is the only subject the code-signing identity trusts.
|
||||
sign-loader:
|
||||
name: Sign bootstrap loader
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
@@ -59,22 +66,6 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
# The loader executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
# bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job
|
||||
# stands on its own because it can be dispatched at any time.
|
||||
- name: Validate runner script (parse + ASCII-only)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errs = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $env:RUNNER_SCRIPT), [ref]$null, [ref]$errs) | Out-Null
|
||||
if ($errs) { $errs | ForEach-Object { Write-Error $_.ToString() }; exit 1 }
|
||||
if ((Get-Content $env:RUNNER_SCRIPT -Raw) -match '[^\x00-\x7F]') {
|
||||
Write-Error "Runner script contains non-ASCII characters"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Runner script parse + ASCII check passed"
|
||||
|
||||
# Every published MSI is signed; a missing variable stops the run before the build.
|
||||
- name: Check code-signing configuration
|
||||
shell: pwsh
|
||||
|
||||
@@ -46,7 +46,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -79,7 +78,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -70,7 +69,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
schedule:
|
||||
# Weekly, so advisories published after a merge still surface. Deliberately
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Pester (Bootstrap scripts)
|
||||
|
||||
# Pull-request view of the bootstrap script gates. Like ci.yml it reports rather than
|
||||
# blocks the merge button.
|
||||
#
|
||||
# There is deliberately no push trigger: publish-scripts.yml runs the very same
|
||||
# reusable gate workflow on every push to main and blocks the upload to the download
|
||||
# alias on it. A push trigger here would only duplicate that run, with no consequence.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
# The integration tests run the generated OOBE script and the MSI's command line.
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pester-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit.
|
||||
gates:
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
@@ -55,7 +55,12 @@ env:
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit, never a
|
||||
# path some earlier step could have written to the workspace.
|
||||
#
|
||||
# A push with [skip publish] in one of its commit messages runs neither job and uploads
|
||||
# nothing; the gates' own push trigger, whose paths cover these, still checks it. Never
|
||||
# [skip actions] for that: it drops every check of the push, CI and these gates included.
|
||||
gates:
|
||||
if: github.event_name != 'push' || !contains(join(github.event.commits.*.message, ' '), '[skip publish]')
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
publish:
|
||||
|
||||
+1
-1
Submodule infra updated: 0085deab45...7589909f54
+1
-1
Submodule internal updated: ffb7595c02...f7d94b395a
@@ -1,56 +1,63 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-001",
|
||||
"title": "Win32 App Detection Script Failure",
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"title": "App Not Detected After Install (0x87D1041C)",
|
||||
"description": "Detects when Intune's detection check right after an app's installer finished did not find the app, so Intune reports the install as failed (0x87D1041C).",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.3.0",
|
||||
"version": "2.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"required": true,
|
||||
"suppressByEvent": {
|
||||
"eventType": "app_install_completed",
|
||||
"joinField": "appId"
|
||||
}
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "A Win32 app detection script failed or did not detect the app after installation. This causes the app to appear as 'not installed' despite successful installation. Detection failures that were later resolved by a successful install of the same app are suppressed and do not raise this finding.",
|
||||
"explanation": "The Intune Management Extension runs an app's detection twice: before the install, to decide whether the install is needed, and right after the installer finishes. Here the installer finished, but the check afterwards did not find the app. Intune therefore reports the install as failed with **0x87D1041C** (*application not detected after installation completed successfully*), even though the installer itself succeeded.\n\nFor Win32 apps the detection rule does not match what the installer left on the device. Common causes:\n\n- The rule checks a path or registry key the installer does not create (wrong path, version folder or bitness, e.g. HKLM\\SOFTWARE\\... versus HKLM\\SOFTWARE\\WOW6432Node\\...).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after the check has run.\n- A detection script exits 0 without writing to stdout, which Intune treats as not detected.\n- The installer writes to the user profile while the rule looks in system scope, or the other way round.\n\nMicrosoft Store (WinGet) apps have no detection rule to edit: Intune asks WinGet whether the package is installed in the app's install context.\n\nWhen the installer requests a restart with its return code, Intune checks again after the restart; this finding then means the app was still missing afterwards.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Fix detection rules",
|
||||
"title": "Check the detection rule against an installed device",
|
||||
"steps": [
|
||||
"Review the app's detection rules in Intune",
|
||||
"Verify file/registry paths are correct",
|
||||
"Test detection script manually on a device"
|
||||
"Open the app in Intune → Properties → Detection rules and note exactly what the rule checks",
|
||||
"Install the app on a test device outside Autopilot and confirm that the file, registry key or script result exists right after the installer exits",
|
||||
"For registry rules, check both HKLM\\SOFTWARE\\... and HKLM\\SOFTWARE\\WOW6432Node\\... depending on the installer's bitness",
|
||||
"For script rules, run the script as SYSTEM: it must write at least one line to stdout and exit 0 when the app is present",
|
||||
"If the installer completes its work later, detect something it writes synchronously, such as the MSI product code under HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Store (WinGet) apps",
|
||||
"steps": [
|
||||
"Check the package identifier and the install behavior (System or User) of the app in Intune",
|
||||
"Install the package manually in the same context on a test device and confirm it is reported as installed"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Win32 app detection rules in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its install",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its installer finished",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{detection_failure.appId}}",
|
||||
"name": "{{detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{detection_failure.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{detection_failure.errorCode}}",
|
||||
@@ -61,6 +68,8 @@
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
"win32"
|
||||
"win32",
|
||||
"winget",
|
||||
"0x87d1041c"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-010",
|
||||
"version": "1.1.0",
|
||||
"title": "App Reverted to Error After Successful Install",
|
||||
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "app_regression",
|
||||
"source": "event_correlation",
|
||||
"eventType": "app_install_completed",
|
||||
"correlateEventType": "app_install_failed",
|
||||
"joinField": "appId",
|
||||
"dataField": "errorPatternId",
|
||||
"operator": "equals",
|
||||
"value": "IME-ERROR-REPORT",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_succeeded",
|
||||
"source": "event_type",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "An app was initially reported as **successfully installed** (`app_install_completed`) but the IME (Intune Management Extension) subsequently reported an **Error** state for the same app via an `IME-ERROR-REPORT` event — while the overall enrollment completed successfully.\n\nThis pattern occurs when the IME re-evaluates enforcement state after the initial install report and detects a failure, e.g. `EnforcementState` transitions from `Success` → `Error`. The affected app and its details are captured in the matched evidence.\n\nCommon causes:\n- A post-install detection check fails (detection rule mismatch)\n- The enforcement error code `-2016345060` (0x87D13B9C) indicates the app state diverged from expected\n- The app may appear installed in the ESP summary but is in an error state on the device\n\n**The enrollment succeeded, but the affected app requires attention.**",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the app's post-install enforcement state",
|
||||
"steps": [
|
||||
"Note the appId and appName from the rule finding — these identify the exact affected app",
|
||||
"Check the Intune portal: Devices > [Device] > Apps — confirm the current enforcement state of the app",
|
||||
"Review IME logs on the device: C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log",
|
||||
"Search the log for the appId and look for 'EnforcementState' transitions (Success → Error) near the enrollment time",
|
||||
"Error -2016345060 (0x87D13B9C) typically indicates a detection failure after install — verify the app's detection rule in Intune",
|
||||
"Trigger a manual sync (Company Portal or Settings > Accounts > Access work or school > Sync) to force re-evaluation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix detection or installation issues",
|
||||
"steps": [
|
||||
"If detection fails: correct the detection rule (file path, registry key, or PowerShell script) in the Intune app configuration",
|
||||
"If the app itself is broken: redeploy or reassign the app to trigger a fresh install",
|
||||
"Consider adding a post-install validation script to catch this scenario proactively"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{ "title": "Troubleshoot Win32 app installations", "url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install" },
|
||||
{ "title": "Intune Management Extension logs", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension" }
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{app_regression.joinValue}}",
|
||||
"name": "{{app_regression.eventA_appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Installed",
|
||||
"value": "{{app_regression.eventA_timestamp}}",
|
||||
"format": "timestamp"
|
||||
},
|
||||
{
|
||||
"label": "Error after",
|
||||
"value": "{{app_regression.timeDeltaSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{app_regression.eventB_errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["apps", "win32", "enforcement", "post-install", "correlation"]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -27,9 +27,9 @@
|
||||
"signal": "app_with_detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -42,7 +42,7 @@
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "App **{{appName}}** failed during the Enrollment Status Page with HRESULT **{{errorCode}}** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer writes the detection artefact AFTER the ESP's detection probe runs (e.g. async post-install task, requires reboot).\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"explanation": "App **{{app_with_detection_failure.appName}}** failed during the Enrollment Status Page with HRESULT **0x87D1041C** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after Intune's check has run.\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Validate the detection rule against an installed device",
|
||||
@@ -66,7 +66,7 @@
|
||||
{
|
||||
"title": "Cross-check the agent's per-app event",
|
||||
"steps": [
|
||||
"Look at the app_install_failed event in this session for DataJson.detectionResult — NotDetected confirms Intune's detection probe ran and returned negative",
|
||||
"Look at the app_install_failed event of this app: errorCode 0x87D1041C (the IME log writes it as -2016345060) is Intune's own verdict that its check right after the installer did not find the app",
|
||||
"Look at app_install_failed.bytesDownloaded vs bytesTotal — if they match, the package downloaded fully (install error vs. download error)",
|
||||
"Cross-reference DataJson.exitCode and hresultFromWin32 — these come from the installer itself and may explain why the detection artefact was not written"
|
||||
]
|
||||
@@ -95,10 +95,6 @@
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{app_with_detection_failure.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+38
-131
@@ -3,56 +3,63 @@
|
||||
"rules": [
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-001",
|
||||
"title": "Win32 App Detection Script Failure",
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"title": "App Not Detected After Install (0x87D1041C)",
|
||||
"description": "Detects when Intune's detection check right after an app's installer finished did not find the app, so Intune reports the install as failed (0x87D1041C).",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.3.0",
|
||||
"version": "2.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"required": true,
|
||||
"suppressByEvent": {
|
||||
"eventType": "app_install_completed",
|
||||
"joinField": "appId"
|
||||
}
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "A Win32 app detection script failed or did not detect the app after installation. This causes the app to appear as 'not installed' despite successful installation. Detection failures that were later resolved by a successful install of the same app are suppressed and do not raise this finding.",
|
||||
"explanation": "The Intune Management Extension runs an app's detection twice: before the install, to decide whether the install is needed, and right after the installer finishes. Here the installer finished, but the check afterwards did not find the app. Intune therefore reports the install as failed with **0x87D1041C** (*application not detected after installation completed successfully*), even though the installer itself succeeded.\n\nFor Win32 apps the detection rule does not match what the installer left on the device. Common causes:\n\n- The rule checks a path or registry key the installer does not create (wrong path, version folder or bitness, e.g. HKLM\\SOFTWARE\\... versus HKLM\\SOFTWARE\\WOW6432Node\\...).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after the check has run.\n- A detection script exits 0 without writing to stdout, which Intune treats as not detected.\n- The installer writes to the user profile while the rule looks in system scope, or the other way round.\n\nMicrosoft Store (WinGet) apps have no detection rule to edit: Intune asks WinGet whether the package is installed in the app's install context.\n\nWhen the installer requests a restart with its return code, Intune checks again after the restart; this finding then means the app was still missing afterwards.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Fix detection rules",
|
||||
"title": "Check the detection rule against an installed device",
|
||||
"steps": [
|
||||
"Review the app's detection rules in Intune",
|
||||
"Verify file/registry paths are correct",
|
||||
"Test detection script manually on a device"
|
||||
"Open the app in Intune → Properties → Detection rules and note exactly what the rule checks",
|
||||
"Install the app on a test device outside Autopilot and confirm that the file, registry key or script result exists right after the installer exits",
|
||||
"For registry rules, check both HKLM\\SOFTWARE\\... and HKLM\\SOFTWARE\\WOW6432Node\\... depending on the installer's bitness",
|
||||
"For script rules, run the script as SYSTEM: it must write at least one line to stdout and exit 0 when the app is present",
|
||||
"If the installer completes its work later, detect something it writes synchronously, such as the MSI product code under HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Store (WinGet) apps",
|
||||
"steps": [
|
||||
"Check the package identifier and the install behavior (System or User) of the app in Intune",
|
||||
"Install the package manually in the same context on a test device and confirm it is reported as installed"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Win32 app detection rules in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its install",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its installer finished",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{detection_failure.appId}}",
|
||||
"name": "{{detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{detection_failure.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{detection_failure.errorCode}}",
|
||||
@@ -63,7 +70,9 @@
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
"win32"
|
||||
"win32",
|
||||
"winget",
|
||||
"0x87d1041c"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -541,104 +550,6 @@
|
||||
],
|
||||
"version": "1.2.0"
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-010",
|
||||
"version": "1.1.0",
|
||||
"title": "App Reverted to Error After Successful Install",
|
||||
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "app_regression",
|
||||
"source": "event_correlation",
|
||||
"eventType": "app_install_completed",
|
||||
"correlateEventType": "app_install_failed",
|
||||
"joinField": "appId",
|
||||
"dataField": "errorPatternId",
|
||||
"operator": "equals",
|
||||
"value": "IME-ERROR-REPORT",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_succeeded",
|
||||
"source": "event_type",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "An app was initially reported as **successfully installed** (`app_install_completed`) but the IME (Intune Management Extension) subsequently reported an **Error** state for the same app via an `IME-ERROR-REPORT` event — while the overall enrollment completed successfully.\n\nThis pattern occurs when the IME re-evaluates enforcement state after the initial install report and detects a failure, e.g. `EnforcementState` transitions from `Success` → `Error`. The affected app and its details are captured in the matched evidence.\n\nCommon causes:\n- A post-install detection check fails (detection rule mismatch)\n- The enforcement error code `-2016345060` (0x87D13B9C) indicates the app state diverged from expected\n- The app may appear installed in the ESP summary but is in an error state on the device\n\n**The enrollment succeeded, but the affected app requires attention.**",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the app's post-install enforcement state",
|
||||
"steps": [
|
||||
"Note the appId and appName from the rule finding — these identify the exact affected app",
|
||||
"Check the Intune portal: Devices > [Device] > Apps — confirm the current enforcement state of the app",
|
||||
"Review IME logs on the device: C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log",
|
||||
"Search the log for the appId and look for 'EnforcementState' transitions (Success → Error) near the enrollment time",
|
||||
"Error -2016345060 (0x87D13B9C) typically indicates a detection failure after install — verify the app's detection rule in Intune",
|
||||
"Trigger a manual sync (Company Portal or Settings > Accounts > Access work or school > Sync) to force re-evaluation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix detection or installation issues",
|
||||
"steps": [
|
||||
"If detection fails: correct the detection rule (file path, registry key, or PowerShell script) in the Intune app configuration",
|
||||
"If the app itself is broken: redeploy or reassign the app to trigger a fresh install",
|
||||
"Consider adding a post-install validation script to catch this scenario proactively"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Troubleshoot Win32 app installations",
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
|
||||
},
|
||||
{
|
||||
"title": "Intune Management Extension logs",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{app_regression.joinValue}}",
|
||||
"name": "{{app_regression.eventA_appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Installed",
|
||||
"value": "{{app_regression.eventA_timestamp}}",
|
||||
"format": "timestamp"
|
||||
},
|
||||
{
|
||||
"label": "Error after",
|
||||
"value": "{{app_regression.timeDeltaSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{app_regression.eventB_errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"win32",
|
||||
"enforcement",
|
||||
"post-install",
|
||||
"correlation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-012",
|
||||
"title": "Enforcement Error Resolved by Successful Detection",
|
||||
@@ -724,7 +635,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -746,9 +657,9 @@
|
||||
"signal": "app_with_detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -761,7 +672,7 @@
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "App **{{appName}}** failed during the Enrollment Status Page with HRESULT **{{errorCode}}** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer writes the detection artefact AFTER the ESP's detection probe runs (e.g. async post-install task, requires reboot).\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"explanation": "App **{{app_with_detection_failure.appName}}** failed during the Enrollment Status Page with HRESULT **0x87D1041C** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after Intune's check has run.\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Validate the detection rule against an installed device",
|
||||
@@ -785,7 +696,7 @@
|
||||
{
|
||||
"title": "Cross-check the agent's per-app event",
|
||||
"steps": [
|
||||
"Look at the app_install_failed event in this session for DataJson.detectionResult — NotDetected confirms Intune's detection probe ran and returned negative",
|
||||
"Look at the app_install_failed event of this app: errorCode 0x87D1041C (the IME log writes it as -2016345060) is Intune's own verdict that its check right after the installer did not find the app",
|
||||
"Look at app_install_failed.bytesDownloaded vs bytesTotal — if they match, the package downloaded fully (install error vs. download error)",
|
||||
"Cross-reference DataJson.exitCode and hresultFromWin32 — these come from the installer itself and may explain why the detection artefact was not written"
|
||||
]
|
||||
@@ -814,10 +725,6 @@
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Detection",
|
||||
"value": "{{app_with_detection_failure.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+22
-3
@@ -195,7 +195,7 @@
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the result of every IME detection run for an app: the check before the install (NotDetected is what starts the install) and the check right after it. App events therefore carry the check before the install; IME's verdict after the install is the enforcement error 0x87D1041C on failure. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -285,12 +285,12 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}(?:\\},\"{1,2}EnforcementErrorCode\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:-?\\d+|null),\"{1,2}NewValue\"{1,2}:(?<errorCode>-?\\d+))?",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both. The optional errorCode group takes EnforcementErrorCode.NewValue, which IME logs as a signed decimal right after the state object (e.g. -2016345060 = 0x87D1041C: IME's check after the install did not find the app); the code reaches app_install_failed only when this line is the app's first move to Error.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -487,6 +487,25 @@
|
||||
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-POST-INSTALL-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Policy with id: {GUID} is expected to have enforcement state: Detected\\.",
|
||||
"action": "markPostInstallDetection",
|
||||
"description": "Marks IME's check right after an install. IME logs this line only for the app it has just installed, before that check's result line; the agent reports that result (IME-DETECTION-RESULT) as app_post_install_detection and keeps detectionResult as the check before the install. Other apps of the same dependency chain are checked in the same run without this line. A failed check is IME's error 0x87D1041C (IME-ERROR-REPORT).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-POST-REBOOT-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgressPendingReboot\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "markPostInstallDetection",
|
||||
"parameters": {
|
||||
"afterReboot": "true"
|
||||
},
|
||||
"description": "Marks IME's check after the restart an installer requested. After an exit code mapped to SoftReboot or HardReboot the check right after the install decides nothing; after the restart IME moves the app from InProgressPendingReboot to Success (held back like after a clean install) and checks again without the IME-POST-INSTALL-DETECTION line. That check's result is reported as app_post_install_detection with afterReboot=true. Quote-flexible like IME-ENFORCEMENT-SUCCESS.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
|
||||
@@ -292,6 +292,7 @@
|
||||
"app_install_completed",
|
||||
"app_install_failed",
|
||||
"app_install_skipped",
|
||||
"app_post_install_detection",
|
||||
"app_download_started",
|
||||
"app_tracking_summary",
|
||||
"download_progress",
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the result of every IME detection run for an app: the check before the install (NotDetected is what starts the install) and the check right after it. App events therefore carry the check before the install; IME's verdict after the install is the enforcement error 0x87D1041C on failure. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}(?:\\},\"{1,2}EnforcementErrorCode\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:-?\\d+|null),\"{1,2}NewValue\"{1,2}:(?<errorCode>-?\\d+))?",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both. The optional errorCode group takes EnforcementErrorCode.NewValue, which IME logs as a signed decimal right after the state object (e.g. -2016345060 = 0x87D1041C: IME's check after the install did not find the app); the code reaches app_install_failed only when this line is the app's first move to Error.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-POST-INSTALL-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Policy with id: {GUID} is expected to have enforcement state: Detected\\.",
|
||||
"action": "markPostInstallDetection",
|
||||
"description": "Marks IME's check right after an install. IME logs this line only for the app it has just installed, before that check's result line; the agent reports that result (IME-DETECTION-RESULT) as app_post_install_detection and keeps detectionResult as the check before the install. Other apps of the same dependency chain are checked in the same run without this line. A failed check is IME's error 0x87D1041C (IME-ERROR-REPORT).",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-POST-REBOOT-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgressPendingReboot\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "markPostInstallDetection",
|
||||
"parameters": {
|
||||
"afterReboot": "true"
|
||||
},
|
||||
"description": "Marks IME's check after the restart an installer requested. After an exit code mapped to SoftReboot or HardReboot the check right after the install decides nothing; after the restart IME moves the app from InProgressPendingReboot to Success (held back like after a clean install) and checks again without the IME-POST-INSTALL-DETECTION line. That check's result is reported as app_post_install_detection with afterReboot=true. Quote-flexible like IME-ENFORCEMENT-SUCCESS.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -82,7 +82,8 @@
|
||||
"captureAppTypeMsi",
|
||||
"captureAttemptNumber",
|
||||
"captureDetectionResult",
|
||||
"captureExitCodeClass"
|
||||
"captureExitCodeClass",
|
||||
"markPostInstallDetection"
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
Generated
+3
-3
@@ -53,9 +53,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// An <see cref="ImeLogTracker"/> fed the shipped IME pattern pack (<c>rules/ime-log-patterns</c>,
|
||||
/// sorted as combine.js embeds it) and a CMTrace <c>IntuneManagementExtension.log</c> the test
|
||||
/// appends to, so a test drives the real patterns end to end.
|
||||
/// </summary>
|
||||
internal sealed class ShippedImePackHarness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
|
||||
public ImeLogTracker Tracker { get; }
|
||||
public string StateDir { get; }
|
||||
public DateTime Now { get; } = new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public ShippedImePackHarness()
|
||||
{
|
||||
StateDir = Path.Combine(_tmp.Path, "State");
|
||||
Tracker = Build();
|
||||
}
|
||||
|
||||
public static List<ImeLogPattern> Pack()
|
||||
=> Directory.GetFiles(Path.Combine(RepoPaths.Root(), "rules", "ime-log-patterns"), "*.json")
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.Select(f => JsonConvert.DeserializeObject<ImeLogPattern>(File.ReadAllText(f))!)
|
||||
.ToList();
|
||||
|
||||
/// <summary>A second tracker on the same log and state folder (an agent restart).</summary>
|
||||
public ImeLogTracker Build()
|
||||
{
|
||||
var tracker = new ImeLogTracker(
|
||||
logFolder: _tmp.Path,
|
||||
patterns: Pack(),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
stateDirectory: StateDir);
|
||||
// The historic-replay guard skips app-mutating actions for lines older than 24 h
|
||||
// relative to "now": pin both so the appended entries are always fresh.
|
||||
tracker.UtcNowProvider = () => Now;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
public void Append(params string[] messages) => AppendAt(Now.AddSeconds(-60), messages);
|
||||
|
||||
/// <summary>Entries one second apart from <paramref name="firstUtc"/> (an older instant replays a previous enrollment).</summary>
|
||||
public void AppendAt(DateTime firstUtc, params string[] messages)
|
||||
{
|
||||
var lines = messages.Select((m, i) => Entry(m, firstUtc.AddSeconds(i)));
|
||||
File.AppendAllText(Path.Combine(_tmp.Path, "IntuneManagementExtension.log"),
|
||||
string.Join(Environment.NewLine, lines) + Environment.NewLine);
|
||||
}
|
||||
|
||||
public Task Pass() => Tracker.CheckLogFilesAsync(CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant)
|
||||
{
|
||||
// CMTrace line with a zero writer offset: the tracker reads the local time and
|
||||
// resolves it against the reader zone; only freshness (< 24 h) matters here.
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{utcInstant:HH:mm:ss.fffffff}\" date=\"{utcInstant:M-d-yyyy}\" " +
|
||||
"component=\"IntuneManagementExtension\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -270,6 +270,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
new[] { "id=" + Guid1, "to=InProgress" }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-ERROR-REPORT",
|
||||
"[Win32App][ReportingManager] Execution state for app with id: " + Guid1 + " has been updated. Report delta: {\"EnforcementState\":{\"OldValue\":\"Success\",\"NewValue\":\"Error\"},\"EnforcementErrorCode\":{\"OldValue\":null,\"NewValue\":-2016345060}}",
|
||||
new[] { "id=" + Guid1, "from=Success", "to=Error", "errorCode=-2016345060" }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-POST-INSTALL-DETECTION",
|
||||
"[Win32App][DetectionActionHandler] Policy with id: " + Guid1 + " is expected to have enforcement state: Detected.",
|
||||
new[] { "id=" + Guid1 }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-POST-REBOOT-DETECTION",
|
||||
"[Win32App][ReportingManager] Execution state for app with id: " + Guid1 + " has been updated. Report delta: {\"EnforcementState\":{\"OldValue\":\"InProgressPendingReboot\",\"NewValue\":\"Success\"}}",
|
||||
new[] { "id=" + Guid1 }
|
||||
};
|
||||
yield return new object[]
|
||||
{
|
||||
"IME-TOKEN-FAILURE",
|
||||
"Failed to get AAD token. len = 34 using client id fc0f3af4-6835-4174-b806-f7db311fd2f3 and resource id 26a4ae64-5862-427f-a9b0-044e62572a4f, errorCode = 3399548929",
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// IME's sequence per app, driven through the shipped pattern pack: the check before the
|
||||
/// install, the installer, "Execution state … Success", the check right after the install,
|
||||
/// and the final state (D-392).
|
||||
/// <list type="bullet">
|
||||
/// <item>When the check after the install does not find the app, IME flips Success → Error
|
||||
/// with EnforcementErrorCode -2016345060 (0x87D1041C) BEFORE it logs the check's
|
||||
/// result line; that code is IME's verdict and must reach the error transition.</item>
|
||||
/// <item>The check after the install is marked ("expected to have enforcement state:
|
||||
/// Detected"); its result is reported through OnPostInstallDetection, and
|
||||
/// detectionResult stays the check before the install.</item>
|
||||
/// <item>After an exit code that asked for a restart, that check decides nothing; the check
|
||||
/// after the restart (InProgressPendingReboot → Success) is the one reported.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerDetectionAfterInstallTests
|
||||
{
|
||||
private const string AppId = "5c95bf94-1cf4-4629-88d1-3f616e7a405c";
|
||||
private const string DependencyId = "9d2b8a71-4e0c-4f5a-a3c6-71b0e2d5c8f4";
|
||||
|
||||
private static string PhaseLine => "[Win32App] In EspPhase: DeviceSetup.";
|
||||
private static string CurrentAppLine => @"[Win32App] SetCurrentDirectory: C:\Windows\IMECache\" + AppId + "_1";
|
||||
|
||||
private static string DetectionResult(string state, string appId = AppId)
|
||||
=> "[Win32App][DetectionActionHandler] Detection for policy with id: " + appId +
|
||||
" resulted in action status: Success and detection state: " + state + ".";
|
||||
|
||||
private static string[] CheckAfterInstallMarkers() => new[]
|
||||
{
|
||||
"[Win32App][DetectionActionHandler] Detection running for policy with id: " + AppId + ".",
|
||||
"[Win32App][DetectionActionHandler] The last enforcement action was on policy with id: " + AppId + ".",
|
||||
"[Win32App][DetectionActionHandler] Policy with id: " + AppId + " is expected to have enforcement state: Detected.",
|
||||
};
|
||||
|
||||
private static string ExecutionDelta(string from, string to, string? errorCode = null)
|
||||
=> "[Win32App][ReportingManager] Execution state for app with id: " + AppId + " has been updated. Report delta: " +
|
||||
"{\"EnforcementState\":{\"OldValue\":\"" + from + "\",\"NewValue\":\"" + to + "\"}" +
|
||||
(errorCode == null ? "" : ",\"EnforcementErrorCode\":{\"OldValue\":null,\"NewValue\":" + errorCode + "}") + "}";
|
||||
|
||||
private sealed class Run
|
||||
{
|
||||
public ShippedImePackHarness Harness { get; } = new ShippedImePackHarness();
|
||||
public AppPackageState Package { get; } = new AppPackageState(AppId, 0);
|
||||
public List<(AppInstallationState To, string? Code)> Transitions { get; } = new List<(AppInstallationState, string?)>();
|
||||
public List<(string Detection, bool AfterReboot)> ChecksAfterInstall { get; } = new List<(string, bool)>();
|
||||
|
||||
public Run()
|
||||
{
|
||||
Package.UpdateState(AppInstallationState.Installing);
|
||||
Harness.Tracker.PackageStates.Add(Package);
|
||||
Harness.Tracker.OnAppStateChanged = (p, _, to) => { if (p.Id == AppId) Transitions.Add((to, p.ErrorCode)); };
|
||||
Harness.Tracker.OnPostInstallDetection = (p, detection, afterReboot, _) => ChecksAfterInstall.Add((detection, afterReboot));
|
||||
}
|
||||
|
||||
public void Lines(params string[] lines) => Harness.Append(lines);
|
||||
}
|
||||
|
||||
private static string[] CleanInstallThen(params string[] tail)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
PhaseLine,
|
||||
DetectionResult("NotDetected"), // the check before the install
|
||||
ExecutionDelta("InProgress", "Success"), // IME holds this back until its check
|
||||
};
|
||||
lines.AddRange(CheckAfterInstallMarkers());
|
||||
lines.AddRange(tail);
|
||||
return lines.ToArray();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_after_install_failing_carries_IMEs_code_on_the_error_transition()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(CleanInstallThen(
|
||||
ExecutionDelta("Success", "Error", "-2016345060"),
|
||||
DetectionResult("NotDetected"))); // the check's own result line comes last
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(new[] { AppInstallationState.Installed, AppInstallationState.Error }, run.Transitions.Select(t => t.To));
|
||||
Assert.Equal("-2016345060", run.Transitions.Last().Code);
|
||||
Assert.Equal(AppInstallationState.Error, run.Package.InstallationState);
|
||||
Assert.Equal("IME-ERROR-REPORT", run.Package.ErrorPatternId);
|
||||
Assert.Equal(new[] { ("NotDetected", false) }, run.ChecksAfterInstall);
|
||||
Assert.Equal("NotDetected", run.Package.DetectionResult);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_after_a_clean_install_is_reported_and_detectionResult_stays_the_check_before()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(CleanInstallThen(DetectionResult("Detected")));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(new[] { ("Detected", false) }, run.ChecksAfterInstall);
|
||||
Assert.Equal("NotDetected", run.Package.DetectionResult);
|
||||
Assert.Equal(AppInstallationState.Installed, run.Package.InstallationState);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_different_version_found_after_the_install_is_reported_as_such()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(CleanInstallThen(DetectionResult("DetectedDifferentVersion")));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(new[] { ("DetectedDifferentVersion", false) }, run.ChecksAfterInstall);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_dependency_checked_in_the_same_run_is_no_check_after_its_install()
|
||||
{
|
||||
// IME checks every app of the dependency chain in the run after an install; only the
|
||||
// installed app carries the markers.
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
var dependency = new AppPackageState(DependencyId, 1);
|
||||
run.Harness.Tracker.PackageStates.Add(dependency);
|
||||
run.Lines(CleanInstallThen(
|
||||
DetectionResult("Detected", DependencyId),
|
||||
DetectionResult("Detected")));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(new[] { ("Detected", false) }, run.ChecksAfterInstall);
|
||||
Assert.Equal("Detected", dependency.DetectionResult);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_check_before_a_requested_restart_is_not_reported_the_one_after_it_is()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
var beforeRestart = new List<string>
|
||||
{
|
||||
PhaseLine,
|
||||
CurrentAppLine,
|
||||
"[Win32App] lpExitCode 3010",
|
||||
"[Win32App] lpExitCode is defined as SoftReboot",
|
||||
ExecutionDelta("InProgress", "InProgressPendingReboot"),
|
||||
};
|
||||
beforeRestart.AddRange(CheckAfterInstallMarkers());
|
||||
beforeRestart.Add(DetectionResult("NotDetected")); // provisional: IME sets no error
|
||||
run.Lines(beforeRestart.ToArray());
|
||||
|
||||
await run.Harness.Pass();
|
||||
Assert.Empty(run.ChecksAfterInstall);
|
||||
|
||||
run.Lines(
|
||||
ExecutionDelta("InProgressPendingReboot", "Success"), // after the restart
|
||||
DetectionResult("Detected"));
|
||||
|
||||
await run.Harness.Pass();
|
||||
Assert.Equal(new[] { ("Detected", true) }, run.ChecksAfterInstall);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lines_from_a_previous_enrollment_mark_nothing()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Harness.AppendAt(run.Harness.Now.AddDays(-2), CleanInstallThen(DetectionResult("Detected")));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Empty(run.ChecksAfterInstall);
|
||||
Assert.Null(run.Package.DetectionResult);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Other_IME_error_reports_carry_their_code_too()
|
||||
{
|
||||
// A download that fails is the app's first move to Error through the same line.
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(PhaseLine, ExecutionDelta("InProgressDownloadCompleted", "Error", "-2145091577"));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(AppInstallationState.Error, Assert.Single(run.Transitions).To);
|
||||
Assert.Equal("-2145091577", run.Package.ErrorCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Error_report_without_a_code_leaves_the_code_empty()
|
||||
{
|
||||
var run = new Run();
|
||||
using (run.Harness)
|
||||
{
|
||||
run.Lines(PhaseLine, ExecutionDelta("InProgress", "Error"));
|
||||
|
||||
await run.Harness.Pass();
|
||||
|
||||
Assert.Equal(AppInstallationState.Error, run.Package.InstallationState);
|
||||
Assert.True(string.IsNullOrEmpty(run.Package.ErrorCode));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-79
@@ -1,15 +1,10 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
@@ -70,82 +65,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
// ---------------------------------------------------------------- shipped pack, end to end
|
||||
|
||||
private static string FindRulesPatternDir()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "rules", "ime-log-patterns");
|
||||
if (Directory.Exists(candidate)) return candidate;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException($"Could not locate rules/ime-log-patterns walking up from {AppContext.BaseDirectory}");
|
||||
}
|
||||
|
||||
/// <summary>The shipped pack, exactly as combine.js embeds it (sorted by file name).</summary>
|
||||
private static List<ImeLogPattern> ShippedPack()
|
||||
=> Directory.GetFiles(FindRulesPatternDir(), "*.json")
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.Select(f => JsonConvert.DeserializeObject<ImeLogPattern>(File.ReadAllText(f))!)
|
||||
.ToList();
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant)
|
||||
{
|
||||
// CMTrace line with a zero writer offset: the tracker reads the local time and
|
||||
// resolves it against the reader zone; only freshness (< 24 h) matters here.
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{utcInstant:HH:mm:ss.fffffff}\" date=\"{utcInstant:M-d-yyyy}\" " +
|
||||
"component=\"IntuneManagementExtension\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public ImeLogTracker Tracker { get; }
|
||||
public string StateDir { get; }
|
||||
public DateTime Now { get; } = new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public Harness()
|
||||
{
|
||||
StateDir = Path.Combine(_tmp.Path, "State");
|
||||
Tracker = Build();
|
||||
}
|
||||
|
||||
public ImeLogTracker Build()
|
||||
{
|
||||
var tracker = new ImeLogTracker(
|
||||
logFolder: _tmp.Path,
|
||||
patterns: ShippedPack(),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
stateDirectory: StateDir);
|
||||
// The historic-replay guard skips app-mutating actions for lines older than 24 h
|
||||
// relative to "now": pin both so the CMTrace entries below are always fresh.
|
||||
tracker.UtcNowProvider = () => Now;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
public void Append(params string[] messages)
|
||||
{
|
||||
var lines = messages.Select((m, i) => Entry(m, Now.AddSeconds(-60 + i)));
|
||||
File.AppendAllText(Path.Combine(_tmp.Path, "IntuneManagementExtension.log"),
|
||||
string.Join(Environment.NewLine, lines) + Environment.NewLine);
|
||||
}
|
||||
|
||||
public Task Pass() => Tracker.CheckLogFilesAsync(CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string PhaseLine => "[Win32App] In EspPhase: DeviceSetup.";
|
||||
private static string CurrentAppLine => @"[Win32App] SetCurrentDirectory: C:\Windows\IMECache\" + AppId + "_1";
|
||||
|
||||
[Fact]
|
||||
public async Task Shipped_pattern_captures_the_class_for_the_current_app()
|
||||
{
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, // activates the currentPhase pack (IME-EXITCODE, IME-EXITCODE-CLASS)
|
||||
@@ -165,7 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
[Fact]
|
||||
public async Task Retry_loop_that_ends_in_success_reports_the_last_class()
|
||||
{
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
@@ -188,7 +114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
// IME-EXITCODE-CLASS and IME-EXITCODE-MAPPED-FAILED both match the Failed line: the
|
||||
// class is captured and the existing error path stays intact.
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
var pkg = new AppPackageState(AppId, 0);
|
||||
pkg.UpdateState(AppInstallationState.Installing);
|
||||
h.Tracker.PackageStates.Add(pkg);
|
||||
@@ -207,7 +133,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
[Fact]
|
||||
public async Task Default_branch_and_raw_numeric_types_leave_the_class_unset()
|
||||
{
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
@@ -228,7 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
// A SoftReboot/HardReboot app completes only after the reboot; the restarted agent
|
||||
// must still know the class when the post-reboot Success line arrives.
|
||||
using var h = new Harness();
|
||||
using var h = new ShippedImePackHarness();
|
||||
h.Tracker.PackageStates.Add(new AppPackageState(AppId, 0));
|
||||
h.Append(
|
||||
PhaseLine, CurrentAppLine,
|
||||
|
||||
+15
@@ -92,6 +92,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stale_post_install_detection_is_suppressed_fresh_emits()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-check", 0);
|
||||
|
||||
adapter.TriggerPostInstallDetectionFromTest(app, "Detected", afterReboot: false, ClockNow.AddDays(-7));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
|
||||
|
||||
adapter.TriggerPostInstallDetectionFromTest(app, "Detected", afterReboot: false, ClockNow.AddMinutes(-1));
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
|
||||
{
|
||||
|
||||
+44
@@ -663,6 +663,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("00:01:45.159", info.Payload["doDownloadDuration"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostInstallDetection_emits_the_check_after_the_install_as_its_own_info_event()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = AppPackageState.Restore(
|
||||
"0e3be4ce-790b-4081-831c-d72d92f3cc9b", 0, "Contoso VPN",
|
||||
AppRunAs.System, AppIntent.Install, AppTargeted.Device,
|
||||
new HashSet<string>(),
|
||||
AppInstallationState.Installed, true, 100, 0, 0,
|
||||
exitCode: "0", exitCodeClass: "Success");
|
||||
|
||||
adapter.TriggerPostInstallDetectionFromTest(app, "Detected", afterReboot: false, f.Clock.UtcNow.AddSeconds(-5));
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
|
||||
Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]);
|
||||
Assert.Equal(EventSeverity.Info.ToString(), info.Payload[SignalPayloadKeys.Severity]);
|
||||
Assert.Equal(app.Id, info.Payload["appId"]);
|
||||
Assert.Equal("Contoso VPN", info.Payload["appName"]);
|
||||
Assert.Equal("Detected", info.Payload["postInstallDetection"]);
|
||||
Assert.Equal("false", info.Payload["afterReboot"]);
|
||||
Assert.Equal("Success", info.Payload["exitCodeClass"]);
|
||||
// detectionResult is the check before the install; this event never carries it.
|
||||
Assert.False(info.Payload.ContainsKey("detectionResult"));
|
||||
// Informational only: no app state signal.
|
||||
Assert.Empty(f.DecisionSignals(DecisionSignalKind.AppInstallCompleted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PostInstallDetection_not_detected_is_a_warning_and_the_check_after_a_restart_says_so()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("8a1c2f6e-5b7d-4e93-9c4a-0f2d6b8e1a37", 0);
|
||||
|
||||
adapter.TriggerPostInstallDetectionFromTest(app, "NotDetected", afterReboot: true, f.Clock.UtcNow.AddSeconds(-5));
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppPostInstallDetection));
|
||||
Assert.Equal(EventSeverity.Warning.ToString(), info.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("NotDetected", info.Payload["postInstallDetection"]);
|
||||
Assert.Equal("true", info.Payload["afterReboot"]);
|
||||
Assert.Contains("after the restart", info.Payload[SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoTelemetry_null_app_or_empty_id_is_ignored()
|
||||
{
|
||||
|
||||
+5
-1
@@ -114,7 +114,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public string AppType { get; private set; }
|
||||
/// <summary>Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle.</summary>
|
||||
public int AttemptNumber { get; private set; }
|
||||
/// <summary>Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines.</summary>
|
||||
/// <summary>
|
||||
/// IME's detection check outside the check right after an install: Detected, NotDetected or
|
||||
/// DetectedDifferentVersion — for an app being installed, the check before the install. The
|
||||
/// check after the install is reported through OnPostInstallDetection instead (D-392).
|
||||
/// </summary>
|
||||
public string DetectionResult { get; private set; }
|
||||
|
||||
// Delivery Optimization telemetry (populated from [DO TEL] log entries OR Get-DeliveryOptimizationStatus poll)
|
||||
|
||||
+22
@@ -117,6 +117,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of IME's check right after an install (D-392). After an exit code that asked for a
|
||||
/// restart that check decides nothing: IME sets no error then and checks again after the
|
||||
/// restart (IME-POST-REBOOT-DETECTION marks that check), so it is not reported.
|
||||
/// </summary>
|
||||
private void HandlePostInstallDetection(string appId, string detection, bool afterReboot)
|
||||
{
|
||||
var pkg = _packageStates.GetPackage(appId);
|
||||
if (pkg == null) return;
|
||||
if (!afterReboot
|
||||
&& (string.Equals(pkg.ExitCodeClass, "SoftReboot", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(pkg.ExitCodeClass, "HardReboot", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: check after the install of {pkg.Name ?? appId} precedes the requested restart ({detection}), IME decides after it");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"ImeLogTracker: check after the install of {pkg.Name ?? appId}: {detection}{(afterReboot ? " (after the restart)" : string.Empty)}");
|
||||
try { OnPostInstallDetection?.Invoke(pkg, detection, afterReboot, LastMatchedLogTimestamp); }
|
||||
catch (Exception ex) { _logger.Warning($"OnPostInstallDetection handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void HandleDoTelemetry(string json)
|
||||
{
|
||||
try
|
||||
|
||||
+20
-2
@@ -516,7 +516,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"updatestateskipped", "updatestateerror", "updatestatepostponed",
|
||||
"captureexitcode", "capturehresult", "captureexitcodeclass", "captureappversion",
|
||||
"captureapptypewinget", "captureapptypemsi", "captureattemptnumber",
|
||||
"capturedetectionresult", "esptrackstatus", "policiesdiscovered",
|
||||
"capturedetectionresult", "markpostinstalldetection", "esptrackstatus", "policiesdiscovered",
|
||||
"ignorecompletedapp", "updatename", "updatewin32appstate",
|
||||
"cancelstuckandsetcurrent", "updatedotelemetry",
|
||||
// Not app-mutating, but a stale token-failure replayed from a previous
|
||||
@@ -790,10 +790,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
break;
|
||||
|
||||
case "markpostinstalldetection":
|
||||
// The check right after an install starts (D-392); its result line follows.
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
_postInstallCheckPending[id] = pattern.Parameters.ContainsKey("afterReboot")
|
||||
&& pattern.Parameters["afterReboot"] == "true";
|
||||
break;
|
||||
|
||||
case "capturedetectionresult":
|
||||
var detectionVal = match.Groups["detection"]?.Value;
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(detectionVal))
|
||||
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(detectionVal))
|
||||
break;
|
||||
bool afterReboot;
|
||||
if (_postInstallCheckPending.TryGetValue(id, out afterReboot))
|
||||
{
|
||||
// detectionResult stays the check before the install.
|
||||
_postInstallCheckPending.Remove(id);
|
||||
HandlePostInstallDetection(id, detectionVal, afterReboot);
|
||||
}
|
||||
else
|
||||
{
|
||||
_packageStates.GetPackage(id)?.UpdateDetectionResult(detectionVal);
|
||||
}
|
||||
break;
|
||||
|
||||
case "updatestatepostponed":
|
||||
|
||||
@@ -50,6 +50,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// because setcurrentapp, esptrackstatus etc. see IDs that never enter packageStates.
|
||||
private readonly HashSet<string> _seenAppIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// IME's check right after an install (D-392): app id → "after the restart the installer
|
||||
// requested". Set by IME-POST-INSTALL-DETECTION / IME-POST-REBOOT-DETECTION, consumed by
|
||||
// the app's next detection result line, which IME always logs last in that check.
|
||||
// Transient on purpose: a marker whose result line was never read must not survive an
|
||||
// agent restart and turn a later ordinary check into a post-install result.
|
||||
private readonly Dictionary<string, bool> _postInstallCheckPending = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Forward-only phase progression: prevents accidental phase bounce (e.g. DeviceSetup
|
||||
// re-detected during AccountSetup if IME re-evaluates device apps and logs the old phase).
|
||||
private static readonly Dictionary<string, int> EspPhaseOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -513,6 +520,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
|
||||
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
|
||||
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IME's check right after an install found its result (D-392): the package, the detection
|
||||
/// state (Detected, NotDetected, DetectedDifferentVersion), whether the check followed the
|
||||
/// restart the installer requested, and the result line's timestamp. Not raised for the
|
||||
/// check before a requested restart: IME decides only after it.
|
||||
/// </summary>
|
||||
public Action<AppPackageState, string, bool, DateTime?> OnPostInstallDetection { get; set; }
|
||||
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -40,8 +40,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
/// "Completed user session" line fires per processing pass, see
|
||||
/// <see cref="ImeLogTracker.GetPendingRequiredUserEspInstallApps"/>).</item>
|
||||
/// <item><c>OnAppStateChanged(app, old, new)</c> → <see cref="DecisionSignalKind.AppInstallCompleted"/>
|
||||
/// (state=Installed/Skipped/Postponed) oder <see cref="DecisionSignalKind.AppInstallFailed"/> (state=Error).
|
||||
/// Dedup pro (AppId, terminal-state-tuple).</item>
|
||||
/// (state=Installed/Skipped/Postponed) or <see cref="DecisionSignalKind.AppInstallFailed"/> (state=Error).
|
||||
/// Once per app: the first terminal state wins. A later IME flip Installed → Error (0x87D1041C after
|
||||
/// the check right after the install, D-392) still emits app_install_failed, but no second signal.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -81,6 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<string>? _prevOnPatternMatched;
|
||||
private readonly Action<string>? _prevOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, string, bool, DateTime?>? _prevOnPostInstallDetection;
|
||||
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
|
||||
private readonly Action<string, string>? _prevOnImeTokenFailure;
|
||||
@@ -92,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<string> _ourOnPatternMatched;
|
||||
private readonly Action<string> _ourOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, string, bool, DateTime?> _ourOnPostInstallDetection;
|
||||
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
|
||||
private readonly Action<ScriptRecurrenceSummary>? _prevOnScriptRecurrenceSummary;
|
||||
@@ -195,6 +198,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_prevOnPatternMatched = _tracker.OnPatternMatched;
|
||||
_prevOnImeAgentVersion = _tracker.OnImeAgentVersion;
|
||||
_prevOnDoTelemetryReceived = _tracker.OnDoTelemetryReceived;
|
||||
_prevOnPostInstallDetection = _tracker.OnPostInstallDetection;
|
||||
_prevOnScriptCompleted = _tracker.OnScriptCompleted;
|
||||
_prevOnScriptStarted = _tracker.OnScriptStarted;
|
||||
_prevOnScriptRecurrenceSummary = _tracker.OnScriptRecurrenceSummary;
|
||||
@@ -210,6 +214,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_ourOnPatternMatched = OnPatternMatched;
|
||||
_ourOnImeAgentVersion = OnImeAgentVersion;
|
||||
_ourOnDoTelemetryReceived = OnDoTelemetryReceived;
|
||||
_ourOnPostInstallDetection = OnPostInstallDetection;
|
||||
_ourOnScriptCompleted = OnScriptCompleted;
|
||||
_ourOnScriptStarted = OnScriptStarted;
|
||||
_ourOnScriptRecurrenceSummary = OnScriptRecurrenceSummary;
|
||||
@@ -221,6 +226,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_tracker.OnPatternMatched = _ourOnPatternMatched;
|
||||
_tracker.OnImeAgentVersion = _ourOnImeAgentVersion;
|
||||
_tracker.OnDoTelemetryReceived = _ourOnDoTelemetryReceived;
|
||||
_tracker.OnPostInstallDetection = _ourOnPostInstallDetection;
|
||||
_tracker.OnScriptCompleted = _ourOnScriptCompleted;
|
||||
_tracker.OnScriptStarted = _ourOnScriptStarted;
|
||||
_tracker.OnScriptRecurrenceSummary = _ourOnScriptRecurrenceSummary;
|
||||
@@ -246,6 +252,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_tracker.OnImeAgentVersion = _prevOnImeAgentVersion;
|
||||
if (ReferenceEquals(_tracker.OnDoTelemetryReceived, _ourOnDoTelemetryReceived))
|
||||
_tracker.OnDoTelemetryReceived = _prevOnDoTelemetryReceived;
|
||||
if (ReferenceEquals(_tracker.OnPostInstallDetection, _ourOnPostInstallDetection))
|
||||
_tracker.OnPostInstallDetection = _prevOnPostInstallDetection;
|
||||
if (ReferenceEquals(_tracker.OnScriptCompleted, _ourOnScriptCompleted))
|
||||
_tracker.OnScriptCompleted = _prevOnScriptCompleted;
|
||||
if (ReferenceEquals(_tracker.OnScriptStarted, _ourOnScriptStarted))
|
||||
@@ -292,6 +300,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
}
|
||||
|
||||
private void OnPostInstallDetection(AppPackageState app, string detection, bool afterReboot, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
_prevOnPostInstallDetection?.Invoke(app, detection, afterReboot, sourceTimestampUtc);
|
||||
EmitPostInstallDetection(app, detection, afterReboot, sourceTimestampUtc);
|
||||
}
|
||||
|
||||
private void OnScriptCompleted(ScriptExecutionState script)
|
||||
{
|
||||
_prevOnScriptCompleted?.Invoke(script);
|
||||
@@ -368,6 +382,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app, DateTime? sourceTimestampUtc = null) =>
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
internal void TriggerPostInstallDetectionFromTest(AppPackageState app, string detection, bool afterReboot, DateTime? sourceTimestampUtc = null) =>
|
||||
EmitPostInstallDetection(app, detection, afterReboot, sourceTimestampUtc);
|
||||
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
|
||||
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
|
||||
internal void TriggerImeTokenFailureFromTest(string errorCode, string message) => EmitImeTokenFailure(errorCode, message);
|
||||
@@ -1217,6 +1233,45 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
$"ImeAdapter: DO telemetry app='{app.Name ?? "?"}' total={app.DoFileSize} peers={app.DoBytesFromPeers} http={app.DoBytesFromHttp} mode={app.DoDownloadMode}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IME's check right after an install (D-392), or after the restart the installer requested.
|
||||
/// Informational: completion and app state stay with the state events; a NotDetected here is
|
||||
/// followed by IME's app_install_failed with 0x87D1041C.
|
||||
/// </summary>
|
||||
private void EmitPostInstallDetection(AppPackageState app, string detection, bool afterReboot, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
if (app == null || string.IsNullOrEmpty(app.Id) || string.IsNullOrEmpty(detection)) return;
|
||||
|
||||
var now = ResolveOccurredAt(sourceTimestampUtc, out var derivedFromClock, out var rawSourceTs);
|
||||
// Historic replay — a check from a previous enrollment's log is not this install's.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.AppPostInstallDetection, app.Id))
|
||||
return;
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = app.Id,
|
||||
["appName"] = app.Name ?? string.Empty,
|
||||
["postInstallDetection"] = detection,
|
||||
["afterReboot"] = afterReboot ? "true" : "false",
|
||||
};
|
||||
if (!string.IsNullOrEmpty(app.AppVersion)) data["appVersion"] = app.AppVersion!;
|
||||
if (!string.IsNullOrEmpty(app.AppType)) data["appType"] = app.AppType!;
|
||||
if (app.AttemptNumber > 0) data["attemptNumber"] = app.AttemptNumber.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (!string.IsNullOrEmpty(app.ExitCodeClass)) data["exitCodeClass"] = app.ExitCodeClass!;
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
var label = string.IsNullOrEmpty(app.Name) ? app.Id : app.Name;
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.AppPostInstallDetection,
|
||||
source: SourceLabel,
|
||||
message: $"{label}: detection {(afterReboot ? "after the restart" : "after install")} - {detection}",
|
||||
severity: string.Equals(detection, "NotDetected", StringComparison.OrdinalIgnoreCase) ? EventSeverity.Warning : EventSeverity.Info,
|
||||
// One per install, part of the app's story: flush with the state events.
|
||||
immediateUpload: true,
|
||||
data: data,
|
||||
occurredAtUtc: now);
|
||||
}
|
||||
|
||||
private void EmitScriptCompleted(ScriptExecutionState script)
|
||||
{
|
||||
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// "IME did not detect the app after its installer finished" is IME's error 0x87D1041C, never the
|
||||
/// <c>detectionResult</c> field (IME's check before the install, D-392). The IME log writes the
|
||||
/// code as the signed decimal -2016345060, the ESP path as hex: the stored failure code is
|
||||
/// canonical so rankings and the apps banner see one code.
|
||||
/// </summary>
|
||||
public class AppDetectionFailureTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-0000-0000-000000000fa2";
|
||||
|
||||
[Theory]
|
||||
[InlineData("0x87d1041c", true)]
|
||||
[InlineData("0x87D1041C", true)]
|
||||
[InlineData("-2016345060", true)]
|
||||
[InlineData("esp_apps_detection_failure", true)]
|
||||
[InlineData("ESP_APPS_DETECTION_FAILURE", true)]
|
||||
[InlineData("0x87d1041d", false)] // the uninstall twin: still found after the removal
|
||||
[InlineData("-2016345059", false)]
|
||||
[InlineData("1603", false)]
|
||||
[InlineData("esp_apps_timeout", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsDetectionFailure_recognises_every_form_of_0x87D1041C(string? failureCode, bool expected)
|
||||
=> Assert.Equal(expected, Constants.AppFailureTypes.IsDetectionFailure(failureCode));
|
||||
|
||||
private static AppInstallSummary Failed(string? failureCode)
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = "app_install_failed",
|
||||
Timestamp = new DateTime(2026, 10, 29, 10, 0, 0, DateTimeKind.Utc),
|
||||
Data = new Dictionary<string, object> { ["appName"] = "Contoso App", ["detectionResult"] = "NotDetected" },
|
||||
};
|
||||
if (failureCode != null) evt.Data["errorCode"] = failureCode;
|
||||
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
|
||||
EventIngestProcessor.AggregateAppInstallEvent(evt, TenantId, "session", summaries);
|
||||
return summaries["Contoso App"].Summary;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("-2016345060", "0x87d1041c")]
|
||||
[InlineData("0x87D1041C", "0x87d1041c")]
|
||||
[InlineData("-2145091577", "0x80248007")]
|
||||
[InlineData("1603", "1603")] // an exit code stays an exit code
|
||||
[InlineData(" 0x80070643 ", "0x80070643")]
|
||||
public void Ingest_stores_the_failure_code_canonical(string sent, string stored)
|
||||
=> Assert.Equal(stored, Failed(sent).FailureCode);
|
||||
|
||||
[Fact]
|
||||
public void Ingest_keeps_the_failure_type_ahead_of_the_code()
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = "app_install_failed",
|
||||
Timestamp = new DateTime(2026, 10, 29, 10, 0, 0, DateTimeKind.Utc),
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["appName"] = "Contoso App",
|
||||
["errorCode"] = "0x87d1041c",
|
||||
["failureType"] = Constants.AppFailureTypes.EspAppsDetectionFailure,
|
||||
},
|
||||
};
|
||||
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
|
||||
EventIngestProcessor.AggregateAppInstallEvent(evt, TenantId, "session", summaries);
|
||||
|
||||
Assert.Equal(Constants.AppFailureTypes.EspAppsDetectionFailure, summaries["Contoso App"].Summary.FailureCode);
|
||||
}
|
||||
|
||||
private static AppInstallSummary Row(string status, string failureCode = "", string sessionId = "s-1") => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = sessionId,
|
||||
AppName = "Contoso App",
|
||||
Status = status,
|
||||
FailureCode = failureCode,
|
||||
StartedAt = DateTime.UtcNow.AddHours(-2),
|
||||
DurationSeconds = 30,
|
||||
};
|
||||
|
||||
private static async Task<JsonElement> AnalyticsAsync(List<AppInstallSummary> summaries)
|
||||
{
|
||||
var sessions = new Mock<ISessionRepository>();
|
||||
sessions.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
var response = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, sessions.Object, "Contoso App", AppInstallSources.Ime, days: 30);
|
||||
return TestWire.SerializeToElement(response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Banner_counts_failed_installs_IME_did_not_detect_afterwards()
|
||||
{
|
||||
var root = await AnalyticsAsync(new List<AppInstallSummary>
|
||||
{
|
||||
Row("Failed", "0x87d1041c", "s-1"),
|
||||
Row("Failed", Constants.AppFailureTypes.EspAppsDetectionFailure, "s-2"),
|
||||
Row("Failed", "0x80248007", "s-3"), // a download error
|
||||
Row("Failed", "", "s-4"), // no code recorded
|
||||
Row("Succeeded", "", "s-5"), // installed and detected
|
||||
});
|
||||
|
||||
Assert.Equal(2, root.GetProperty("detectionFailureCount").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Top_failure_codes_rank_both_IME_forms_as_one_code()
|
||||
{
|
||||
var root = await AnalyticsAsync(new List<AppInstallSummary>
|
||||
{
|
||||
Row("Failed", Failed("-2016345060").FailureCode, "s-1"),
|
||||
Row("Failed", Failed("0x87D1041C").FailureCode, "s-2"),
|
||||
Row("Failed", Failed("1603").FailureCode, "s-3"),
|
||||
});
|
||||
|
||||
var top = root.GetProperty("topFailureCodes")[0];
|
||||
Assert.Equal("0x87d1041c", top.GetProperty("code").GetString());
|
||||
Assert.Equal(2, top.GetProperty("count").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,6 @@ public class AppsProjectionEquivalenceTests
|
||||
["FailureCode"] = "0x80070643",
|
||||
["FailureMessage"] = "Fatal error during installation. " + new string('z', 400),
|
||||
["ExitCode"] = 1603,
|
||||
["DetectionResult"] = "NotDetected",
|
||||
// DO telemetry block (dashboard-irrelevant, app-metrics-relevant).
|
||||
["DoDownloadMode"] = 1,
|
||||
["DoFileSize"] = 180_000_000L,
|
||||
@@ -132,7 +131,6 @@ public class AppsProjectionEquivalenceTests
|
||||
Assert.Equal(fromFull.FailureCode, fromProjected.FailureCode);
|
||||
Assert.Equal(fromFull.FailureMessage, fromProjected.FailureMessage);
|
||||
Assert.Equal(fromFull.ExitCode, fromProjected.ExitCode);
|
||||
Assert.Equal(fromFull.DetectionResult, fromProjected.DetectionResult);
|
||||
Assert.Equal(fromFull.TerminalState, fromProjected.TerminalState);
|
||||
Assert.Equal(fromFull.AppId, fromProjected.AppId);
|
||||
Assert.Equal(fromFull.EspBlocking, fromProjected.EspBlocking);
|
||||
|
||||
@@ -179,7 +179,8 @@ public class BuiltInRulesTests
|
||||
"healthScriptResult", "healthScriptDetectionResult",
|
||||
"captureExitCode", "captureHResult",
|
||||
"captureAppVersion", "captureAppTypeWinGet", "captureAppTypeMsi",
|
||||
"captureAttemptNumber", "captureDetectionResult", "captureExitCodeClass"
|
||||
"captureAttemptNumber", "captureDetectionResult", "captureExitCodeClass",
|
||||
"markPostInstallDetection"
|
||||
};
|
||||
|
||||
var patterns = BuiltInImeLogPatterns.GetAll();
|
||||
|
||||
@@ -156,7 +156,6 @@ public class DiagnosisHeadlineBuilderTests
|
||||
Assert.Equal("User", culprit.Targeted);
|
||||
Assert.Equal("WinGet", culprit.AppType);
|
||||
Assert.Equal("0x80d03805", culprit.ErrorCode);
|
||||
Assert.Equal("NotDetected", culprit.DetectionResult);
|
||||
Assert.True(culprit.EspBlocking);
|
||||
|
||||
// Phases in ESP order; the failed step carries the code.
|
||||
@@ -236,12 +235,15 @@ public class DiagnosisHeadlineBuilderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_detection_failure_without_an_app_code_matches_the_esp_not_detected_error()
|
||||
public void IMEs_own_detection_failure_report_matches_the_esp_not_detected_error()
|
||||
{
|
||||
// The IME shape of a detection failure: no errorCode, hresultFromWin32 and exitCode "0".
|
||||
// IME's report delta carries EnforcementErrorCode as the signed decimal of 0x87D1041C;
|
||||
// hresultFromWin32 and exitCode stay "0".
|
||||
var events = new Timeline()
|
||||
.Add("app_install_failed", App(VpnId, "Contoso VPN", ("errorCode", "1603")))
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("state", "Error"), ("detectionResult", "NotDetected"), ("exitCode", "0"), ("hresultFromWin32", "0")))
|
||||
.Add("app_install_completed", App(NotesId, "Contoso Notes", ("state", "Installed"), ("detectionResult", "NotDetected")))
|
||||
.Add("app_post_install_detection", App(NotesId, "Contoso Notes", ("postInstallDetection", "NotDetected"), ("afterReboot", "false")))
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("state", "Error"), ("errorCode", "-2016345060"), ("detectionResult", "NotDetected"), ("exitCode", "0"), ("hresultFromWin32", "0")))
|
||||
.Add("app_install_failed", App(PortalId, "Contoso Portal", ("errorCode", "1618")))
|
||||
.Add("enrollment_failed", Failed("AccountSetup", "Apps", "0x87d1041c"));
|
||||
|
||||
@@ -250,9 +252,25 @@ public class DiagnosisHeadlineBuilderTests
|
||||
Assert.Equal(DiagnosisCauses.EspAppFailure, h.Cause);
|
||||
var first = h.Culprits![0];
|
||||
Assert.Equal(NotesId, first.Key);
|
||||
Assert.Null(first.ErrorCode);
|
||||
Assert.Equal("-2016345060", first.ErrorCode);
|
||||
Assert.Equal("0", first.ExitCode);
|
||||
Assert.Equal("NotDetected", first.DetectionResult);
|
||||
Assert.Equal("NotDetected", first.PostInstallDetection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_check_before_the_install_does_not_match_the_esp_not_detected_error()
|
||||
{
|
||||
// detectionResult NotDetected is IME's check BEFORE the install: every failed app carries
|
||||
// it. A download error must not be ranked as the ESP's detection failure (D-392).
|
||||
var events = new Timeline()
|
||||
.Add("app_install_failed", App(NotesId, "Contoso Notes", ("state", "Error"), ("errorCode", "-2145091577"), ("detectionResult", "NotDetected")))
|
||||
.Add("app_install_failed", App(PortalId, "Contoso Portal", ("errorCode", "1618")))
|
||||
.Add("enrollment_failed", Failed("AccountSetup", "Apps", "0x87d1041c"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
// No app carries the ESP's code, so the latest failure leads.
|
||||
Assert.Equal(new[] { PortalId, NotesId }, h.Culprits!.Select(c => c.Key));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -30,6 +30,8 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
private const string DisplayName = "Alice (Contoso)";
|
||||
private const string Comment = "The timeline filter could remember my last choice.";
|
||||
private static readonly DateTime Now = new(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string FeedbackId = "0123456789ab";
|
||||
private static readonly string EntryPath = Constants.PortalUserFeedbackEntryPath(FeedbackId);
|
||||
|
||||
[Fact]
|
||||
public async Task Rating_IsStored_RaisesTheEvent_RingsTheBell_AndPings()
|
||||
@@ -57,10 +59,11 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
Assert.Equal(4, details.RootElement.GetProperty("rating").GetInt32());
|
||||
Assert.Equal(Comment.Length, details.RootElement.GetProperty("commentLength").GetInt32());
|
||||
Assert.Equal("contoso.invalid", details.RootElement.GetProperty("domainName").GetString());
|
||||
Assert.Equal(Constants.PortalUserFeedbackPath, details.RootElement.GetProperty("portalPath").GetString());
|
||||
Assert.Equal(FeedbackId, details.RootElement.GetProperty("feedbackId").GetString());
|
||||
Assert.Equal("/admin/reports/user-feedback?feedbackId=0123456789ab", details.RootElement.GetProperty("portalPath").GetString());
|
||||
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", Constants.PortalUserFeedbackPath), Times.Once);
|
||||
"feedback", "New Rating", $"{Upn} (contoso.invalid) · 4/5", EntryPath), Times.Once);
|
||||
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 4, Comment), Times.Once);
|
||||
}
|
||||
|
||||
@@ -142,7 +145,7 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
using var details = JsonDocument.Parse(ops.Details!);
|
||||
Assert.Equal(0, details.RootElement.GetProperty("commentLength").GetInt32());
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", $"{Upn} ({NormalizedTenantId}) · 5/5", Constants.PortalUserFeedbackPath), Times.Once);
|
||||
"feedback", "New Rating", $"{Upn} ({NormalizedTenantId}) · 5/5", EntryPath), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,7 +159,7 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
Assert.Single(h.Repo.Written);
|
||||
Assert.Empty(h.OpsEvents);
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Rating", It.IsAny<string>(), Constants.PortalUserFeedbackPath), Times.Once);
|
||||
"feedback", "New Rating", It.IsAny<string>(), EntryPath), Times.Once);
|
||||
h.Telegram.Verify(t => t.SendFeedbackAsync(TenantId, Upn, DisplayName, 3, Comment), Times.Once);
|
||||
}
|
||||
|
||||
@@ -247,7 +250,10 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>In-memory feedback repo holding one person's bubble row; only the in-app members are expected.</summary>
|
||||
/// <summary>
|
||||
/// In-memory feedback repo holding one person's bubble row; only the in-app members are expected. Like the table
|
||||
/// repository it keeps the stored row's id and mints <see cref="FeedbackId"/> for a new row.
|
||||
/// </summary>
|
||||
private sealed class FakeFeedbackRepository : IFeedbackRepository
|
||||
{
|
||||
private FeedbackEntry? _current;
|
||||
@@ -264,6 +270,7 @@ public sealed class FeedbackFunctionSubmitTests
|
||||
if (next != null)
|
||||
{
|
||||
next.Type = FeedbackEntryType.InApp;
|
||||
next.FeedbackId = _current?.FeedbackId ?? FeedbackId;
|
||||
_current = next;
|
||||
Written.Add(next);
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ public class GeoMetricsProjectionEquivalenceTests
|
||||
// Noise the geo aggregation never reads.
|
||||
["Status"] = "Succeeded",
|
||||
["FailureMessage"] = new string('y', 500),
|
||||
["DetectionResult"] = "Detected",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Counting rules of the growth figures: window membership on the UTC day, activation from the
|
||||
/// counters, the first enrollment from the daily usage rows (pending while only today's counters
|
||||
/// moved), activity from the usage rows plus today's counters, hour and weekday in the requested
|
||||
/// time zone, no approval-time rows in the hours or peaks, and excluded tenants in no figure.
|
||||
/// </summary>
|
||||
public class GrowthMetricsBuilderTests
|
||||
{
|
||||
// Wednesday; Europe/Berlin switched from CEST to CET on Sunday 2026-10-25.
|
||||
private static readonly DateTime Now = new(2026, 10, 28, 15, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeZoneInfo Berlin = ResolveBerlin();
|
||||
|
||||
private static TimeZoneInfo ResolveBerlin()
|
||||
{
|
||||
Assert.True(GrowthMetricsBuilder.TryResolveTimeZone("Europe/Berlin", out var tz));
|
||||
return tz;
|
||||
}
|
||||
|
||||
private static readonly string[] NoExclusions = Array.Empty<string>();
|
||||
|
||||
private static string Tid(char c) => $"{new string(c, 8)}-0000-4000-8000-000000000000";
|
||||
|
||||
private static TenantConfiguration Config(char id, DateTime? onboardedAt, string domain = "contoso.example", string plan = "free")
|
||||
=> new() { TenantId = Tid(id), DomainName = domain, OnboardedAt = onboardedAt, PlanTier = plan };
|
||||
|
||||
private static TenantStats Stats(char id, long total, long succeeded, DateTime lastUpdated)
|
||||
=> new() { TenantId = Tid(id), TotalEnrollments = total, SuccessfulEnrollments = succeeded, LastUpdated = lastUpdated };
|
||||
|
||||
private static TenantActivityDay Active(char id, int year, int month, int day)
|
||||
=> new() { TenantId = Tid(id), Day = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Utc), SessionsTotal = 1 };
|
||||
|
||||
private static DateTime Utc(int month, int day, int hour, int minute = 0) => new(2026, month, day, hour, minute, 0, DateTimeKind.Utc);
|
||||
|
||||
private static GrowthMetricsResponse Scenario(bool includeTenants = false, params char[] excluded)
|
||||
{
|
||||
var configs = new List<TenantConfiguration>
|
||||
{
|
||||
Config('a', Utc(10, 22, 9), plan: "pro"), // enrolled the same day, still active this week
|
||||
Config('b', Utc(10, 23, 13, 30)), // first sessions today: pending
|
||||
Config('c', Utc(10, 24, 7)), // never enrolled
|
||||
Config('d', Utc(10, 26, 8, 15)), // enrolled the next day, trial started
|
||||
Config('e', Utc(9, 1, 12)), // older tenant, active 23 days ago
|
||||
Config('f', Utc(10, 16, 12)), // previous window, enrolled, silent for 30 days
|
||||
Config('g', Utc(10, 25, 12), domain: ""), // read of an unknown tenant id, not a signup
|
||||
Config('h', Utc(10, 27, 23, 30)), // 00:30 on Wednesday in Berlin; never enrolled
|
||||
};
|
||||
configs[0].PayingCustomer = true;
|
||||
configs[3].TrialStartedUtc = Utc(10, 26, 10);
|
||||
configs[3].TrialExpiresUtc = Utc(11, 25, 10);
|
||||
|
||||
var stats = new List<TenantStats>
|
||||
{
|
||||
Stats('a', 3, 2, Utc(10, 27, 8)),
|
||||
Stats('b', 1, 0, Utc(10, 28, 10)),
|
||||
Stats('d', 2, 1, Utc(10, 27, 9)),
|
||||
Stats('e', 10, 9, Utc(10, 5, 9)),
|
||||
Stats('f', 5, 5, Utc(9, 1, 9)),
|
||||
};
|
||||
var activity = new List<TenantActivityDay>
|
||||
{
|
||||
Active('a', 2026, 10, 22),
|
||||
Active('d', 2026, 10, 27),
|
||||
Active('e', 2026, 10, 5),
|
||||
};
|
||||
|
||||
return GrowthMetricsBuilder.Build(configs, stats, activity, Now, 7, Berlin, includeTenants, excluded.Select(Tid).ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Window_is_the_last_n_utc_days_including_today()
|
||||
{
|
||||
var r = Scenario();
|
||||
|
||||
Assert.Equal("2026-10-22", r.WindowStart);
|
||||
Assert.Equal("2026-10-28", r.WindowEnd);
|
||||
Assert.Equal("2026-10-27", r.UsageDaysThrough);
|
||||
Assert.Equal("Europe/Berlin", r.TimeZone);
|
||||
Assert.Equal(0, r.ExcludedTenants);
|
||||
Assert.Equal(5, r.Signups.Total);
|
||||
Assert.Equal(1, r.Signups.PreviousWindowTotal);
|
||||
Assert.Equal("day", r.Signups.BucketSize);
|
||||
Assert.Equal(new[] { 1, 1, 1, 0, 1, 1, 0 }, r.Signups.Buckets.Select(b => b.Count));
|
||||
Assert.Equal("2026-10-22", r.Signups.Buckets[0].Start);
|
||||
Assert.Empty(r.Signups.PeakDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hours_and_weekdays_follow_the_time_zone_across_the_clock_change()
|
||||
{
|
||||
var r = Scenario();
|
||||
|
||||
var expectedHours = new int[24];
|
||||
expectedHours[0] = 1; // h: 23:30Z on 10-27 = 00:30 CET
|
||||
expectedHours[9] = 2; // c: 07:00Z CEST, d: 08:15Z CET
|
||||
expectedHours[11] = 1; // a: 09:00Z CEST
|
||||
expectedHours[15] = 1; // b: 13:30Z CEST
|
||||
Assert.Equal(expectedHours, r.Signups.ByHour);
|
||||
// Monday first: d Mon, h Wed, a Thu, b Fri, c Sat.
|
||||
Assert.Equal(new[] { 1, 0, 1, 1, 1, 1, 0 }, r.Signups.ByWeekday);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activation_counts_the_first_day_with_sessions_and_holds_todays_first_enrollment_as_pending()
|
||||
{
|
||||
var a = Scenario().Activation;
|
||||
|
||||
Assert.Equal(3, a.Enrolled);
|
||||
Assert.Equal(2, a.Succeeded);
|
||||
Assert.Equal(2, a.NeverEnrolled);
|
||||
Assert.Equal(1, a.FirstEnrollmentSameDay);
|
||||
Assert.Equal(1, a.FirstEnrollmentNextDay);
|
||||
Assert.Equal(0, a.FirstEnrollmentDays2To7);
|
||||
Assert.Equal(0, a.FirstEnrollmentLater);
|
||||
Assert.Equal(1, a.FirstEnrollmentPending);
|
||||
Assert.Equal(0, a.FirstEnrollmentUnknown);
|
||||
Assert.Equal(0.5, a.MedianDaysToFirstEnrollment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cohorts_are_monday_anchored_utc_weeks()
|
||||
{
|
||||
var cohorts = Scenario().Cohorts;
|
||||
|
||||
Assert.Equal(new[] { "2026-10-19", "2026-10-26" }, cohorts.Select(c => c.WeekStart));
|
||||
Assert.Equal(new[] { 3, 2 }, cohorts.Select(c => c.Signups));
|
||||
Assert.Equal(new[] { 2, 1 }, cohorts.Select(c => c.Enrolled));
|
||||
Assert.Equal(new[] { 1, 1 }, cohorts.Select(c => c.Succeeded));
|
||||
Assert.Equal(new[] { 2, 1 }, cohorts.Select(c => c.ActiveLast7Days));
|
||||
Assert.Equal(new double?[] { 0, 1 }, cohorts.Select(c => c.MedianDaysToFirstEnrollment));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Segments_cover_every_tenant_with_a_domain()
|
||||
{
|
||||
var s = Scenario().AllTenants;
|
||||
|
||||
Assert.Equal(7, s.Tenants);
|
||||
Assert.Equal(2, s.NeverEnrolled);
|
||||
Assert.Equal(5, s.Enrolled);
|
||||
Assert.Equal(1, s.EnrolledWithoutSuccess);
|
||||
Assert.Equal(3, s.Active7Days); // a (10-22), b (counters today), d (10-27)
|
||||
Assert.Equal(4, s.Active30Days); // plus e (10-05)
|
||||
Assert.Equal(1, s.Dormant); // f
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plans_count_tiers_trials_and_paying_customers()
|
||||
{
|
||||
var p = Scenario().Plans;
|
||||
|
||||
Assert.Equal(new[] { ("free", 6), ("pro", 1) }, p.ByPlanTier.Select(x => (x.PlanTier, x.Tenants)));
|
||||
Assert.Equal(1, p.TrialsStartedInWindow);
|
||||
Assert.Equal(1, p.TrialsActive);
|
||||
Assert.Equal(1, p.PayingCustomers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tenant_rows_appear_only_on_request_in_signup_order()
|
||||
{
|
||||
Assert.Null(Scenario().Tenants);
|
||||
|
||||
var rows = Scenario(includeTenants: true).Tenants!;
|
||||
Assert.Equal(new[] { Tid('a'), Tid('b'), Tid('c'), Tid('d'), Tid('h') }, rows.Select(r => r.TenantId));
|
||||
var b = rows[1];
|
||||
Assert.True(b.Enrolled);
|
||||
Assert.Null(b.FirstEnrollmentDay);
|
||||
Assert.Equal("2026-10-28", b.LastActiveDay);
|
||||
var c = rows[2];
|
||||
Assert.False(c.Enrolled);
|
||||
Assert.Null(c.LastActiveDay);
|
||||
Assert.Equal("pro", rows[0].PlanTier);
|
||||
Assert.Equal("2026-10-22", rows[0].FirstEnrollmentDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Excluded_tenants_leave_every_figure_and_only_signed_up_rows_count_as_excluded()
|
||||
{
|
||||
// a: in-window pro signup, paying, enrolled the same day; e: older tenant active 23 days ago;
|
||||
// g: a row without a domain (never a tenant); z: no configuration row at all.
|
||||
var r = Scenario(true, 'a', 'e', 'g', 'z');
|
||||
|
||||
Assert.Equal(2, r.ExcludedTenants);
|
||||
Assert.Equal(4, r.Signups.Total);
|
||||
Assert.Equal(new[] { 0, 1, 1, 0, 1, 1, 0 }, r.Signups.Buckets.Select(b => b.Count));
|
||||
Assert.Equal(0, r.Signups.ByHour[11]);
|
||||
Assert.Equal(2, r.Activation.Enrolled);
|
||||
Assert.Equal(0, r.Activation.FirstEnrollmentSameDay);
|
||||
Assert.Equal(1, r.Activation.MedianDaysToFirstEnrollment);
|
||||
Assert.Equal(new[] { 2, 2 }, r.Cohorts.Select(c => c.Signups)); // b, c | d, h
|
||||
Assert.Equal(5, r.AllTenants.Tenants);
|
||||
Assert.Equal(2, r.AllTenants.Active7Days);
|
||||
Assert.Equal(2, r.AllTenants.Active30Days);
|
||||
Assert.Equal(new[] { ("free", 5) }, r.Plans.ByPlanTier.Select(x => (x.PlanTier, x.Tenants)));
|
||||
Assert.Equal(0, r.Plans.PayingCustomers);
|
||||
Assert.Equal(new[] { Tid('b'), Tid('c'), Tid('d'), Tid('h') }, r.Tenants!.Select(t => t.TenantId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true, "")]
|
||||
[InlineData("", true, "")]
|
||||
[InlineData(" {AAAAAAAA-0000-4000-8000-000000000000} ,,bbbbbbbb-0000-4000-8000-000000000000", true,
|
||||
"aaaaaaaa-0000-4000-8000-000000000000,bbbbbbbb-0000-4000-8000-000000000000")]
|
||||
[InlineData("aaaaaaaa-0000-4000-8000-000000000000,contoso.example", false, "")]
|
||||
public void Excluded_tenants_are_tenant_ids_in_the_lowercase_d_form(string? raw, bool ok, string expected)
|
||||
{
|
||||
Assert.Equal(ok, GrowthMetricsBuilder.TryParseExcludedTenants(raw, out var ids));
|
||||
if (ok) Assert.Equal(expected, string.Join(",", ids));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void At_most_twenty_tenants_can_be_excluded()
|
||||
{
|
||||
static string Ids(int n) => string.Join(",", Enumerable.Range(0, n).Select(i => $"{i:x8}-0000-4000-8000-000000000000"));
|
||||
|
||||
Assert.True(GrowthMetricsBuilder.TryParseExcludedTenants(Ids(GrowthMetricsBuilder.MaxExcludedTenants), out _));
|
||||
Assert.False(GrowthMetricsBuilder.TryParseExcludedTenants(Ids(GrowthMetricsBuilder.MaxExcludedTenants + 1), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_first_enrollment_without_a_usage_row_and_without_activity_today_is_unknown()
|
||||
{
|
||||
var configs = new[] { Config('a', Utc(10, 20, 9)) };
|
||||
var stats = new[] { Stats('a', 4, 4, Utc(10, 27, 8)) };
|
||||
|
||||
var a = GrowthMetricsBuilder.Build(configs, stats, Array.Empty<TenantActivityDay>(), Now, 30, TimeZoneInfo.Utc, false, NoExclusions).Activation;
|
||||
|
||||
Assert.Equal(1, a.FirstEnrollmentUnknown);
|
||||
Assert.Equal(0, a.FirstEnrollmentPending);
|
||||
Assert.Null(a.MedianDaysToFirstEnrollment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_peak_needs_three_signups_and_twice_the_median_of_the_two_weeks_before()
|
||||
{
|
||||
var configs = new List<TenantConfiguration>();
|
||||
var next = 'a';
|
||||
void Signups(int month, int day, int count)
|
||||
{
|
||||
for (var i = 0; i < count; i++) configs.Add(Config(next++, Utc(month, day, 10, i)));
|
||||
}
|
||||
// One signup a day from 10-01 to 10-14 sets the baseline median to 1.
|
||||
for (var day = 1; day <= 14; day++) Signups(10, day, 1);
|
||||
Signups(10, 15, 3); // 3 >= 2 × 1: peak
|
||||
Signups(10, 16, 1);
|
||||
|
||||
var r = GrowthMetricsBuilder.Build(configs, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(),
|
||||
new DateTime(2026, 10, 16, 12, 0, 0, DateTimeKind.Utc), 7, TimeZoneInfo.Utc, false, NoExclusions);
|
||||
|
||||
var peak = Assert.Single(r.Signups.PeakDays);
|
||||
Assert.Equal("2026-10-15", peak.Day);
|
||||
Assert.Equal(3, peak.Count);
|
||||
Assert.Equal(1, peak.BaselineMedian);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approval_time_rows_stay_out_of_the_hours_and_peaks_but_count_as_signups()
|
||||
{
|
||||
// Before 2026-03-18 OnboardedAt is the approval time: four rows within milliseconds.
|
||||
var configs = Enumerable.Range(0, 4)
|
||||
.Select(i => Config((char)('a' + i), new DateTime(2026, 2, 24, 7, 46, 30, i * 10, DateTimeKind.Utc)))
|
||||
.Append(Config('k', Utc(10, 20, 14)))
|
||||
.ToList();
|
||||
|
||||
var r = GrowthMetricsBuilder.Build(configs, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(),
|
||||
Now, 365, TimeZoneInfo.Utc, false, NoExclusions);
|
||||
|
||||
Assert.Equal(5, r.Signups.Total);
|
||||
Assert.Equal(1, r.Signups.ByHour.Sum());
|
||||
Assert.Equal(1, r.Signups.ByHour[14]);
|
||||
Assert.Empty(r.Signups.PeakDays);
|
||||
Assert.Equal("2026-03-18", r.ExactSignupTimesFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows_beyond_90_days_bucket_by_monday_anchored_week_clipped_to_the_window()
|
||||
{
|
||||
var configs = new[] { Config('a', Utc(10, 20, 9)), Config('b', Utc(8, 3, 9)) };
|
||||
|
||||
var r = GrowthMetricsBuilder.Build(configs, Array.Empty<TenantStats>(), Array.Empty<TenantActivityDay>(),
|
||||
Now, 120, TimeZoneInfo.Utc, false, NoExclusions);
|
||||
|
||||
Assert.Equal("week", r.Signups.BucketSize);
|
||||
Assert.Equal(r.WindowStart, r.Signups.Buckets[0].Start);
|
||||
Assert.All(r.Signups.Buckets.Skip(1), b => Assert.Equal(DayOfWeek.Monday, DateTime.Parse(b.Start).DayOfWeek));
|
||||
Assert.Equal(2, r.Signups.Buckets.Sum(b => b.Count));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Usage_rows_are_read_for_the_window_or_at_least_the_30_day_look_back()
|
||||
{
|
||||
var today = Now.Date;
|
||||
Assert.Equal(today.AddDays(-29), GrowthMetricsBuilder.UsageReadStart(today, 7));
|
||||
Assert.Equal(today.AddDays(-89), GrowthMetricsBuilder.UsageReadStart(today, 90));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, true, "UTC")]
|
||||
[InlineData("", true, "UTC")]
|
||||
[InlineData("Europe/Berlin", true, "Europe/Berlin")]
|
||||
[InlineData("America/Argentina/Buenos_Aires", true, "America/Argentina/Buenos_Aires")]
|
||||
[InlineData("../../etc/passwd", false, null)]
|
||||
[InlineData("/etc/localtime", false, null)]
|
||||
[InlineData("Europe/Nowhere", false, null)]
|
||||
public void Time_zones_are_iana_ids_the_host_knows(string? id, bool ok, string? expectedId)
|
||||
{
|
||||
Assert.Equal(ok, GrowthMetricsBuilder.TryResolveTimeZone(id, out var tz));
|
||||
if (ok) Assert.Equal(expectedId, tz.Id);
|
||||
}
|
||||
}
|
||||
@@ -832,6 +832,136 @@ public class MetricsWireParityTests
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetGrowthMetrics ---------------------------------------------------------------
|
||||
|
||||
private static GrowthMetricsResponse GrowthResponse(double? median, IReadOnlyList<GrowthTenantRow>? tenants) => new()
|
||||
{
|
||||
WindowDays = 7,
|
||||
WindowStart = "2026-10-22",
|
||||
WindowEnd = "2026-10-28",
|
||||
TimeZone = "Europe/Berlin",
|
||||
ExactSignupTimesFrom = "2026-03-18",
|
||||
UsageDaysThrough = "2026-10-27",
|
||||
ExcludedTenants = 1,
|
||||
Signups = new GrowthSignups
|
||||
{
|
||||
Total = 5,
|
||||
PreviousWindowTotal = 1,
|
||||
BucketSize = "day",
|
||||
Buckets = new List<GrowthBucket> { new() { Start = "2026-10-22", Count = 5 } },
|
||||
PeakDays = new List<GrowthPeakDay> { new() { Day = "2026-10-22", Count = 5, BaselineMedian = 1 } },
|
||||
ByHour = new int[24],
|
||||
ByWeekday = new int[7],
|
||||
},
|
||||
Cohorts = new List<GrowthCohortWeek>
|
||||
{
|
||||
new() { WeekStart = "2026-10-19", Signups = 5, Enrolled = 3, Succeeded = 2, ActiveLast7Days = 3, MedianDaysToFirstEnrollment = median },
|
||||
},
|
||||
Activation = new GrowthActivation
|
||||
{
|
||||
Enrolled = 3, Succeeded = 2, NeverEnrolled = 2,
|
||||
FirstEnrollmentSameDay = 1, FirstEnrollmentNextDay = 1, FirstEnrollmentDays2To7 = 0, FirstEnrollmentLater = 0,
|
||||
FirstEnrollmentPending = 1, FirstEnrollmentUnknown = 0, MedianDaysToFirstEnrollment = median,
|
||||
},
|
||||
AllTenants = new GrowthTenantSegments
|
||||
{
|
||||
Tenants = 7, NeverEnrolled = 2, Enrolled = 5, EnrolledWithoutSuccess = 1, Active7Days = 3, Active30Days = 4, Dormant = 1,
|
||||
},
|
||||
Plans = new GrowthPlans
|
||||
{
|
||||
ByPlanTier = new List<GrowthPlanCount> { new() { PlanTier = "free", Tenants = 6 } },
|
||||
TrialsStartedInWindow = 1,
|
||||
TrialsActive = 1,
|
||||
PayingCustomers = 1,
|
||||
},
|
||||
Tenants = tenants,
|
||||
};
|
||||
|
||||
private static object GrowthLiteral(double? median, object? tenants) => new
|
||||
{
|
||||
windowDays = 7,
|
||||
windowStart = "2026-10-22",
|
||||
windowEnd = "2026-10-28",
|
||||
timeZone = "Europe/Berlin",
|
||||
exactSignupTimesFrom = "2026-03-18",
|
||||
usageDaysThrough = "2026-10-27",
|
||||
excludedTenants = 1,
|
||||
signups = new
|
||||
{
|
||||
total = 5,
|
||||
previousWindowTotal = 1,
|
||||
bucketSize = "day",
|
||||
buckets = new[] { new { start = "2026-10-22", count = 5 } },
|
||||
peakDays = new[] { new { day = "2026-10-22", count = 5, baselineMedian = 1.0 } },
|
||||
byHour = new int[24],
|
||||
byWeekday = new int[7],
|
||||
},
|
||||
cohorts = new[]
|
||||
{
|
||||
new { weekStart = "2026-10-19", signups = 5, enrolled = 3, succeeded = 2, activeLast7Days = 3, medianDaysToFirstEnrollment = median },
|
||||
},
|
||||
activation = new
|
||||
{
|
||||
enrolled = 3, succeeded = 2, neverEnrolled = 2,
|
||||
firstEnrollmentSameDay = 1, firstEnrollmentNextDay = 1, firstEnrollmentDays2To7 = 0, firstEnrollmentLater = 0,
|
||||
firstEnrollmentPending = 1, firstEnrollmentUnknown = 0, medianDaysToFirstEnrollment = median,
|
||||
},
|
||||
allTenants = new
|
||||
{
|
||||
tenants = 7, neverEnrolled = 2, enrolled = 5, enrolledWithoutSuccess = 1, active7Days = 3, active30Days = 4, dormant = 1,
|
||||
},
|
||||
plans = new
|
||||
{
|
||||
byPlanTier = new[] { new { planTier = "free", tenants = 6 } },
|
||||
trialsStartedInWindow = 1,
|
||||
trialsActive = 1,
|
||||
payingCustomers = 1,
|
||||
},
|
||||
tenants,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void GrowthMetricsResponse_matches_the_shape_with_tenant_rows()
|
||||
{
|
||||
var tenantId = "8bb31d22-0003-4c8d-b2e3-63f4bbbb0001";
|
||||
var onboardedAt = new DateTime(2026, 10, 22, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
GrowthLiteral(0.5, new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
onboardedAt,
|
||||
planTier = "free",
|
||||
enrolled = true,
|
||||
succeeded = true,
|
||||
firstEnrollmentDay = "2026-10-22",
|
||||
lastActiveDay = "2026-10-27",
|
||||
},
|
||||
}),
|
||||
GrowthResponse(0.5, new List<GrowthTenantRow>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
OnboardedAt = onboardedAt,
|
||||
PlanTier = "free",
|
||||
Enrolled = true,
|
||||
Succeeded = true,
|
||||
FirstEnrollmentDay = "2026-10-22",
|
||||
LastActiveDay = "2026-10-27",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GrowthMetricsResponse_omits_unknown_medians_and_the_tenant_rows()
|
||||
{
|
||||
// No first enrollment known yet and no includeTenants: the three keys vanish identically.
|
||||
AssertParity(GrowthLiteral(null, null), GrowthResponse(null, null));
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Storage side of the platform rollup: the platform row is merged with the ETag it was read
|
||||
/// with and never carries IssuesDetected, the tenant floors land in one write, and the reads
|
||||
/// that feed the rollup baseline are scoped to exactly the rows they mean.
|
||||
/// that feed the rollup baseline and the growth figures are scoped to exactly the rows they mean.
|
||||
/// </summary>
|
||||
public class PlatformStatsStorageTests
|
||||
{
|
||||
@@ -53,6 +53,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);
|
||||
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -231,6 +232,7 @@ public class PlatformStatsStorageTests
|
||||
var stats = Assert.Single(await h.Sut.GetAllTenantStatsAsync());
|
||||
|
||||
Assert.Equal("RowKey eq 'current' and PartitionKey ne 'global'", Assert.Single(h.Filters));
|
||||
Assert.Equal(Tenant, stats.TenantId);
|
||||
Assert.Equal(7, stats.TotalEnrollments);
|
||||
Assert.Equal(5, stats.SuccessfulEnrollments);
|
||||
Assert.Equal(900, stats.TotalEventsProcessed);
|
||||
@@ -246,6 +248,39 @@ public class PlatformStatsStorageTests
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetAllTenantStatsAsync());
|
||||
}
|
||||
|
||||
// ---------- tenant activity days (growth figures) ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_activity_days_read_the_tenant_rows_of_the_range_only()
|
||||
{
|
||||
var h = new Harness
|
||||
{
|
||||
RowsFor = _ => new[]
|
||||
{
|
||||
new TableEntity("2026-10-01", Tenant) { ["SessionsTotal"] = 3 },
|
||||
new TableEntity("2026-10-02", Tenant) { ["SessionsTotal"] = 0 },
|
||||
}
|
||||
};
|
||||
|
||||
var days = await h.Sut.GetTenantActivityDaysAsync(
|
||||
new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 10, 27, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal("PartitionKey ge '2026-10-01' and PartitionKey le '2026-10-27' and RowKey ne 'global'", Assert.Single(h.Filters));
|
||||
var day = Assert.Single(days);
|
||||
Assert.Equal(Tenant, day.TenantId);
|
||||
Assert.Equal(new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc), day.Day);
|
||||
Assert.Equal(DateTimeKind.Utc, day.Day.Kind);
|
||||
Assert.Equal(3, day.SessionsTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_activity_read_throws_instead_of_reporting_no_activity()
|
||||
{
|
||||
var h = new Harness { RowsFor = _ => throw new RequestFailedException(503, "busy") };
|
||||
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.GetTenantActivityDaysAsync(DateTime.UtcNow.Date, DateTime.UtcNow.Date));
|
||||
}
|
||||
|
||||
// ---------- seen device models ----------
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -367,6 +367,7 @@ public class PushDeliveryServiceTests
|
||||
Assert.Equal("paused", e.Properties["action"]);
|
||||
Assert.Equal("owner_inactive", e.Properties["reason"]);
|
||||
Assert.Equal("tenant", e.Properties["scope"]);
|
||||
Assert.Equal(TenantId, e.Properties["tenantId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -383,6 +384,7 @@ public class PushDeliveryServiceTests
|
||||
Assert.Equal("resumed", e.Properties["action"]);
|
||||
Assert.Equal("owner_inactive", e.Properties["reason"]);
|
||||
Assert.Equal("tenant", e.Properties["scope"]);
|
||||
Assert.Equal(TenantId, e.Properties["tenantId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -398,9 +400,56 @@ public class PushDeliveryServiceTests
|
||||
Assert.Equal("revoked", e.Properties["action"]);
|
||||
Assert.Equal("member_removed", e.Properties["reason"]);
|
||||
Assert.Equal("tenant", e.Properties["scope"]);
|
||||
Assert.Equal(TenantId, e.Properties["tenantId"]);
|
||||
Assert.False(h.Repo.Devices.ContainsKey((TenantId, "d1")));
|
||||
}
|
||||
|
||||
// ── Telemetry: PushDelivery and the inventory ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Delivery_events_name_the_tenant_of_a_tenant_device_and_none_for_a_platform_device()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
var h = new Harness(Telemetry(channel));
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope);
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
var deliveries = channel.Items.OfType<EventTelemetry>().Where(e => e.Name == "PushDelivery").ToList();
|
||||
Assert.Equal(2, deliveries.Count);
|
||||
var tenantEvent = Assert.Single(deliveries, e => e.Properties["scope"] == "tenant");
|
||||
Assert.Equal(TenantId, tenantEvent.Properties["tenantId"]);
|
||||
Assert.Equal("delivered", tenantEvent.Properties["outcome"]);
|
||||
var platformEvent = Assert.Single(deliveries, e => e.Properties["scope"] == "platform");
|
||||
Assert.Equal(string.Empty, platformEvent.Properties["tenantId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maintenance_reports_the_inventory_it_found_before_sweeping()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
var h = new Harness(Telemetry(channel));
|
||||
var now = DateTime.UtcNow;
|
||||
h.SignIn("admin@contoso.invalid", now.AddDays(-1));
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active).LastOpenedUtc = now.AddDays(-2);
|
||||
h.AddDevice("pending-old", Constants.Push.DeviceStatus.Pending, pairedUtc: now.AddMinutes(-30));
|
||||
h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope);
|
||||
|
||||
var result = await h.Maintenance.RunAsync(now);
|
||||
|
||||
Assert.Equal(1, result.PendingDevicesDeleted);
|
||||
var e = Assert.Single(channel.Items.OfType<EventTelemetry>(), i => i.Name == PushDeviceInventory.EventName);
|
||||
Assert.Equal(1, e.Metrics["tenants"]);
|
||||
Assert.Equal(2, e.Metrics["devices"]); // the Pending row still counts: the state the run found
|
||||
Assert.Equal(1, e.Metrics["active"]);
|
||||
Assert.Equal(1, e.Metrics["pending"]);
|
||||
Assert.Equal(1, e.Metrics["opened7d"]);
|
||||
Assert.Equal(1, e.Metrics["ios"]);
|
||||
Assert.Equal(1, e.Metrics["platformDevices"]);
|
||||
Assert.Empty(e.Properties); // counts only, no ids
|
||||
}
|
||||
|
||||
// ── SendSystemToOwnerAsync: the session-watch fan-out ────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -490,20 +539,24 @@ public class PushDeliveryServiceTests
|
||||
private static PushPayload SessionFinished()
|
||||
=> PushAlertProjector.SystemMessage("session_watch", "Session finished", "DESKTOP-4711 finished enrollment.", "success", Tenant, DateTime.UtcNow);
|
||||
|
||||
private static TelemetryClient Telemetry(CapturingChannel channel)
|
||||
internal static TelemetryClient Telemetry(CapturingChannel channel)
|
||||
=> new(new TelemetryConfiguration { TelemetryChannel = channel, ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000" });
|
||||
|
||||
/// <summary>The PushPairing lifecycle events only — every send also tracks a PushDelivery event.</summary>
|
||||
private static List<EventTelemetry> PairingEvents(CapturingChannel channel)
|
||||
=> channel.Items.OfType<EventTelemetry>().Where(e => e.Name == "PushPairing").ToList();
|
||||
|
||||
/// <summary>Captures what the service tracks (the startup-telemetry test keeps its own copy private).</summary>
|
||||
private sealed class CapturingChannel : ITelemetryChannel
|
||||
/// <summary>
|
||||
/// Captures what the service tracks (the startup-telemetry test keeps its own copy private). The fan-out
|
||||
/// sends to several devices in parallel, each tracking from its own thread: writes lock, reads get a snapshot.
|
||||
/// </summary>
|
||||
internal sealed class CapturingChannel : ITelemetryChannel
|
||||
{
|
||||
public List<ITelemetry> Items { get; } = new();
|
||||
private readonly List<ITelemetry> _items = new();
|
||||
public List<ITelemetry> Items { get { lock (_items) return _items.ToList(); } }
|
||||
public bool? DeveloperMode { get; set; }
|
||||
public string EndpointAddress { get; set; } = "";
|
||||
public void Send(ITelemetry item) => Items.Add(item);
|
||||
public void Send(ITelemetry item) { lock (_items) _items.Add(item); }
|
||||
public void Flush() { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -599,7 +652,7 @@ public class PushDeliveryServiceTests
|
||||
Sut = new PushDeliveryService(Repo, Eligibility.Object, Settings, new WebPushSender(new HttpClient(Http)),
|
||||
new Lazy<OpsEventService>(() => OpsEvents.Object), new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<PushDeliveryService>.Instance, telemetry);
|
||||
Maintenance = new PushMaintenanceService(Repo, Settings, Sut, NullLogger<PushMaintenanceService>.Instance);
|
||||
Maintenance = new PushMaintenanceService(Repo, Settings, Sut, NullLogger<PushMaintenanceService>.Instance, telemetry);
|
||||
|
||||
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
|
||||
var p = ua.ExportParameters(false);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>The maintenance run's device count: tenant scope only (the platform scope apart), statuses, platforms, open and delivery windows.</summary>
|
||||
public class PushDeviceInventoryTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private static readonly DateTime Now = new(2026, 10, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Counts_tenants_statuses_platforms_and_the_open_and_delivery_windows()
|
||||
{
|
||||
var inventory = PushDeviceInventory.Count(new[]
|
||||
{
|
||||
Device(TenantA, Constants.Push.DeviceStatus.Active, "ios-homescreen", opened: Now.AddDays(-1), delivered: Now.AddDays(-6)),
|
||||
Device(TenantA, Constants.Push.DeviceStatus.Active, "android-chrome", opened: Now.AddDays(-8), delivered: Now.AddDays(-8)),
|
||||
Device(TenantB, Constants.Push.DeviceStatus.Active, "something-new"),
|
||||
Device(TenantB, Constants.Push.DeviceStatus.Paused, "ios-homescreen", opened: Now.AddDays(-31)),
|
||||
Device(TenantB, Constants.Push.DeviceStatus.Stale, "windows-chromium"),
|
||||
Device(TenantB, Constants.Push.DeviceStatus.Pending, "firefox"),
|
||||
Device(Constants.Push.PlatformScope, Constants.Push.DeviceStatus.Active, "ios-homescreen", opened: Now),
|
||||
}, Now);
|
||||
|
||||
Assert.Equal(2, inventory.Tenants);
|
||||
Assert.Equal(6, inventory.Devices);
|
||||
Assert.Equal(3, inventory.Active);
|
||||
Assert.Equal(1, inventory.Paused);
|
||||
Assert.Equal(1, inventory.Stale);
|
||||
Assert.Equal(1, inventory.Pending);
|
||||
Assert.Equal(1, inventory.Opened7d);
|
||||
Assert.Equal(2, inventory.Opened30d);
|
||||
Assert.Equal(1, inventory.Delivered7d);
|
||||
Assert.Equal(1, inventory.PlatformDevices);
|
||||
|
||||
var m = inventory.ToMeasurements();
|
||||
Assert.Equal(1, m["ios"]);
|
||||
Assert.Equal(1, m["android"]);
|
||||
Assert.Equal(1, m["other"]); // an unknown platform string lands in "other"
|
||||
Assert.Equal(0, m["windows"]); // only Active devices count per platform
|
||||
Assert.Equal(0, m["macos"]);
|
||||
Assert.Equal(0, m["firefox"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_tenant_with_only_paused_devices_is_not_a_push_tenant_and_no_devices_report_zeros()
|
||||
{
|
||||
var paused = PushDeviceInventory.Count(new[] { Device(TenantA, Constants.Push.DeviceStatus.Paused, "ios-homescreen") }, Now);
|
||||
Assert.Equal(0, paused.Tenants);
|
||||
Assert.Equal(1, paused.Devices);
|
||||
|
||||
var none = PushDeviceInventory.Count(Array.Empty<PushDevice>(), Now).ToMeasurements();
|
||||
Assert.All(none.Values, v => Assert.Equal(0, v));
|
||||
Assert.Equal(16, none.Count);
|
||||
}
|
||||
|
||||
private static PushDevice Device(string scope, string status, string platform, DateTime? opened = null, DateTime? delivered = null) => new()
|
||||
{
|
||||
Scope = scope,
|
||||
DeviceId = Guid.NewGuid().ToString("N"),
|
||||
Status = status,
|
||||
Platform = platform,
|
||||
PairedUtc = Now.AddDays(-40),
|
||||
LastOpenedUtc = opened,
|
||||
LastDeliveredUtc = delivered,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,8 @@ using AutopilotMonitor.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
@@ -73,6 +75,24 @@ public class PushPairingServiceTests
|
||||
Assert.Equal("My iPhone", listed.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pairing_events_name_the_tenant_and_a_platform_pairing_none()
|
||||
{
|
||||
var channel = new PushDeliveryServiceTests.CapturingChannel();
|
||||
var h = new Harness(telemetry: PushDeliveryServiceTests.Telemetry(channel));
|
||||
|
||||
var created = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code));
|
||||
await h.Sut.ConfirmAsync(Tenant, created.Value.PairingId, Admin);
|
||||
await h.Sut.CreateAsync(NotificationScope.Platform, Admin);
|
||||
|
||||
var events = channel.Items.OfType<EventTelemetry>().Where(e => e.Name == "PushPairing").ToList();
|
||||
Assert.Equal(new[] { "created", "redeemed", "confirmed", "created" }, events.Select(e => e.Properties["action"]));
|
||||
Assert.All(events.Take(3), e => Assert.Equal(TenantId, e.Properties["tenantId"]));
|
||||
Assert.Equal("platform", events[3].Properties["scope"]);
|
||||
Assert.Equal(string.Empty, events[3].Properties["tenantId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_code_redeems_exactly_once()
|
||||
{
|
||||
@@ -454,7 +474,8 @@ public class PushPairingServiceTests
|
||||
|
||||
/// <param name="keys">The platform's key ring; a rotated ring for the key-migration tests.</param>
|
||||
/// <param name="repo">A repository shared with another harness: the same device rows seen under a new ring.</param>
|
||||
public Harness(VapidKeyRing? keys = null, InMemoryPushDeviceRepository? repo = null)
|
||||
/// <param name="telemetry">Optional: a client over a capturing channel to observe what the pairing service tracks.</param>
|
||||
public Harness(VapidKeyRing? keys = null, InMemoryPushDeviceRepository? repo = null, TelemetryClient? telemetry = null)
|
||||
{
|
||||
Keys = keys ?? new VapidKeyRing(VapidKey.Generate());
|
||||
Repo = repo ?? new InMemoryPushDeviceRepository();
|
||||
@@ -478,7 +499,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);
|
||||
Mock.Of<IMaintenanceRepository>(), 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);
|
||||
|
||||
@@ -25,10 +25,7 @@ public class QueryParamGuardTests
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "se= expiry inside the tenant's stored SAS URL",
|
||||
// Content-Length header guard on body-taking routes (long.TryParse on the header value).
|
||||
["Functions/Admin/SubmitOffboardingFeedbackFunction.cs"] = "Content-Length header",
|
||||
["Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = "Content-Length header",
|
||||
["Functions/Ingest/ReportAgentErrorFunction.cs"] = "Content-Length header",
|
||||
["Functions/Ingest/ReportDistressFunction.cs"] = "Content-Length header",
|
||||
["Functions/Sessions/RegisterSessionFunction.cs"] = "Content-Length header",
|
||||
};
|
||||
|
||||
private static string FunctionsRoot()
|
||||
|
||||
@@ -136,6 +136,16 @@ public class RawTablePaginationTests
|
||||
Assert.DoesNotContain("STALE", link);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNextLink_echoes_fields_so_a_follow_up_page_keeps_the_projection()
|
||||
{
|
||||
var query = new NameValueCollection { { "fields", "Status,StartedAt" } };
|
||||
|
||||
var link = RawTablePagination.BuildNextLink("Sessions", 200, "abc", query);
|
||||
|
||||
Assert.Contains("fields=Status%2CStartedAt", link);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildNextLink_url_encodes_table_name_with_special_chars()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Reports;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The per-instance cap on concurrent report uploads: a caller past the limit gets 429 with
|
||||
/// Retry-After, the first refusal of a window raises ReportUploadLimitReached while later ones in
|
||||
/// that window stay in the trace, and a released slot is free again.
|
||||
/// </summary>
|
||||
public class ReportUploadGateTests
|
||||
{
|
||||
private const string Upn = "admin@example.com";
|
||||
|
||||
private readonly List<OpsEventEntry> _opsEvents = new();
|
||||
|
||||
[Fact]
|
||||
public async Task Caller_past_the_limit_gets_429_and_one_ops_event_per_window()
|
||||
{
|
||||
var gate = CreateGate(limit: 1);
|
||||
using var upload = gate.TryEnter();
|
||||
Assert.NotNull(upload);
|
||||
Assert.Null(gate.TryEnter());
|
||||
|
||||
var (req, _) = EndpointHarness.Request("tenant-a", Upn);
|
||||
var refused = await gate.RefuseAsync(req, "sessions/{sessionId}/report", "tenant-a", Upn);
|
||||
await gate.RefuseAsync(req, "diagnostics/files", "tenant-a", Upn);
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.RateLimited, EndpointHarness.ErrorCode(refused));
|
||||
Assert.True(refused.Headers.TryGetValues("Retry-After", out var retryAfter));
|
||||
Assert.Equal("5", retryAfter!.Single());
|
||||
|
||||
var alert = Assert.Single(_opsEvents);
|
||||
Assert.Equal(OpsEventTypes.ReportUploadLimitReached, alert.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, alert.Severity);
|
||||
Assert.Equal("tenant-a", alert.TenantId);
|
||||
Assert.Contains("sessions/{sessionId}/report", alert.Details);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alert_is_taken_once_per_window()
|
||||
{
|
||||
var gate = CreateGate(limit: 1);
|
||||
var start = new DateTime(2026, 10, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Assert.True(gate.TakeAlert(start));
|
||||
Assert.False(gate.TakeAlert(start.AddMinutes(59)));
|
||||
Assert.True(gate.TakeAlert(start + ReportUploadGate.AlertWindow));
|
||||
Assert.False(gate.TakeAlert(start + ReportUploadGate.AlertWindow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Released_slot_is_free_again_and_an_instance_takes_three()
|
||||
{
|
||||
var gate = CreateGate(limit: 1);
|
||||
gate.TryEnter()!.Dispose();
|
||||
Assert.NotNull(gate.TryEnter());
|
||||
|
||||
var production = new ReportUploadGate(CreateOps(), NullLogger<ReportUploadGate>.Instance);
|
||||
Assert.Equal(3, production.Limit);
|
||||
var slots = Enumerable.Range(0, 3).Select(_ => production.TryEnter()).ToList();
|
||||
Assert.All(slots, Assert.NotNull);
|
||||
Assert.Null(production.TryEnter());
|
||||
}
|
||||
|
||||
private ReportUploadGate CreateGate(int limit) => new(limit, CreateOps(), NullLogger<ReportUploadGate>.Instance);
|
||||
|
||||
private OpsEventService CreateOps()
|
||||
{
|
||||
var repo = new Mock<IOpsEventRepository>();
|
||||
repo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Returns<OpsEventEntry>(e => { lock (_opsEvents) _opsEvents.Add(e); return Task.CompletedTask; });
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
return new OpsEventService(repo.Object, NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,39 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Push;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Backup;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using BadHttpRequestException = Microsoft.AspNetCore.Http.BadHttpRequestException;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The one body reader (D-207): camelCase and PascalCase both bind, string enums bind, an empty
|
||||
/// or malformed body is a 400 envelope (never a 500), the optional variant treats "no body" as
|
||||
/// null, the byte cap and the Newtonsoft branch behave the same way.
|
||||
/// The one body reader (D-207): camelCase and PascalCase both bind, string enums bind, an empty,
|
||||
/// malformed or aborted body is a 400 envelope (never a 500), the optional variant treats "no body" as
|
||||
/// null, the byte cap and the Newtonsoft branch behave the same way. The cap counts the bytes actually
|
||||
/// read, so neither a chunked nor a gzip body gets past it; the pipeline tests run the production
|
||||
/// registration on real Kestrel, through the real push handler, and pin the worker's body limit.
|
||||
/// </summary>
|
||||
public class RequestBodyTests
|
||||
{
|
||||
@@ -85,6 +99,16 @@ public class RequestBodyTests
|
||||
await AssertBadRequest(read.Error, "Invalid JSON body");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aborted_body_is_400_incomplete_not_500()
|
||||
{
|
||||
// The client closed the tab mid-body: before this, the handler's catch turned it into a 500.
|
||||
var req = Build(new ThrowingBodyStream(new BadHttpRequestException("Unexpected end of request content.")));
|
||||
var read = await req.ReadAsync<SignalRJoinGroupRequest>();
|
||||
await AssertBadRequest(read.Error, "Request body incomplete");
|
||||
Assert.Null(read.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Json_null_body_is_400_required()
|
||||
{
|
||||
@@ -109,13 +133,78 @@ public class RequestBodyTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Byte_cap_applies_only_when_given()
|
||||
public async Task Cap_is_strictly_greater_than()
|
||||
{
|
||||
var capped = await Build("{\"name\":\"x\"}", contentLength: 2_000_000).ReadAsync<CreateTenantGroupRequest>(maxBytes: 1_048_576);
|
||||
await AssertBadRequest(capped.Error, "Request body too large");
|
||||
var atCap = await Build(new MemoryStream(PaddedJson("{\"code\":\"x\"", 8192))).ReadAsync<BeginPairRequest>(maxBytes: 8192);
|
||||
Assert.Null(atCap.Error);
|
||||
Assert.Equal("x", atCap.Value!.Code);
|
||||
|
||||
var uncapped = await Build("{\"name\":\"x\"}", contentLength: 2_000_000).ReadAsync<CreateTenantGroupRequest>();
|
||||
Assert.Null(uncapped.Error);
|
||||
var pastCap = await Build(new MemoryStream(PaddedJson("{\"code\":\"x\"", 8193))).ReadAsync<BeginPairRequest>(maxBytes: 8192);
|
||||
await AssertBadRequest(pastCap.Error, "Request body too large");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_site_without_a_cap_reads_under_the_default()
|
||||
{
|
||||
var atDefault = await Build(new MemoryStream(PaddedJson("{\"name\":\"x\"", (int)RequestBody.DefaultMaxBytes)))
|
||||
.ReadAsync<CreateTenantGroupRequest>();
|
||||
Assert.Null(atDefault.Error);
|
||||
|
||||
var pastDefault = await Build(new MemoryStream(PaddedJson("{\"name\":\"x\"", (int)RequestBody.DefaultMaxBytes + 1)))
|
||||
.ReadAsync<CreateTenantGroupRequest>();
|
||||
await AssertBadRequest(pastDefault.Error, "Request body too large");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Byte_order_mark_is_honoured_as_before()
|
||||
{
|
||||
var withBom = Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("{\"name\":\"Group A\"}")).ToArray();
|
||||
var read = await Build(new MemoryStream(withBom)).ReadAsync<CreateTenantGroupRequest>();
|
||||
Assert.Null(read.Error);
|
||||
Assert.Equal("Group A", read.Value!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capped_buffer_is_null_past_the_cap_and_rewound_within_it()
|
||||
{
|
||||
Assert.Null(await Build(new MemoryStream(new byte[101])).ReadCappedAsync(100));
|
||||
|
||||
using var within = await Build("{\"a\":1}").ReadCappedAsync(100);
|
||||
Assert.NotNull(within);
|
||||
Assert.Equal(0, within!.Position);
|
||||
Assert.Equal("{\"a\":1}", Encoding.UTF8.GetString(within.ToArray()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Chunked_body_is_refused_one_byte_past_the_cap()
|
||||
{
|
||||
// Chunked transfer carries no Content-Length: the cap must hold on the bytes actually read.
|
||||
var body = new CountingStream(new MemoryStream(PaddedJson("{\"code\":\"x\"", 64 * 1024)));
|
||||
var read = await Build(body).ReadAsync<BeginPairRequest>(maxBytes: 8192);
|
||||
await AssertBadRequest(read.Error, "Request body too large");
|
||||
Assert.True(body.BytesRead <= 8192 + 1, $"read {body.BytesRead} bytes against an 8 KiB cap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gzip_body_is_capped_on_its_inflated_bytes()
|
||||
{
|
||||
// UseRequestDecompression hands the handler the inflating stream, while Content-Length still names
|
||||
// the compressed size, far below the cap.
|
||||
var compressed = Gzip(PaddedJson("{\"code\":\"x\"", 1024 * 1024));
|
||||
Assert.True(compressed.Length < 8192);
|
||||
var inflated = new CountingStream(new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress));
|
||||
var read = await Build(inflated, contentLength: compressed.Length).ReadAsync<BeginPairRequest>(maxBytes: 8192);
|
||||
await AssertBadRequest(read.Error, "Request body too large");
|
||||
Assert.True(inflated.BytesRead <= 8192 + 1, $"inflated {inflated.BytesRead} bytes against an 8 KiB cap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_limit_refusal_is_too_large_not_incomplete()
|
||||
{
|
||||
// Kestrel and UseRequestDecompression answer the worker's own limit with a 413 BadHttpRequestException.
|
||||
var read = await Build(new ThrowingBodyStream(new BadHttpRequestException("Request body too large.", 413)))
|
||||
.ReadAsync<BeginPairRequest>(maxBytes: 8192);
|
||||
await AssertBadRequest(read.Error, "Request body too large");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -150,6 +239,147 @@ public class RequestBodyTests
|
||||
await AssertBadRequest(array.Error, "Body must be a JSON object");
|
||||
}
|
||||
|
||||
// ── the production pipeline: real Kestrel with AddWorkerHttpPipeline ───────
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_push_route_refuses_a_gzip_bomb_before_inflating_it()
|
||||
{
|
||||
var compressed = Gzip(PaddedJson("{\"code\":\"x\"", 4 * 1024 * 1024));
|
||||
var content = new ByteArrayContent(compressed);
|
||||
content.Headers.ContentEncoding.Add("gzip");
|
||||
|
||||
var (status, error, bytesRead) = await PostThroughPipelineAsync(content, PushBeginAsync);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, status);
|
||||
Assert.Equal("Request body too large", error);
|
||||
Assert.True(bytesRead <= 8192 + 1, $"inflated {bytesRead} bytes against the push route's 8 KiB cap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_push_route_refuses_a_chunked_body_at_the_cap()
|
||||
{
|
||||
var content = new StreamContent(new MemoryStream(PaddedJson("{\"code\":\"x\"", 64 * 1024)));
|
||||
|
||||
var (status, error, bytesRead) = await PostThroughPipelineAsync(content, PushBeginAsync, chunked: true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, status);
|
||||
Assert.Equal("Request body too large", error);
|
||||
Assert.True(bytesRead <= 8192 + 1, $"read {bytesRead} bytes against the push route's 8 KiB cap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_worker_limit_bounds_what_a_gzip_body_inflates_to()
|
||||
{
|
||||
// A read without an effective cap stands in for any read that escapes one: Kestrel's default
|
||||
// (30,000,000 bytes) would inflate and accept this body; the worker limit refuses it.
|
||||
var compressed = Gzip(PaddedJson("{\"code\":\"x\"", (int)RequestBody.WorkerMaxBytes + 1024 * 1024));
|
||||
var content = new ByteArrayContent(compressed);
|
||||
content.Headers.ContentEncoding.Add("gzip");
|
||||
|
||||
var (status, error, bytesRead) = await PostThroughPipelineAsync(content, ReadUncappedAsync);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, status);
|
||||
Assert.Equal("Request body too large", error);
|
||||
Assert.True(bytesRead <= RequestBody.WorkerMaxBytes, $"inflated {bytesRead} bytes past the worker limit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_kestrel_refuses_a_declared_length_past_the_worker_limit_unread()
|
||||
{
|
||||
var content = new ByteArrayContent(new byte[RequestBody.WorkerMaxBytes + 1]);
|
||||
|
||||
var (status, error, bytesRead) = await PostThroughPipelineAsync(content, ReadUncappedAsync, expectContinue: true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, status);
|
||||
Assert.Equal("Request body too large", error);
|
||||
Assert.Equal(0, bytesRead);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pipeline_still_inflates_an_honest_gzip_body()
|
||||
{
|
||||
var content = new ByteArrayContent(Gzip(Encoding.UTF8.GetBytes("{\"code\":\"x\"}")));
|
||||
content.Headers.ContentEncoding.Add("gzip");
|
||||
|
||||
var (status, error, _) = await PostThroughPipelineAsync(content, ReadPairCodeAsync);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, status);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Worker_body_limit_survives_the_functions_host_composition()
|
||||
{
|
||||
// Program.cs order, plus two would-be overrides: a Kestrel:Limits app setting and a later Configure.
|
||||
var builder = FunctionsApplication.CreateBuilder(Array.Empty<string>());
|
||||
builder.Configuration["Kestrel:Limits:MaxRequestBodySize"] = "104857600";
|
||||
builder.ConfigureFunctionsWebApplication();
|
||||
builder.Services.AddWorkerHttpPipeline();
|
||||
builder.Services.Configure<KestrelServerOptions>(o => o.Limits.MaxRequestBodySize = null);
|
||||
|
||||
using var provider = builder.Services.BuildServiceProvider();
|
||||
Assert.Equal(RequestBody.WorkerMaxBytes, provider.GetRequiredService<IOptions<KestrelServerOptions>>().Value.Limits.MaxRequestBodySize);
|
||||
Assert.Contains(provider.GetServices<IStartupFilter>(), filter => filter is HttpCompressionStartupFilter);
|
||||
}
|
||||
|
||||
/// <summary>The real push handler; the pairing service is never reached when the read fails first.</summary>
|
||||
private static Task<HttpResponseData> PushBeginAsync(HttpRequestData req)
|
||||
=> new PushReceiverFunctions(null!,
|
||||
new RateLimitService(new MemoryCache(new MemoryCacheOptions()), NullLogger<RateLimitService>.Instance),
|
||||
NullLogger<PushReceiverFunctions>.Instance)
|
||||
.Begin(req);
|
||||
|
||||
private static async Task<HttpResponseData> ReadUncappedAsync(HttpRequestData req)
|
||||
=> (await req.ReadAsync<BeginPairRequest>(maxBytes: long.MaxValue)).Error ?? req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
private static async Task<HttpResponseData> ReadPairCodeAsync(HttpRequestData req)
|
||||
{
|
||||
var read = await req.ReadAsync<BeginPairRequest>(maxBytes: 8192);
|
||||
return read.Error ?? req.CreateResponse(read.Value!.Code == "x" ? HttpStatusCode.OK : HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts <paramref name="content"/> through real Kestrel with the production registration
|
||||
/// (AddWorkerHttpPipeline: the worker body limit, and UseRequestDecompression through the startup
|
||||
/// filter) to <paramref name="handle"/>, which receives the request body as the Functions worker
|
||||
/// would hand it over. Returns the status, the error envelope's message, and the body bytes the
|
||||
/// handler pulled (for a gzip body: inflated bytes).
|
||||
/// </summary>
|
||||
private static async Task<(HttpStatusCode Status, string? Error, long BytesRead)> PostThroughPipelineAsync(
|
||||
HttpContent content, Func<HttpRequestData, Task<HttpResponseData>> handle, bool chunked = false, bool expectContinue = false)
|
||||
{
|
||||
CountingStream? body = null;
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.AddWorkerHttpPipeline();
|
||||
await using var app = builder.Build();
|
||||
app.Run(async context =>
|
||||
{
|
||||
body = new CountingStream(context.Request.Body);
|
||||
var response = await handle(Build(body, context.Request.ContentLength));
|
||||
context.Response.StatusCode = (int)response.StatusCode;
|
||||
response.Body.Position = 0;
|
||||
await response.Body.CopyToAsync(context.Response.Body);
|
||||
});
|
||||
await app.StartAsync();
|
||||
|
||||
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/test") { Content = content };
|
||||
request.Headers.TransferEncodingChunked = chunked;
|
||||
request.Headers.ExpectContinue = expectContinue;
|
||||
using var response = await client.SendAsync(request);
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
await app.StopAsync();
|
||||
|
||||
string? error = null;
|
||||
if (text.Length > 0)
|
||||
{
|
||||
using var envelope = JsonDocument.Parse(text);
|
||||
if (envelope.RootElement.TryGetProperty("error", out var message)) error = message.GetString();
|
||||
}
|
||||
return (response.StatusCode, error, body?.BytesRead ?? 0);
|
||||
}
|
||||
|
||||
// ── fakes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task AssertBadRequest(HttpResponseData? error, string message)
|
||||
@@ -165,6 +395,9 @@ public class RequestBodyTests
|
||||
}
|
||||
|
||||
private static HttpRequestData Build(string body, long? contentLength = null)
|
||||
=> Build(new MemoryStream(Encoding.UTF8.GetBytes(body)), contentLength);
|
||||
|
||||
private static HttpRequestData Build(Stream body, long? contentLength = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
@@ -180,11 +413,68 @@ public class RequestBodyTests
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
req.SetupGet(r => r.Headers).Returns(headers);
|
||||
req.SetupGet(r => r.Body).Returns(new MemoryStream(Encoding.UTF8.GetBytes(body)));
|
||||
req.SetupGet(r => r.Body).Returns(body);
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object));
|
||||
return req.Object;
|
||||
}
|
||||
|
||||
/// <summary>Valid JSON: <paramref name="head"/>, spaces, and the closing brace, <paramref name="length"/> bytes in all.</summary>
|
||||
private static byte[] PaddedJson(string head, int length)
|
||||
=> Encoding.UTF8.GetBytes(head + new string(' ', length - head.Length - 1) + "}");
|
||||
|
||||
private static byte[] Gzip(byte[] data)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize))
|
||||
gzip.Write(data);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What Kestrel's request body throws: a client that disconnects mid-body (400), or the worker's
|
||||
/// own body limit, raised by Kestrel or UseRequestDecompression (413).
|
||||
/// </summary>
|
||||
private sealed class ThrowingBodyStream : MemoryStream
|
||||
{
|
||||
private readonly BadHttpRequestException _error;
|
||||
|
||||
public ThrowingBodyStream(BadHttpRequestException error) => _error = error;
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw _error;
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => throw _error;
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) => throw _error;
|
||||
}
|
||||
|
||||
/// <summary>Counts the bytes the reader pulled from the body (for a gzip body: the inflated bytes).</summary>
|
||||
private sealed class CountingStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
|
||||
public CountingStream(Stream inner) => _inner = inner;
|
||||
|
||||
public long BytesRead { get; private set; }
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => BytesRead; set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => Count(_inner.Read(buffer, offset, count));
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
=> Count(await _inner.ReadAsync(buffer, cancellationToken));
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
=> ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
private int Count(int read)
|
||||
{
|
||||
BytesRead += read;
|
||||
return read;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
|
||||
@@ -7,21 +7,22 @@ using Moq;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Session 080edee9 follow-up — pins the analyze-rule shape for the canonical detection-
|
||||
/// failure scenario (Microsoft Apps for Enterprise + HRESULT 0x87D1041C).
|
||||
/// Pins the analyze rules for an app that IME did not detect after its installer finished
|
||||
/// (HRESULT 0x87D1041C). The verdict is IME's own error code, never <c>detectionResult</c>:
|
||||
/// that field is IME's check BEFORE the install, and every installed app starts out
|
||||
/// NotDetected, so it names no detection failure (D-392).
|
||||
///
|
||||
/// The code reaches <c>app_install_failed</c> in two shapes:
|
||||
/// - IME's report delta: the signed decimal the IME log writes, -2016345060, after the app
|
||||
/// had already been reported installed (IME flips Success → Error after its check).
|
||||
/// - The ESP promotion of an app still installing when the ESP failed: hex 0x87d1041c plus
|
||||
/// failureType esp_apps_detection_failure.
|
||||
///
|
||||
/// The customer-visible expectation:
|
||||
/// - ANALYZE-APP-013 fires CRITICAL with the failed app name + HRESULT.
|
||||
/// - ANALYZE-ENRL-001 fires CRITICAL referencing the same app + HRESULT.
|
||||
/// - ANALYZE-CORR-001 ("Slow Network") does NOT fire — there was no slow network.
|
||||
/// - ANALYZE-APP-009 ("App Download Stalled") does NOT fire — the synthetic
|
||||
/// `download_progress: failed` emitted by the V2 termination handler is a
|
||||
/// post-mortem signal, not a real download stall.
|
||||
///
|
||||
/// Both APP-013 and ENRL-001 must work for HISTORICAL events emitted by agents
|
||||
/// shipped BEFORE the AppFailureTypes classifier landed (the legacy shape:
|
||||
/// `detectionResult: NotDetected` + co-located `esp_failure_settle_started`
|
||||
/// errorCode=0x87d1041c, with the old `failureType: esp_apps_timeout` tag).
|
||||
/// - ANALYZE-APP-001 names the app in any enrollment outcome.
|
||||
/// - ANALYZE-APP-013 fires CRITICAL with the app when the ESP terminated on 0x87D1041C.
|
||||
/// - ANALYZE-ENRL-001 references the same app + HRESULT.
|
||||
/// - An app that failed for another reason (download, installer) is never a detection failure.
|
||||
/// </summary>
|
||||
public class RuleEngineDetectionFailureTests
|
||||
{
|
||||
@@ -29,22 +30,72 @@ public class RuleEngineDetectionFailureTests
|
||||
private const string SessionId = "080edee9-9f2e-47b9-b8f0-59fe18b8a8ad";
|
||||
private const string FailedAppId = "f9026516-dbec-47cd-ae4e-ec2312a1303c";
|
||||
private const string FailedAppName = "Microsoft Apps for Enterprise - 64Bit";
|
||||
private const string OtherAppId = "6c1f0e52-3b8d-4f4e-9a6e-2d7c5b9e1a40";
|
||||
private const string OtherAppName = "Contoso VPN Client";
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_APP_013_fires_on_legacy_detectionResult_plus_errorCode_shape()
|
||||
public async Task ANALYZE_APP_001_fires_on_IME_report_after_the_app_was_reported_installed()
|
||||
{
|
||||
// The real IME sequence: "Execution state … Success" (app_install_completed), then IME's
|
||||
// check after the install, then Success → Error with -2016345060. The completed event
|
||||
// must not silence the finding.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-001");
|
||||
Assert.Equal("2.0.0", rule.Version);
|
||||
Assert.True(rule.Enabled);
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppInstallCompleted(sequence: 134),
|
||||
AppInstallFailedImeReport(errorCode: "-2016345060", sequence: 139),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
var evidence = AsDict(result.MatchedConditions["detection_failure"]);
|
||||
Assert.Equal(FailedAppName, AsString(evidence["appName"]));
|
||||
Assert.Equal("-2016345060", AsString(evidence["errorCode"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_APP_001_fires_on_the_ESP_promotion_shape()
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-001");
|
||||
|
||||
var outcome = await RunAsync(rule, new List<EnrollmentEvent> { AppInstallFailedEspPromotion() });
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
var evidence = AsDict(result.MatchedConditions["detection_failure"]);
|
||||
Assert.Equal("0x87d1041c", AsString(evidence["errorCode"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_APP_001_does_not_fire_on_an_install_failure_with_another_code()
|
||||
{
|
||||
// A download error carries detectionResult NotDetected like every app that was not
|
||||
// installed yet; the old rule read that as "not detected after the install".
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-001");
|
||||
|
||||
var outcome = await RunAsync(rule, new List<EnrollmentEvent>
|
||||
{
|
||||
AppInstallFailedImeReport(errorCode: "-2145091577", sequence: 120),
|
||||
});
|
||||
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_APP_013_names_the_app_IME_reported_with_0x87D1041C()
|
||||
{
|
||||
// Legacy agent shape (pre-classifier): app_install_failed carries
|
||||
// failureType=esp_apps_timeout (the old default) but ALSO detectionResult=NotDetected,
|
||||
// and there's a co-located esp_failure_settle_started with errorCode=0x87d1041c.
|
||||
// The rule must fire and the explanation must interpolate to the Office app.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
|
||||
Assert.Equal("1.3.0", rule.Version);
|
||||
Assert.Equal("1.4.0", rule.Version);
|
||||
Assert.True(rule.Enabled);
|
||||
Assert.Equal("critical", rule.Severity);
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppInstallFailedLegacyShape(detectionResult: "NotDetected"),
|
||||
AppInstallFailedImeReport(errorCode: "-2145091577", sequence: 120, appId: OtherAppId, appName: OtherAppName),
|
||||
AppInstallFailedImeReport(errorCode: "-2016345060", sequence: 139),
|
||||
EspFailureSettleStarted(errorCode: "0x87d1041c", failedSubcategory: "Apps"),
|
||||
};
|
||||
|
||||
@@ -54,22 +105,51 @@ public class RuleEngineDetectionFailureTests
|
||||
Assert.Equal("ANALYZE-APP-013", result.RuleId);
|
||||
Assert.Equal("critical", result.Severity);
|
||||
|
||||
// Backend stores the raw `{{...}}` template — the web interpolator
|
||||
// (`interpolateRuleTemplate.ts`) resolves them at render time. So we pin the
|
||||
// MATERIAL the interpolator needs: the matched-condition evidence dicts must
|
||||
// carry appName (via auto-field injection on the required condition's match)
|
||||
// AND errorCode (via the optional byField condition).
|
||||
Assert.True(result.MatchedConditions.ContainsKey("app_with_detection_failure"));
|
||||
// Backend stores the raw `{{...}}` template — the web interpolator resolves it at render
|
||||
// time. Pin the material it needs: the app from the detection-failure match (not the app
|
||||
// that failed its download) and the ESP code from the optional byField condition.
|
||||
var detectionEvidence = AsDict(result.MatchedConditions["app_with_detection_failure"]);
|
||||
Assert.Equal(FailedAppName, AsString(detectionEvidence["appName"]));
|
||||
Assert.Equal("NotDetected", AsString(detectionEvidence["value"]));
|
||||
Assert.Equal("-2016345060", AsString(detectionEvidence["value"]));
|
||||
|
||||
Assert.True(result.MatchedConditions.ContainsKey("esp_failure_errorcode"));
|
||||
var errorCodeEvidence = AsDict(result.MatchedConditions["esp_failure_errorcode"]);
|
||||
Assert.Equal("errorCode", AsString(errorCodeEvidence["field"]));
|
||||
Assert.Equal("0x87d1041c", AsString(errorCodeEvidence["value"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_APP_013_names_the_app_the_ESP_promotion_tagged()
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
|
||||
|
||||
var outcome = await RunAsync(rule, new List<EnrollmentEvent>
|
||||
{
|
||||
AppInstallFailedEspPromotion(),
|
||||
EspFailureSettleStarted(errorCode: "0x87d1041c", failedSubcategory: "Apps"),
|
||||
});
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
var detectionEvidence = AsDict(result.MatchedConditions["app_with_detection_failure"]);
|
||||
Assert.Equal(FailedAppName, AsString(detectionEvidence["appName"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_APP_013_does_not_name_an_app_that_failed_for_another_reason()
|
||||
{
|
||||
// The ESP failed on 0x87D1041C, but the only failed app the agent saw had a download
|
||||
// error. Its detectionResult NotDetected is the check before the install, so the rule
|
||||
// must not blame it.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
|
||||
|
||||
var outcome = await RunAsync(rule, new List<EnrollmentEvent>
|
||||
{
|
||||
AppInstallFailedImeReport(errorCode: "-2145091577", sequence: 120, appId: OtherAppId, appName: OtherAppName),
|
||||
EspFailureSettleStarted(errorCode: "0x87d1041c", failedSubcategory: "Apps"),
|
||||
});
|
||||
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_APP_013_does_not_fire_when_errorCode_is_not_0x87d1041c()
|
||||
{
|
||||
@@ -80,7 +160,7 @@ public class RuleEngineDetectionFailureTests
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppInstallFailedLegacyShape(detectionResult: "NotDetected"),
|
||||
AppInstallFailedImeReport(errorCode: "-2016345060", sequence: 139),
|
||||
EspFailureSettleStarted(errorCode: "0x8007064a", failedSubcategory: "SecurityPolicies"),
|
||||
};
|
||||
|
||||
@@ -91,15 +171,13 @@ public class RuleEngineDetectionFailureTests
|
||||
[Fact]
|
||||
public async Task ANALYZE_APP_013_does_not_fire_without_esp_failure_settle_started()
|
||||
{
|
||||
// Defense: a Win32 app with detectionResult=NotDetected but NO ESP terminal
|
||||
// failure (e.g. a retry that later succeeded, or a non-blocking app) must not
|
||||
// pull in the critical detection-failure-during-ESP narrative — that rule is
|
||||
// specifically about ESP-terminating detection failures.
|
||||
// Defense: a detection failure without an ESP terminal failure (a non-blocking app)
|
||||
// is ANALYZE-APP-001's finding, not the critical ESP narrative.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppInstallFailedLegacyShape(detectionResult: "NotDetected"),
|
||||
AppInstallFailedImeReport(errorCode: "-2016345060", sequence: 139),
|
||||
// No esp_failure_settle_started event — precondition fails.
|
||||
};
|
||||
|
||||
@@ -122,7 +200,7 @@ public class RuleEngineDetectionFailureTests
|
||||
{
|
||||
EnrollmentFailedEvent(reason: "esp_terminal_failure"),
|
||||
EspFailureSettleStarted(errorCode: "0x87d1041c", failedSubcategory: "Apps"),
|
||||
AppInstallFailedLegacyShape(detectionResult: "NotDetected"),
|
||||
AppInstallFailedEspPromotion(),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(rule, events);
|
||||
@@ -174,43 +252,78 @@ public class RuleEngineDetectionFailureTests
|
||||
Assert.DoesNotContain(BuiltInAnalyzeRules.GetAll(), r => r.RuleId == "ANALYZE-APP-009");
|
||||
}
|
||||
|
||||
// ===== Event builders (faithful to session 080edee9's actual payloads) =====
|
||||
[Fact]
|
||||
public void ANALYZE_APP_010_is_removed_from_builtin_catalog()
|
||||
{
|
||||
// "App Reverted to Error After Successful Install" matched completed → IME error report.
|
||||
// That flip is IME's check after the install failing (every one carries 0x87D1041C), so
|
||||
// since ANALYZE-APP-001 reads the code it would report the same event twice (D-392).
|
||||
Assert.DoesNotContain(BuiltInAnalyzeRules.GetAll(), r => r.RuleId == "ANALYZE-APP-010");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy app_install_failed shape — the one currently sitting in Table Storage
|
||||
/// for session 080edee9. Note: failureType is the OLD `esp_apps_timeout` tag (the
|
||||
/// new classifier hadn't shipped yet), errorCode is ABSENT, but detectionResult
|
||||
/// is populated by ImeLogTracker from the IME log.
|
||||
/// </summary>
|
||||
private static EnrollmentEvent AppInstallFailedLegacyShape(string detectionResult) => new()
|
||||
// ===== Event builders =====
|
||||
|
||||
private static Dictionary<string, object> AppData(string appId, string appName) => new()
|
||||
{
|
||||
["appId"] = appId,
|
||||
["appName"] = appName,
|
||||
["intent"] = "Install",
|
||||
["targeted"] = "Device",
|
||||
["runAs"] = "System",
|
||||
["attemptNumber"] = "1",
|
||||
// The check before the install: what every app carries until it is installed.
|
||||
["detectionResult"] = "NotDetected",
|
||||
};
|
||||
|
||||
private static EnrollmentEvent AppInstallCompleted(long sequence)
|
||||
{
|
||||
var data = AppData(FailedAppId, FailedAppName);
|
||||
data["state"] = "Installed";
|
||||
data["isCompleted"] = "true";
|
||||
data["exitCode"] = "0";
|
||||
return AppEvent("app_install_completed", sequence, data);
|
||||
}
|
||||
|
||||
/// <summary>The failed event IME-ERROR-REPORT produces once its errorCode group is captured.</summary>
|
||||
private static EnrollmentEvent AppInstallFailedImeReport(
|
||||
string errorCode, long sequence, string appId = FailedAppId, string appName = FailedAppName)
|
||||
{
|
||||
var data = AppData(appId, appName);
|
||||
data["state"] = "Error";
|
||||
data["isError"] = "true";
|
||||
data["isCompleted"] = "true";
|
||||
data["errorPatternId"] = "IME-ERROR-REPORT";
|
||||
data["errorCode"] = errorCode;
|
||||
data["errorDetail"] = "[Win32App][ReportingManager] Execution state for app with id: " + appId +
|
||||
" has been updated. Report delta: {\"EnforcementState\":{\"OldValue\":\"Success\",\"NewValue\":\"Error\"}," +
|
||||
"\"EnforcementErrorCode\":{\"OldValue\":null,\"NewValue\":" + errorCode + "}}";
|
||||
return AppEvent("app_install_failed", sequence, data);
|
||||
}
|
||||
|
||||
/// <summary>The termination handler's promotion of the app still installing at the ESP failure.</summary>
|
||||
private static EnrollmentEvent AppInstallFailedEspPromotion()
|
||||
{
|
||||
var data = AppData(FailedAppId, FailedAppName);
|
||||
data["state"] = "Error";
|
||||
data["isError"] = "true";
|
||||
data["isCompleted"] = "true";
|
||||
data["errorPatternId"] = "esp_apps_detection_failure";
|
||||
data["failureType"] = "esp_apps_detection_failure";
|
||||
data["errorCode"] = "0x87d1041c";
|
||||
data["confidence"] = "presumed";
|
||||
data["terminationTrigger"] = "EspTerminalFailure";
|
||||
return AppEvent("app_install_failed", 168, data);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent AppEvent(string eventType, long sequence, Dictionary<string, object> data) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "app_install_failed",
|
||||
EventType = eventType,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 168,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["appId"] = FailedAppId,
|
||||
["appName"] = FailedAppName,
|
||||
["state"] = "Error",
|
||||
["intent"] = "Install",
|
||||
["targeted"] = "Device",
|
||||
["runAs"] = "System",
|
||||
["progressPercent"] = "0",
|
||||
["bytesDownloaded"] = "10580224",
|
||||
["bytesTotal"] = "10580224",
|
||||
["isError"] = "true",
|
||||
["isCompleted"] = "true",
|
||||
["attemptNumber"] = "1",
|
||||
["detectionResult"] = detectionResult,
|
||||
["errorPatternId"] = "esp_apps_timeout",
|
||||
["failureType"] = "esp_apps_timeout",
|
||||
["confidence"] = "presumed",
|
||||
["errorDetail"] = "Install status unconfirmed — ESP timed out (180 min) while still installing.",
|
||||
["terminationTrigger"] = "EspTerminalFailure",
|
||||
}
|
||||
Sequence = sequence,
|
||||
Data = data,
|
||||
};
|
||||
|
||||
private static EnrollmentEvent EspFailureSettleStarted(string errorCode, string failedSubcategory) => new()
|
||||
|
||||
@@ -897,7 +897,7 @@ public class SessionsWireParityTests
|
||||
state = "Error",
|
||||
errorCode = "0x80d03805",
|
||||
exitCode = "1",
|
||||
detectionResult = "NotDetected",
|
||||
postInstallDetection = "NotDetected",
|
||||
installStarts = 4,
|
||||
lastStartedAt = startedAt,
|
||||
failedAt,
|
||||
@@ -969,7 +969,7 @@ public class SessionsWireParityTests
|
||||
State = "Error",
|
||||
ErrorCode = "0x80d03805",
|
||||
ExitCode = "1",
|
||||
DetectionResult = "NotDetected",
|
||||
PostInstallDetection = "NotDetected",
|
||||
InstallStarts = 4,
|
||||
LastStartedAt = startedAt,
|
||||
FailedAt = failedAt,
|
||||
|
||||
@@ -111,6 +111,36 @@ public sealed class ErrorCodeCatalogTests
|
||||
Assert.Null(ErrorCodeCatalog.TryLookupDetailed(raw));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0x87D1041C", "0x87d1041c")]
|
||||
[InlineData("0x0087d1041c", "0x87d1041c")]
|
||||
[InlineData("-2016345060", "0x87d1041c")] // the signed decimal the IME log writes
|
||||
[InlineData(" -2147024891 ", "0x80070005")]
|
||||
[InlineData("-2147483648", "0x80000000")]
|
||||
[InlineData("0x0", "0x00000000")]
|
||||
[InlineData("1603", "1603")] // exit codes stay decimal
|
||||
[InlineData("2147483647", "2147483647")]
|
||||
[InlineData("-2147483649", "-2147483649")] // outside the 32-bit range: no HRESULT
|
||||
[InlineData("0x800706431", "0x800706431")]
|
||||
[InlineData("esp_apps_timeout", "esp_apps_timeout")]
|
||||
public void NormalizeCode_writes_HRESULTs_as_canonical_hex_and_keeps_other_codes(string raw, string expected)
|
||||
=> Assert.Equal(expected, ErrorCodeCatalog.NormalizeCode(raw));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void NormalizeCode_returns_null_for_blank(string? raw)
|
||||
=> Assert.Null(ErrorCodeCatalog.NormalizeCode(raw));
|
||||
|
||||
[Fact]
|
||||
public void TryLookup_resolves_the_signed_decimal_of_an_HRESULT()
|
||||
{
|
||||
var hex = ErrorCodeCatalog.TryLookup("0x87d1041c");
|
||||
Assert.NotNull(hex);
|
||||
Assert.Same(hex, ErrorCodeCatalog.TryLookup("-2016345060"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryLookupDetailed_never_throws_for_any_short_tail_after_a_hex_prefix()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Feedback;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -54,10 +55,12 @@ public sealed class SubmitGeneralFeedbackFunctionTests
|
||||
Assert.Equal(NormalizedTenantId, ops.TenantId);
|
||||
Assert.Equal(Upn, ops.UserId);
|
||||
Assert.Contains("contoso.invalid", ops.Message);
|
||||
Assert.Contains(row.FeedbackId!, ops.Details);
|
||||
using var details = JsonDocument.Parse(ops.Details!);
|
||||
Assert.Equal("0123456789ab", details.RootElement.GetProperty("feedbackId").GetString());
|
||||
Assert.Equal("/admin/reports/user-feedback?feedbackId=0123456789ab", details.RootElement.GetProperty("portalPath").GetString());
|
||||
|
||||
h.Bell.Verify(b => b.CreateNotificationAsync(
|
||||
"feedback", "New Feedback", It.IsAny<string>(), "/admin/reports/user-feedback"), Times.Once);
|
||||
"feedback", "New Feedback", It.IsAny<string>(), "/admin/reports/user-feedback?feedbackId=0123456789ab"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -268,7 +271,7 @@ public sealed class SubmitGeneralFeedbackFunctionTests
|
||||
{
|
||||
if (ThrowOnSave is { } ex) throw ex;
|
||||
entry.Type = FeedbackEntryType.General;
|
||||
entry.FeedbackId = $"{DateTime.MaxValue.Ticks - entry.InteractedAt!.Value.Ticks:D19}_0123456789ab";
|
||||
entry.FeedbackId = "0123456789ab";
|
||||
Saved.Add(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
@@ -56,6 +57,11 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
|
||||
Assert.Equal("OffboardingFeedbackReceived", ops.EventType);
|
||||
Assert.Equal(TenantId, ops.TenantId);
|
||||
Assert.Equal(Upn, ops.UserId);
|
||||
|
||||
// The alert links the stored entry by the id the repository gave it.
|
||||
using var details = JsonDocument.Parse(ops.Details!);
|
||||
Assert.Equal("0123456789ab", details.RootElement.GetProperty("feedbackId").GetString());
|
||||
Assert.Equal("/admin/reports/user-feedback?feedbackId=0123456789ab", details.RootElement.GetProperty("portalPath").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -312,6 +318,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
|
||||
public Task SaveOffboardingFeedbackAsync(FeedbackEntry entry)
|
||||
{
|
||||
if (ThrowOnSaveOffboardingFeedback is { } ex) throw ex;
|
||||
entry.FeedbackId = "0123456789ab";
|
||||
PersistedOffboardingFeedback = entry;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ public class TableFeedbackRepositoryTests
|
||||
Assert.False(fetched.Dismissed);
|
||||
Assert.True(fetched.Submitted);
|
||||
Assert.Equal(entry.InteractedAt, fetched.InteractedAt);
|
||||
Assert.Matches(IdShape, fetched.FeedbackId!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -143,6 +144,7 @@ public class TableFeedbackRepositoryTests
|
||||
Assert.Equal("contoso.invalid", fetched.DomainName);
|
||||
Assert.Equal("Pricing was too high for our use case.", fetched.Comment);
|
||||
Assert.Equal(entry.InteractedAt, fetched.InteractedAt);
|
||||
Assert.Matches(IdShape, fetched.FeedbackId!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -327,6 +329,117 @@ public class TableFeedbackRepositoryTests
|
||||
private static async Task SaveInApp(Harness harness, FeedbackEntry entry)
|
||||
=> await harness.Sut.UpdateInAppFeedbackAsync(entry.Upn, _ => entry);
|
||||
|
||||
// ── FeedbackId: minted once per row, kept by every later write ───────────
|
||||
|
||||
private const string IdShape = "^[0-9a-f]{12}$";
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_SecondAnswer_KeepsTheIdOfTheFirst()
|
||||
{
|
||||
// The row is rewritten whole on every answer; the id must survive the Replace.
|
||||
var harness = new Harness();
|
||||
var first = await harness.Sut.UpdateInAppFeedbackAsync(Upn, _ => new FeedbackEntry
|
||||
{
|
||||
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Dismissed = true, DismissCount = 1,
|
||||
InteractedAt = DateTime.UtcNow.AddDays(-61),
|
||||
});
|
||||
var second = await harness.Sut.UpdateInAppFeedbackAsync(Upn, _ => new FeedbackEntry
|
||||
{
|
||||
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Rating = 4, Submitted = true, DismissCount = 1,
|
||||
InteractedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
Assert.Matches(IdShape, first!.FeedbackId!);
|
||||
Assert.Equal(first.FeedbackId, second!.FeedbackId);
|
||||
Assert.Equal(first.FeedbackId, (await harness.Sut.GetInAppFeedbackAsync(Upn))!.FeedbackId);
|
||||
Assert.Equal(first.FeedbackId, harness.SingleRow("InApp").GetString("FeedbackId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_RowWithoutId_GetsOneOnItsNextWrite()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Seed(new TableEntity("InApp", Upn)
|
||||
{
|
||||
["TenantId"] = TenantId, ["DisplayName"] = "Alice", ["Dismissed"] = true, ["Submitted"] = false,
|
||||
["DismissCount"] = 1, ["InteractedAt"] = DateTime.UtcNow.AddDays(-61),
|
||||
});
|
||||
Assert.Null((await harness.Sut.GetInAppFeedbackAsync(Upn))!.FeedbackId);
|
||||
|
||||
var written = await harness.Sut.UpdateInAppFeedbackAsync(Upn, current => new FeedbackEntry
|
||||
{
|
||||
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Dismissed = true, DismissCount = current!.DismissCount + 1,
|
||||
InteractedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
Assert.Matches(IdShape, written!.FeedbackId!);
|
||||
Assert.Equal(written.FeedbackId, harness.SingleRow("InApp").GetString("FeedbackId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InApp_IdFromTheCaller_IsIgnored()
|
||||
{
|
||||
// The repository owns the id: a decision that carries one cannot replace the stored id.
|
||||
var harness = new Harness();
|
||||
var first = await harness.Sut.UpdateInAppFeedbackAsync(Upn, _ => new FeedbackEntry
|
||||
{
|
||||
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Dismissed = true, DismissCount = 1,
|
||||
InteractedAt = DateTime.UtcNow,
|
||||
});
|
||||
var second = await harness.Sut.UpdateInAppFeedbackAsync(Upn, _ => new FeedbackEntry
|
||||
{
|
||||
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Dismissed = true, DismissCount = 2,
|
||||
InteractedAt = DateTime.UtcNow, FeedbackId = "ffffffffffff",
|
||||
});
|
||||
|
||||
Assert.Equal(first!.FeedbackId, second!.FeedbackId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Offboarding_SecondComment_KeepsTheIdOfTheFirst()
|
||||
{
|
||||
var harness = new Harness();
|
||||
FeedbackEntry Comment(string text) => new()
|
||||
{
|
||||
HistoryRowKey = HistoryRowKey, TenantId = TenantId, Upn = Upn, DisplayName = "Alice", Comment = text,
|
||||
InteractedAt = DateTime.UtcNow,
|
||||
};
|
||||
var first = Comment("Too expensive.");
|
||||
await harness.Sut.SaveOffboardingFeedbackAsync(first);
|
||||
var second = Comment("Too expensive for our size.");
|
||||
await harness.Sut.SaveOffboardingFeedbackAsync(second);
|
||||
|
||||
Assert.Matches(IdShape, first.FeedbackId!);
|
||||
Assert.Equal(first.FeedbackId, second.FeedbackId);
|
||||
var stored = await harness.Sut.GetOffboardingFeedbackAsync(HistoryRowKey);
|
||||
Assert.Equal("Too expensive for our size.", stored!.Comment);
|
||||
Assert.Equal(first.FeedbackId, stored.FeedbackId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAll_EveryKind_CarriesItsOwnId()
|
||||
{
|
||||
var harness = new Harness();
|
||||
await SaveInApp(harness, new FeedbackEntry
|
||||
{
|
||||
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Rating = 5, Submitted = true, InteractedAt = DateTime.UtcNow,
|
||||
});
|
||||
await harness.Sut.SaveOffboardingFeedbackAsync(new FeedbackEntry
|
||||
{
|
||||
HistoryRowKey = HistoryRowKey, TenantId = TenantId, Upn = Upn, Comment = "x", InteractedAt = DateTime.UtcNow,
|
||||
});
|
||||
await harness.Sut.SaveGeneralFeedbackAsync(new FeedbackEntry
|
||||
{
|
||||
Upn = Upn, TenantId = TenantId, DisplayName = "Alice", Comment = "y", InteractedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
var all = await harness.Sut.GetAllAsync();
|
||||
|
||||
Assert.Equal(3, all.Count);
|
||||
Assert.All(all, e => Assert.Matches(IdShape, e.FeedbackId!));
|
||||
Assert.Equal(3, all.Select(e => e.FeedbackId).Distinct().Count());
|
||||
}
|
||||
|
||||
// ── General (help-menu feedback) ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -350,8 +463,8 @@ public class TableFeedbackRepositoryTests
|
||||
var fetched = Assert.Single(await harness.Sut.GetAllAsync());
|
||||
Assert.Equal(FeedbackEntryType.General, fetched.Type);
|
||||
Assert.Equal(entry.FeedbackId, fetched.FeedbackId);
|
||||
Assert.Matches("^[0-9]{19}_[0-9a-f]{12}$", fetched.FeedbackId!);
|
||||
Assert.StartsWith((DateTime.MaxValue.Ticks - at.Ticks).ToString("D19"), fetched.FeedbackId!);
|
||||
Assert.Matches(IdShape, fetched.FeedbackId!);
|
||||
Assert.Equal($"{DateTime.MaxValue.Ticks - at.Ticks:D19}_{fetched.FeedbackId}", harness.SingleRow("General").RowKey);
|
||||
Assert.Equal("alice@contoso.invalid", fetched.Upn);
|
||||
Assert.Equal(TenantId, fetched.TenantId);
|
||||
Assert.Equal("Alice (Contoso)", fetched.DisplayName);
|
||||
@@ -430,6 +543,9 @@ public class TableFeedbackRepositoryTests
|
||||
/// <summary>Runs once just before the next Add/Update — a concurrent writer between read and write.</summary>
|
||||
public Action? BeforeNextWrite { get; set; }
|
||||
|
||||
/// <summary>The one stored row of a partition.</summary>
|
||||
public TableEntity SingleRow(string partitionKey) => _store.Values.Single(e => e.PartitionKey == partitionKey);
|
||||
|
||||
/// <summary>Stores a row as another writer would (fresh ETag).</summary>
|
||||
public void Seed(TableEntity entity)
|
||||
{
|
||||
|
||||
+6
-5
@@ -551,7 +551,7 @@ public class TypedContractCompletionWireParityTests
|
||||
interactedAt = (string?)"2026-08-30T10:00:00.0000000Z",
|
||||
historyRowKey = (string?)null, // in-app entry → key vanishes
|
||||
domainName = (string?)null,
|
||||
feedbackId = (string?)null,
|
||||
feedbackId = (string?)"a1b2c3d4e5f6", // every kind carries its id
|
||||
contactEmail = (string?)null,
|
||||
dismissCount = (int?)1,
|
||||
},
|
||||
@@ -568,7 +568,7 @@ public class TypedContractCompletionWireParityTests
|
||||
interactedAt = (string?)"2026-10-05T09:30:00.0000000Z",
|
||||
historyRowKey = (string?)null,
|
||||
domainName = (string?)"fabrikam.invalid",
|
||||
feedbackId = (string?)"2516114433936827287_0123456789ab",
|
||||
feedbackId = (string?)"0123456789ab",
|
||||
contactEmail = (string?)"bob.support@fabrikam.invalid",
|
||||
dismissCount = (int?)null, // general entry → no count
|
||||
},
|
||||
@@ -591,6 +591,7 @@ public class TypedContractCompletionWireParityTests
|
||||
InteractedAt = "2026-08-30T10:00:00.0000000Z",
|
||||
HistoryRowKey = null,
|
||||
DomainName = null,
|
||||
FeedbackId = "a1b2c3d4e5f6",
|
||||
DismissCount = 1,
|
||||
},
|
||||
new FeedbackEntryWire
|
||||
@@ -605,7 +606,7 @@ public class TypedContractCompletionWireParityTests
|
||||
Submitted = false,
|
||||
InteractedAt = "2026-10-05T09:30:00.0000000Z",
|
||||
DomainName = "fabrikam.invalid",
|
||||
FeedbackId = "2516114433936827287_0123456789ab",
|
||||
FeedbackId = "0123456789ab",
|
||||
ContactEmail = "bob.support@fabrikam.invalid",
|
||||
},
|
||||
},
|
||||
@@ -742,7 +743,7 @@ public class TypedContractCompletionWireParityTests
|
||||
versionBreakdown = Array.Empty<object>(),
|
||||
installerPhaseBreakdown = Array.Empty<object>(),
|
||||
topFailureCodes = Array.Empty<object>(),
|
||||
detectionLiesCount = 0,
|
||||
detectionFailureCount = 0,
|
||||
deviceModelBreakdown = Array.Empty<object>(),
|
||||
versionRegressions = regressions,
|
||||
},
|
||||
@@ -775,7 +776,7 @@ public class TypedContractCompletionWireParityTests
|
||||
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
|
||||
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
|
||||
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
|
||||
DetectionLiesCount = 0,
|
||||
DetectionFailureCount = 0,
|
||||
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
|
||||
VersionRegressions = regressions,
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// B: the Newtonsoft branch (<c>ReadNewtonsoftAsync</c>) only where the downstream is
|
||||
/// Newtonsoft-bound — a frozen per-file baseline that may only shrink,
|
||||
/// C: no request DTO declared in the Functions assembly (reflection).
|
||||
/// E keeps every body read inside RequestBody's counted cap (the body size is counted after
|
||||
/// decompression, whatever Content-Length claims) and the worker's body limit in one place.
|
||||
/// </summary>
|
||||
public class TypedRequestGuardTests
|
||||
{
|
||||
@@ -171,19 +173,89 @@ public class TypedRequestGuardTests
|
||||
=> (type == typeof(PatchTenantConfigurationFieldsRequest) && property.Name == nameof(PatchTenantConfigurationFieldsRequest.Fields))
|
||||
|| (type == typeof(PatchAdminConfigurationRequest) && property.Name == nameof(PatchAdminConfigurationRequest.Fields));
|
||||
|
||||
[Fact]
|
||||
public void E_EveryBodyReadIsCounted()
|
||||
{
|
||||
// The whole project, not only Functions/: a middleware that buffers the body is the same exposure.
|
||||
var violations = new List<string>();
|
||||
var allowedSeen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var (relative, original) in ProjectSources())
|
||||
{
|
||||
if (relative.Equals(BodyReaderFile, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var text = original;
|
||||
foreach (var shape in AllowedBodyReads.Where(s => original.Contains(s, StringComparison.Ordinal)))
|
||||
{
|
||||
allowedSeen.Add(shape);
|
||||
text = text.Replace(shape, string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var requestNames = RequestDeclaration.Matches(text).Select(m => m.Groups["name"].Value).Distinct(StringComparer.Ordinal);
|
||||
foreach (var name in requestNames)
|
||||
{
|
||||
var direct = Regex.Matches(text, $@"\b{Regex.Escape(name)}\.(Body|BodyReader)\b").Count;
|
||||
if (direct > 0)
|
||||
violations.Add($"{relative}: {direct}× {name}.Body — read it through RequestBody (ReadAsync<T>, or ReadCappedAsync for a foreign parser).");
|
||||
}
|
||||
foreach (Match raw in RawBodyRead.Matches(text))
|
||||
violations.Add($"{relative}: '{raw.Value}' reads a body outside RequestBody's counted cap.");
|
||||
if (!relative.Equals(BodyLimitFile, StringComparison.OrdinalIgnoreCase) && text.Contains("MaxRequestBodySize", StringComparison.Ordinal))
|
||||
violations.Add($"{relative}: MaxRequestBodySize is set only in {BodyLimitFile} (RequestBody.WorkerMaxBytes).");
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0, "Request bodies read outside the counted reader:\n " + string.Join("\n ", violations));
|
||||
|
||||
var stale = AllowedBodyReads.Where(s => !allowedSeen.Contains(s)).ToList();
|
||||
Assert.True(stale.Count == 0, "Stale allowed body reads (remove them):\n " + string.Join("\n ", stale));
|
||||
}
|
||||
|
||||
/// <summary>The counted reader itself, and the one place that sets the worker's body limit.</summary>
|
||||
private const string BodyReaderFile = "Helpers/RequestBody.cs";
|
||||
private const string BodyLimitFile = "Middleware/HttpCompressionStartupFilter.cs";
|
||||
|
||||
/// <summary>
|
||||
/// Body reads with their own counted cap, allowed by call shape so the rest of their file stays
|
||||
/// scanned. Each entry names why.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedBodyReads =
|
||||
{
|
||||
// Telemetry ingest: buffers through RequestBody.ReadWithinAsync under the tenant's payload cap.
|
||||
"ReadBodyWithSizeCapAsync(req.Body",
|
||||
};
|
||||
|
||||
/// <summary>A name declared as a request: a handler parameter, a typed local, a middleware lookup.</summary>
|
||||
private static readonly Regex RequestDeclaration = new(
|
||||
@"\bHttpRequest(?:Data)?\??\s+(?<name>\w+)\b|\bvar\s+(?<name>\w+)\s*=\s*await\s+[\w.]+GetHttpRequestDataAsync\(",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Body reads that need no declared name: the HttpRequestData string/JSON readers (an HttpClient
|
||||
/// response's Content is fine), the ASP.NET request stream, form and buffering, and MVC body binding,
|
||||
/// which the Functions extension supports and which only Kestrel's limit would bound.
|
||||
/// </summary>
|
||||
private static readonly Regex RawBodyRead = new(
|
||||
@"(?<!Content)\.ReadAsStringAsync\(|(?<!Content)\.ReadFromJsonAsync\b|\.Request\.Body(Reader)?\b|\bReadFormAsync\(|\bEnableBuffering\(|\[FromBody\]",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static IEnumerable<(string Relative, string Text)> HandlerSources()
|
||||
=> ProjectSources().Where(s => s.Relative.StartsWith("Functions/", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static IEnumerable<(string Relative, string Text)> ProjectSources()
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var handlers = Path.Combine(functionsRoot, "Functions");
|
||||
foreach (var file in Directory.EnumerateFiles(handlers, "*.cs", SearchOption.AllDirectories))
|
||||
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) || relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase))
|
||||
if (IsBuildOutput(relative))
|
||||
continue;
|
||||
yield return (relative, File.ReadAllText(file));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBuildOutput(string relative)
|
||||
=> relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) || relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase)
|
||||
|| relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) || relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the hardware lists of the usage metrics: ranked and shared by sessions, with the distinct
|
||||
/// devices behind them keyed like device journeys (normalized serial per tenant) and a session
|
||||
/// without a usable serial counting as its own device.
|
||||
/// </summary>
|
||||
public class UsageMetricsHardwareTests
|
||||
{
|
||||
private const string TenantA = "00000000-0000-0000-0000-00000000000a";
|
||||
private const string TenantB = "00000000-0000-0000-0000-00000000000b";
|
||||
|
||||
private static SessionSummary Session(string id, string serial, string manufacturer = "Contoso",
|
||||
string model = "Book 14", string tenantId = TenantA) => new()
|
||||
{
|
||||
SessionId = id,
|
||||
TenantId = tenantId,
|
||||
SerialNumber = serial,
|
||||
Manufacturer = manufacturer,
|
||||
Model = model,
|
||||
StartedAt = DateTime.UtcNow.AddHours(-1),
|
||||
Status = SessionStatus.Succeeded,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void A_reinstalled_device_counts_once_per_session_and_once_as_a_device()
|
||||
{
|
||||
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
|
||||
{
|
||||
Session("s1", "SN-1001"),
|
||||
Session("s2", "SN-1001"),
|
||||
Session("s3", "SN-1001"),
|
||||
});
|
||||
|
||||
var row = Assert.Single(m.TopModels);
|
||||
Assert.Equal(3, row.Count);
|
||||
Assert.Equal(1, row.DeviceCount);
|
||||
Assert.Equal(100, row.Percentage);
|
||||
Assert.Equal(1, Assert.Single(m.TopManufacturers).DeviceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serials_differing_in_case_or_whitespace_are_one_device()
|
||||
{
|
||||
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
|
||||
{
|
||||
Session("s1", "sn-1001"),
|
||||
Session("s2", " SN-1001 "),
|
||||
});
|
||||
|
||||
Assert.Equal(1, Assert.Single(m.TopModels).DeviceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sessions_without_a_usable_serial_count_as_one_device_each()
|
||||
{
|
||||
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
|
||||
{
|
||||
Session("s1", "To be filled by O.E.M."),
|
||||
Session("s2", "To be filled by O.E.M."),
|
||||
Session("s3", ""),
|
||||
Session("s4", "SN-1001"),
|
||||
});
|
||||
|
||||
var row = Assert.Single(m.TopModels);
|
||||
Assert.Equal(4, row.Count);
|
||||
Assert.Equal(4, row.DeviceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_same_serial_in_two_tenants_is_two_devices()
|
||||
{
|
||||
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
|
||||
{
|
||||
Session("s1", "SN-1001", tenantId: TenantA),
|
||||
Session("s2", "SN-1001", tenantId: TenantB),
|
||||
});
|
||||
|
||||
Assert.Equal(2, Assert.Single(m.TopModels).DeviceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lists_rank_by_sessions_not_devices()
|
||||
{
|
||||
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>
|
||||
{
|
||||
// One device enrolled three times outranks two devices enrolled once each.
|
||||
Session("s1", "SN-1001", "Fabrikam", "Desk 5"),
|
||||
Session("s2", "SN-1001", "Fabrikam", "Desk 5"),
|
||||
Session("s3", "SN-1001", "Fabrikam", "Desk 5"),
|
||||
Session("s4", "SN-2001"),
|
||||
Session("s5", "SN-2002"),
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { "Desk 5", "Book 14" }, m.TopModels.Select(r => r.Name));
|
||||
Assert.Equal(new[] { 3, 2 }, m.TopModels.Select(r => r.Count));
|
||||
Assert.Equal(new[] { 1, 2 }, m.TopModels.Select(r => r.DeviceCount));
|
||||
Assert.Equal(new[] { 60.0, 40.0 }, m.TopModels.Select(r => r.Percentage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_window_yields_empty_lists()
|
||||
{
|
||||
var m = UsageMetricsService.BuildHardwareMetrics(new List<SessionSummary>());
|
||||
|
||||
Assert.Empty(m.TopManufacturers);
|
||||
Assert.Empty(m.TopModels);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -39,6 +39,7 @@ public class UsageMetricsProjectionEquivalenceTests
|
||||
["StartedAt"] = new DateTimeOffset(startedAt),
|
||||
["Manufacturer"] = "Contoso",
|
||||
["Model"] = "Model-X",
|
||||
["SerialNumber"] = "SN-FULL",
|
||||
["IsUserDriven"] = true,
|
||||
["IsPreProvisioned"] = isPreProvisioned,
|
||||
["EnrollmentType"] = enrollmentType,
|
||||
@@ -48,7 +49,6 @@ public class UsageMetricsProjectionEquivalenceTests
|
||||
["RemediationScriptCount"] = 2,
|
||||
// Representative noise the usage compute never reads — present on a real wide row,
|
||||
// absent on the projected one. Proves the projection drops it without changing results.
|
||||
["SerialNumber"] = "SN-FULL",
|
||||
["DeviceName"] = "PC-FULL",
|
||||
["OsName"] = "Windows 11",
|
||||
["GeoCountry"] = "DE",
|
||||
@@ -88,6 +88,7 @@ public class UsageMetricsProjectionEquivalenceTests
|
||||
Assert.Equal(fromFull.Status, fromProjected.Status);
|
||||
Assert.Equal(fromFull.Manufacturer, fromProjected.Manufacturer);
|
||||
Assert.Equal(fromFull.Model, fromProjected.Model);
|
||||
Assert.Equal(fromFull.SerialNumber, fromProjected.SerialNumber);
|
||||
Assert.Equal(fromFull.IsPreProvisioned, fromProjected.IsPreProvisioned);
|
||||
Assert.Equal(fromFull.EnrollmentType, fromProjected.EnrollmentType);
|
||||
Assert.Equal(fromFull.IsHybridJoin, fromProjected.IsHybridJoin);
|
||||
|
||||
+15
-3
@@ -68,6 +68,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
|
||||
next.Type = FeedbackEntryType.InApp;
|
||||
next.Upn = rowKey;
|
||||
next.FeedbackId = stored?.GetString("FeedbackId") ?? NewFeedbackId();
|
||||
try
|
||||
{
|
||||
// Conditional on what was read: two tabs answering at once must not let a dismissal
|
||||
@@ -117,6 +118,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
"HistoryRowKey required for offboarding feedback", nameof(entry));
|
||||
try
|
||||
{
|
||||
// A second comment in the same offboarding replaces the row and keeps its id.
|
||||
entry.FeedbackId = (await GetOffboardingFeedbackAsync(entry.HistoryRowKey))?.FeedbackId ?? NewFeedbackId();
|
||||
await _tableClient.UpsertEntityAsync(StoreOffboarding(entry), TableUpdateMode.Replace);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -134,7 +137,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
entry.Type = FeedbackEntryType.General;
|
||||
entry.InteractedAt ??= DateTime.UtcNow;
|
||||
entry.Upn = entry.Upn.ToLowerInvariant();
|
||||
entry.FeedbackId = $"{RowKeyCodec.InvertedTicks(entry.InteractedAt.Value)}_{Guid.NewGuid().ToString("N")[..12]}";
|
||||
entry.FeedbackId = NewFeedbackId();
|
||||
try
|
||||
{
|
||||
// Insert, never upsert: every submission is its own row.
|
||||
@@ -209,6 +212,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["Submitted"] = e.Submitted,
|
||||
["DismissCount"] = e.DismissCount,
|
||||
["InteractedAt"] = e.InteractedAt,
|
||||
["FeedbackId"] = e.FeedbackId,
|
||||
};
|
||||
|
||||
private static FeedbackEntry MapInApp(TableEntity e)
|
||||
@@ -228,6 +232,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
// Rows from before the count existed: one recorded dismissal at most.
|
||||
DismissCount = e.GetInt32("DismissCount") ?? (dismissed && !submitted ? 1 : 0),
|
||||
InteractedAt = e.GetDateTime("InteractedAt"),
|
||||
FeedbackId = e.GetString("FeedbackId"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,6 +245,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["DomainName"] = e.DomainName,
|
||||
["Comment"] = e.Comment,
|
||||
["InteractedAt"] = e.InteractedAt,
|
||||
["FeedbackId"] = e.FeedbackId,
|
||||
};
|
||||
|
||||
private static FeedbackEntry MapOffboarding(TableEntity e) => new()
|
||||
@@ -252,10 +258,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
DomainName = e.GetString("DomainName"),
|
||||
Comment = e.GetString("Comment"),
|
||||
InteractedAt = e.GetDateTime("InteractedAt"),
|
||||
FeedbackId = e.GetString("FeedbackId"),
|
||||
};
|
||||
|
||||
private static TableEntity StoreGeneral(FeedbackEntry e) =>
|
||||
new(FeedbackEntryType.General, e.FeedbackId!)
|
||||
new(FeedbackEntryType.General, $"{RowKeyCodec.InvertedTicks(e.InteractedAt!.Value)}_{e.FeedbackId}")
|
||||
{
|
||||
["TenantId"] = e.TenantId,
|
||||
["Upn"] = e.Upn,
|
||||
@@ -264,12 +271,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["Comment"] = e.Comment,
|
||||
["ContactEmail"] = e.ContactEmail,
|
||||
["InteractedAt"] = e.InteractedAt,
|
||||
["FeedbackId"] = e.FeedbackId,
|
||||
};
|
||||
|
||||
private static FeedbackEntry MapGeneral(TableEntity e) => new()
|
||||
{
|
||||
Type = FeedbackEntryType.General,
|
||||
FeedbackId = e.RowKey,
|
||||
FeedbackId = e.GetString("FeedbackId"),
|
||||
TenantId = e.GetString("TenantId") ?? string.Empty,
|
||||
Upn = e.GetString("Upn") ?? string.Empty,
|
||||
DisplayName = e.GetString("DisplayName") ?? string.Empty,
|
||||
@@ -289,6 +297,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
TenantId = e.GetString("TenantId") ?? string.Empty,
|
||||
Comment = e.GetString("Comment"),
|
||||
InteractedAt = e.GetDateTime("InteractedAt"),
|
||||
FeedbackId = e.GetString("FeedbackId"),
|
||||
};
|
||||
|
||||
// Same shape as a session report id. Minted once per row; every later write of the row keeps it.
|
||||
private static string NewFeedbackId() => Guid.NewGuid().ToString("N")[..12];
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -20,9 +20,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<bool> SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics)
|
||||
=> _storage.SaveUsageMetricsSnapshotAsync(metrics);
|
||||
|
||||
public Task<List<UsageMetricsSnapshot>> GetUsageMetricsSnapshotAsync(
|
||||
string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100)
|
||||
=> _storage.GetUsageMetricsSnapshotAsync(tenantId, startDate, endDate, maxResults);
|
||||
public Task<List<TenantActivityDay>> GetTenantActivityDaysAsync(DateTime fromDay, DateTime toDay)
|
||||
=> _storage.GetTenantActivityDaysAsync(fromDay, toDay);
|
||||
|
||||
public Task<bool> HasUsageMetricsSnapshotAsync(string date)
|
||||
=> _storage.HasUsageMetricsSnapshotAsync(date);
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ public class SubmitOffboardingFeedbackFunction
|
||||
try
|
||||
{
|
||||
await _opsEvents.RecordOffboardingFeedbackReceivedAsync(
|
||||
normalizedTenantId, upn, domainName, marker.OffboardingHistoryRowKey);
|
||||
normalizedTenantId, upn, domainName, marker.OffboardingHistoryRowKey, entry.FeedbackId!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -291,7 +292,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
VersionBreakdown = Array.Empty<AppVersionBreakdownItem>(),
|
||||
InstallerPhaseBreakdown = Array.Empty<AppInstallerPhaseCount>(),
|
||||
TopFailureCodes = Array.Empty<AppAnalyticsFailureCode>(),
|
||||
DetectionLiesCount = 0,
|
||||
DetectionFailureCount = 0,
|
||||
DeviceModelBreakdown = Array.Empty<AppDeviceModelBreakdownItem>(),
|
||||
// Lockstep with the full response: episodes can outlive the window's data
|
||||
// (e.g. a shrunk days= selection), so the block is still surfaced here.
|
||||
@@ -366,9 +367,8 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
|
||||
var topFailureCodes = TopFailureCodes(summaries, 5);
|
||||
|
||||
var detectionLiesCount = summaries.Count(s =>
|
||||
s.Status == "Succeeded" &&
|
||||
string.Equals(s.DetectionResult, "NotDetected", StringComparison.OrdinalIgnoreCase));
|
||||
var detectionFailureCount = summaries.Count(s =>
|
||||
s.Status == "Failed" && Constants.AppFailureTypes.IsDetectionFailure(s.FailureCode));
|
||||
|
||||
// Device-model correlation: join via session lookup.
|
||||
// Global-admin (no tenant filter) summaries may span multiple tenants, so we use
|
||||
@@ -447,7 +447,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
|
||||
VersionBreakdown = versionBreakdown,
|
||||
InstallerPhaseBreakdown = installerPhaseBreakdown,
|
||||
TopFailureCodes = topFailureCodes,
|
||||
DetectionLiesCount = detectionLiesCount,
|
||||
DetectionFailureCount = detectionFailureCount,
|
||||
DeviceModelBreakdown = deviceModelBreakdown,
|
||||
VersionRegressions = appVersionRegressions
|
||||
};
|
||||
|
||||
+3
-4
@@ -66,14 +66,13 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
// Parse request body (same as original RegisterSessionFunction)
|
||||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||||
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
|
||||
&& contentLength > 1_048_576)
|
||||
using var body = await req.ReadCappedAsync(1_048_576);
|
||||
if (body == null)
|
||||
{
|
||||
return new RegisterSessionOutput { HttpResponse = await req.BadRequestAsync("Request body too large") };
|
||||
}
|
||||
|
||||
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
|
||||
var requestBody = await new StreamReader(body).ReadToEndAsync();
|
||||
var request = JsonConvert.DeserializeObject<RegisterSessionRequest>(requestBody);
|
||||
|
||||
if (request?.Registration == null)
|
||||
|
||||
+5
-1
@@ -103,8 +103,12 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
GetDiagnosticsUploadUrlRequest? requestBody;
|
||||
try
|
||||
{
|
||||
// Read before the device is validated (the tenant id is in the body): an oversized
|
||||
// body is an invalid one.
|
||||
using var body = await req.ReadCappedAsync(RequestBody.DefaultMaxBytes)
|
||||
?? throw new InvalidDataException("Request body too large");
|
||||
requestBody = await System.Text.Json.JsonSerializer.DeserializeAsync<GetDiagnosticsUploadUrlRequest>(
|
||||
req.Body,
|
||||
body,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -185,7 +185,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
|
||||
// Only a rating that was actually recorded is announced — at most once per person.
|
||||
if (stored is { Submitted: true } && stored.Rating is int rating)
|
||||
await AnnounceRatingAsync(tenantId, upn, displayName, rating, comment);
|
||||
await AnnounceRatingAsync(tenantId, upn, displayName, stored.FeedbackId!, rating, comment);
|
||||
|
||||
_logger.LogInformation("Feedback {Action} by {Upn} (tenant {TenantId})",
|
||||
stored == null ? "unchanged" : body.Dismissed ? "dismissed" : "submitted", upn, tenantId);
|
||||
@@ -198,7 +198,8 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
/// them. The ops event carries the stars and the comment length only, so an alert channel
|
||||
/// receives no personal data; the Telegram ping keeps sender and comment until it is retired.
|
||||
/// </summary>
|
||||
private async Task AnnounceRatingAsync(string tenantId, string upn, string displayName, int rating, string? comment)
|
||||
private async Task AnnounceRatingAsync(
|
||||
string tenantId, string upn, string displayName, string feedbackId, int rating, string? comment)
|
||||
{
|
||||
_ = _telegramNotificationService.SendFeedbackAsync(tenantId, upn, displayName, rating, comment);
|
||||
|
||||
@@ -211,7 +212,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
try
|
||||
{
|
||||
await _opsEvents.RecordFeedbackRatingReceivedAsync(
|
||||
normalizedTenantId, upn, domainName, rating, comment?.Length ?? 0);
|
||||
normalizedTenantId, upn, domainName, feedbackId, rating, comment?.Length ?? 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -220,7 +221,7 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
|
||||
|
||||
_ = _globalNotifications.CreateNotificationAsync(
|
||||
"feedback", "New Rating", $"{upn} ({domainName ?? normalizedTenantId}) · {rating}/5",
|
||||
href: Constants.PortalUserFeedbackPath);
|
||||
href: Constants.PortalUserFeedbackEntryPath(feedbackId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+2
-1
@@ -151,7 +151,8 @@ public class SubmitGeneralFeedbackFunction
|
||||
}
|
||||
|
||||
_ = _globalNotifications.CreateNotificationAsync(
|
||||
"feedback", "New Feedback", $"{entry.Upn} ({domainName ?? normalizedTenantId})", href: Constants.PortalUserFeedbackPath);
|
||||
"feedback", "New Feedback", $"{entry.Upn} ({domainName ?? normalizedTenantId})",
|
||||
href: Constants.PortalUserFeedbackEntryPath(entry.FeedbackId!));
|
||||
|
||||
_logger.LogInformation("General feedback {FeedbackId} stored for tenant {TenantId} ({Length} chars)",
|
||||
entry.FeedbackId, normalizedTenantId, message.Length);
|
||||
|
||||
+9
-14
@@ -5,6 +5,7 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -66,23 +67,17 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
return req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
// Bounded read instead of a Body.Length check: with ASP.NET Core integration the
|
||||
// request body is a forward-only stream whose Length property throws
|
||||
// NotSupportedException. Reading at most cap+1 chars detects oversize without ever
|
||||
// needing to know the total size. (Char-based cap ≈ byte cap for a shape gate.)
|
||||
string body;
|
||||
using (var reader = new StreamReader(req.Body, Encoding.UTF8))
|
||||
using var payload = await req.ReadCappedAsync(MaxContentLength);
|
||||
if (payload == null)
|
||||
{
|
||||
var buffer = new char[MaxContentLength + 1];
|
||||
var read = await reader.ReadBlockAsync(buffer, 0, buffer.Length);
|
||||
if (read > MaxContentLength)
|
||||
{
|
||||
_logger.LogWarning("Azure Monitor alert webhook rejected: payload exceeds {Cap} char cap", MaxContentLength);
|
||||
return req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
}
|
||||
body = new string(buffer, 0, read);
|
||||
_logger.LogWarning("Azure Monitor alert webhook rejected: payload exceeds {Cap} byte cap", MaxContentLength);
|
||||
return req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
string body;
|
||||
using (var reader = new StreamReader(payload, Encoding.UTF8))
|
||||
body = await reader.ReadToEndAsync();
|
||||
|
||||
var alert = TryParse(body);
|
||||
if (alert == null)
|
||||
{
|
||||
|
||||
+10
-16
@@ -153,7 +153,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// memory + deserialising (same tenant-config knob the removed V1 NDJSON path
|
||||
// used). Request body here is already gzip-decompressed by
|
||||
// UseRequestDecompression — so the cap is on the actual JSON bytes we'll feed
|
||||
// to the parser.
|
||||
// to the parser. Above RequestBody.WorkerMaxBytes the worker's own limit refuses
|
||||
// the body first, whatever the tenant knob says.
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantIdHeader);
|
||||
var maxPayloadBytes = (tenantConfig?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024;
|
||||
|
||||
@@ -667,10 +668,12 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Buffers the request body under a strict byte cap, then deserialises the telemetry batch
|
||||
/// directly off the buffered bytes. Returns <c>(true, null)</c> as soon as the cap would be
|
||||
/// exceeded — the stream isn't fully drained, which bounds memory use even for a malicious
|
||||
/// sender (strict greater-than, so a payload equal to the cap is accepted).
|
||||
/// Buffers the request body under a strict byte cap (<see cref="RequestBody.ReadWithinAsync"/>,
|
||||
/// the counting every body read shares), then deserialises the telemetry batch directly off the
|
||||
/// buffered bytes. Returns <c>(true, null)</c> as soon as the cap is exceeded — at most one byte
|
||||
/// past it is read, which bounds memory use even for a malicious sender (strict greater-than, so
|
||||
/// a payload equal to the cap is accepted) — and when the worker's own body limit refused the
|
||||
/// body first, so that case is the same 413 and not a 500.
|
||||
/// <para>
|
||||
/// Hot-path note: this deserialises through a <see cref="JsonTextReader"/> over the buffered
|
||||
/// <see cref="MemoryStream"/> rather than materialising a full UTF-16 <c>string</c> + a
|
||||
@@ -686,18 +689,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
internal static async Task<(bool exceeded, List<TelemetryItemDto>? items)> ReadBodyWithSizeCapAsync(
|
||||
Stream source, int maxBytes)
|
||||
{
|
||||
using var buffered = new MemoryStream();
|
||||
var buffer = new byte[8192];
|
||||
int read;
|
||||
long total = 0;
|
||||
while ((read = await source.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
total += read;
|
||||
if (total > maxBytes) return (true, null);
|
||||
await buffered.WriteAsync(buffer, 0, read);
|
||||
}
|
||||
using var buffered = await RequestBody.ReadWithinAsync(source, maxBytes);
|
||||
if (buffered == null) return (true, null);
|
||||
|
||||
buffered.Position = 0;
|
||||
using var textReader = new StreamReader(buffered, System.Text.Encoding.UTF8);
|
||||
using var jsonReader = new JsonTextReader(textReader);
|
||||
var items = JsonSerializer.CreateDefault().Deserialize<List<TelemetryItemDto>>(jsonReader);
|
||||
|
||||
+9
-11
@@ -139,21 +139,19 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
/// </summary>
|
||||
internal async Task<HttpResponseData> ProcessReportErrorAsync(HttpRequestData req, string tenantId, SecurityValidationResult validation)
|
||||
{
|
||||
// Request body size limit (1 MB)
|
||||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||||
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
|
||||
&& contentLength > 1_048_576)
|
||||
{
|
||||
_logger.LogWarning("ReportAgentError: Request body too large ({ContentLength} bytes) from tenant {TenantId}", contentLength, tenantId);
|
||||
return req.CreateResponse(HttpStatusCode.OK); // Still 200 — agent must not retry
|
||||
}
|
||||
|
||||
// Parse the report body
|
||||
// Parse the report body (size limit 1 MB)
|
||||
AgentErrorReport? report = null;
|
||||
try
|
||||
{
|
||||
using var body = await req.ReadCappedAsync(1_048_576);
|
||||
if (body == null)
|
||||
{
|
||||
_logger.LogWarning("ReportAgentError: Request body over 1 MB from tenant {TenantId}", tenantId);
|
||||
return req.CreateResponse(HttpStatusCode.OK); // Still 200 — agent must not retry
|
||||
}
|
||||
|
||||
report = await JsonSerializer.DeserializeAsync<AgentErrorReport>(
|
||||
req.Body,
|
||||
body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
@@ -117,7 +118,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// All validation failures return 200 OK — zero information leakage.
|
||||
try
|
||||
{
|
||||
// Gate 1: Content-Length
|
||||
// Gate 1: Content-Length — a cheap header filter; Gate 6 counts the bytes actually read
|
||||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||||
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
|
||||
&& contentLength > MaxContentLength)
|
||||
@@ -156,8 +157,12 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
DistressReport? report;
|
||||
try
|
||||
{
|
||||
using var body = await req.ReadCappedAsync(MaxContentLength);
|
||||
if (body == null)
|
||||
return req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
report = await JsonSerializer.DeserializeAsync<DistressReport>(
|
||||
req.Body,
|
||||
body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
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.Metrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Operator-only growth figures: the signups of the last N UTC days and how far they got (enrolled,
|
||||
/// succeeded, days to the first day with sessions, still active), plus the activity and plan state of
|
||||
/// every tenant. The configuration rows, the per-tenant counters and the daily usage rows are joined
|
||||
/// here, so the growth report never joins them itself. Tenants named in <c>excludeTenantIds</c> (the
|
||||
/// operator's own and test tenants) leave every figure. Serves the <c>get_growth_metrics</c> MCP tool.
|
||||
/// </summary>
|
||||
public class GetGrowthMetricsFunction
|
||||
{
|
||||
private readonly ILogger<GetGrowthMetricsFunction> _logger;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
|
||||
public GetGrowthMetricsFunction(
|
||||
ILogger<GetGrowthMetricsFunction> logger,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
IMetricsRepository metricsRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_metricsRepo = metricsRepo;
|
||||
}
|
||||
|
||||
[Function("GetGrowthMetrics")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/growth")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var query = req.Query;
|
||||
var days = QueryParams.Int(query["days"], GrowthMetricsBuilder.DefaultWindowDays, 1, GrowthMetricsBuilder.MaxWindowDays);
|
||||
if (!GrowthMetricsBuilder.TryResolveTimeZone(query["timeZone"], out var timeZone))
|
||||
return await req.BadRequestAsync("timeZone must be an IANA time zone id such as Europe/Berlin");
|
||||
if (!GrowthMetricsBuilder.TryParseExcludedTenants(query["excludeTenantIds"], out var excludedTenantIds))
|
||||
return await req.BadRequestAsync(
|
||||
$"excludeTenantIds must be at most {GrowthMetricsBuilder.MaxExcludedTenants} comma-separated tenant ids");
|
||||
var includeTenants = string.Equals(query["includeTenants"], "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
|
||||
var stats = await _metricsRepo.GetAllTenantStatsAsync();
|
||||
var activity = await _metricsRepo.GetTenantActivityDaysAsync(
|
||||
GrowthMetricsBuilder.UsageReadStart(nowUtc.Date, days), nowUtc.Date.AddDays(-1));
|
||||
|
||||
return await req.OkAsync(GrowthMetricsBuilder.Build(
|
||||
configs, stats, activity, nowUtc, days, timeZone, includeTenants, excludedTenantIds));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "GetGrowthMetrics");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure response builder — internal static so the counting rules are pinned by tests. The DTOs
|
||||
/// live in AutopilotMonitor.Shared.Models (Models/Metrics/GrowthMetricsApiModels.cs).
|
||||
/// </summary>
|
||||
internal static class GrowthMetricsBuilder
|
||||
{
|
||||
internal const int DefaultWindowDays = 30;
|
||||
internal const int MaxWindowDays = 365;
|
||||
/// <summary>Beyond this the signup buckets are Monday-anchored weeks, as on GA → Metrics → Customers.</summary>
|
||||
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
|
||||
/// their approval time, so their hours and peaks are approval batches, not signups.
|
||||
/// </summary>
|
||||
internal static readonly DateTime ExactSignupTimesFrom = new(2026, 3, 18, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>"Last N days" = exactly N UTC calendar days including today (both ends inclusive).</summary>
|
||||
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));
|
||||
|
||||
// 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.
|
||||
private static readonly Regex TimeZoneIdPattern = new(
|
||||
@"^[A-Za-z][A-Za-z0-9_+\-]{0,31}(/[A-Za-z0-9_+\-]{1,32}){0,2}$",
|
||||
RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
/// <summary>No id ⇒ UTC; otherwise an IANA id the host knows, or false.</summary>
|
||||
internal static bool TryResolveTimeZone(string? id, out TimeZoneInfo timeZone)
|
||||
{
|
||||
timeZone = TimeZoneInfo.Utc;
|
||||
if (string.IsNullOrWhiteSpace(id)) return true;
|
||||
if (!TimeZoneIdPattern.IsMatch(id) || !TimeZoneInfo.TryFindSystemTimeZoneById(id, out var found)) return false;
|
||||
timeZone = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Cap of excludeTenantIds: the operator's own and test tenants, listed outside the code.</summary>
|
||||
internal const int MaxExcludedTenants = 20;
|
||||
|
||||
/// <summary>No value ⇒ none; otherwise up to 20 comma-separated tenant ids (normalized), or false.</summary>
|
||||
internal static bool TryParseExcludedTenants(string? raw, out IReadOnlyList<string> tenantIds)
|
||||
{
|
||||
var ids = new List<string>();
|
||||
tenantIds = ids;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return true;
|
||||
var entries = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (entries.Length > MaxExcludedTenants) return false;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!Guid.TryParse(entry, out var id)) return false;
|
||||
ids.Add(id.ToString("D"));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
internal static GrowthMetricsResponse Build(
|
||||
IReadOnlyList<TenantConfiguration> configs,
|
||||
IReadOnlyList<TenantStats> stats,
|
||||
IReadOnlyList<TenantActivityDay> activityDays,
|
||||
DateTime nowUtc,
|
||||
int days,
|
||||
TimeZoneInfo timeZone,
|
||||
bool includeTenants,
|
||||
IReadOnlyCollection<string> excludedTenantIds)
|
||||
{
|
||||
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);
|
||||
|
||||
// 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 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 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
|
||||
{
|
||||
WindowDays = days,
|
||||
WindowStart = Key(windowStart),
|
||||
WindowEnd = Key(today),
|
||||
TimeZone = timeZone.Id,
|
||||
ExactSignupTimesFrom = Key(ExactSignupTimesFrom),
|
||||
UsageDaysThrough = Key(today.AddDays(-1)),
|
||||
ExcludedTenants = signedUp.Count - tenants.Count,
|
||||
Signups = BuildSignups(tenants, window, windowStart, previousStart, today, days, timeZone),
|
||||
Cohorts = window
|
||||
.GroupBy(t => WeekStart(t.SignupUtc!.Value.Date))
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new GrowthCohortWeek
|
||||
{
|
||||
WeekStart = Key(g.Key),
|
||||
Signups = g.Count(),
|
||||
Enrolled = g.Count(t => t.Enrolled),
|
||||
Succeeded = g.Count(t => t.Succeeded),
|
||||
ActiveLast7Days = g.Count(t => t.LastActiveDay >= activeFrom7),
|
||||
MedianDaysToFirstEnrollment = Median(g.Select(t => t.DaysToFirstEnrollment)),
|
||||
})
|
||||
.ToList(),
|
||||
Activation = new GrowthActivation
|
||||
{
|
||||
Enrolled = window.Count(t => t.Enrolled),
|
||||
Succeeded = window.Count(t => t.Succeeded),
|
||||
NeverEnrolled = window.Count(t => !t.Enrolled),
|
||||
FirstEnrollmentSameDay = window.Count(t => t.DaysToFirstEnrollment == 0),
|
||||
FirstEnrollmentNextDay = window.Count(t => t.DaysToFirstEnrollment == 1),
|
||||
FirstEnrollmentDays2To7 = window.Count(t => t.DaysToFirstEnrollment is >= 2 and <= 7),
|
||||
FirstEnrollmentLater = window.Count(t => t.DaysToFirstEnrollment > 7),
|
||||
FirstEnrollmentPending = window.Count(t => t.FirstEnrollmentPending),
|
||||
FirstEnrollmentUnknown = window.Count(t => t.Enrolled && t.FirstEnrollmentDay == null && !t.FirstEnrollmentPending),
|
||||
MedianDaysToFirstEnrollment = Median(window.Select(t => t.DaysToFirstEnrollment)),
|
||||
},
|
||||
AllTenants = new GrowthTenantSegments
|
||||
{
|
||||
Tenants = tenants.Count,
|
||||
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)),
|
||||
},
|
||||
Plans = new GrowthPlans
|
||||
{
|
||||
ByPlanTier = tenants
|
||||
.GroupBy(t => PlanTierOf(t.Config), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new GrowthPlanCount { PlanTier = g.Key, Tenants = g.Count() })
|
||||
.OrderByDescending(p => p.Tenants)
|
||||
.ThenBy(p => p.PlanTier, StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
TrialsStartedInWindow = tenants.Count(t => t.Config.TrialStartedUtc is { } started
|
||||
&& Utc(started).Date >= windowStart && Utc(started).Date <= today),
|
||||
TrialsActive = tenants.Count(t => t.Config.TrialExpiresUtc is { } expires && Utc(expires) > nowUtc),
|
||||
PayingCustomers = tenants.Count(t => t.Config.PayingCustomer),
|
||||
},
|
||||
Tenants = includeTenants
|
||||
? window.Select(t => new GrowthTenantRow
|
||||
{
|
||||
TenantId = t.Config.TenantId,
|
||||
OnboardedAt = t.SignupUtc!.Value,
|
||||
PlanTier = PlanTierOf(t.Config),
|
||||
Enrolled = t.Enrolled,
|
||||
Succeeded = t.Succeeded,
|
||||
FirstEnrollmentDay = t.FirstEnrollmentDay is { } first ? Key(first) : null,
|
||||
LastActiveDay = t.LastActiveDay is { } last ? Key(last) : null,
|
||||
}).ToList()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
DateTime windowStart,
|
||||
DateTime previousStart,
|
||||
DateTime today,
|
||||
int days,
|
||||
TimeZoneInfo timeZone)
|
||||
{
|
||||
var perDay = tenants
|
||||
.Where(t => t.SignupUtc != null)
|
||||
.GroupBy(t => t.SignupUtc!.Value.Date)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
int CountOn(DateTime day) => perDay.TryGetValue(day, out var n) ? n : 0;
|
||||
|
||||
var weekly = days > DailyBucketMaxDays;
|
||||
var buckets = new List<GrowthBucket>();
|
||||
var peaks = new List<GrowthPeakDay>();
|
||||
for (var day = windowStart; day <= today; day = day.AddDays(1))
|
||||
{
|
||||
var count = CountOn(day);
|
||||
if (!weekly || buckets.Count == 0 || day.DayOfWeek == DayOfWeek.Monday)
|
||||
buckets.Add(new GrowthBucket { Start = Key(day), Count = count });
|
||||
else
|
||||
buckets[^1].Count += count;
|
||||
|
||||
if (count < PeakMinSignups || day < ExactSignupTimesFrom) continue;
|
||||
var baseline = Median(Enumerable.Range(1, PeakBaselineDays).Select(back => (int?)CountOn(day.AddDays(-back)))) ?? 0;
|
||||
if (count >= 2 * baseline)
|
||||
peaks.Add(new GrowthPeakDay { Day = Key(day), Count = count, BaselineMedian = baseline });
|
||||
}
|
||||
|
||||
var byHour = new int[24];
|
||||
var byWeekday = new int[7];
|
||||
foreach (var t in window)
|
||||
{
|
||||
if (t.SignupUtc is not { } signup || signup < ExactSignupTimesFrom) continue;
|
||||
var local = TimeZoneInfo.ConvertTimeFromUtc(signup, timeZone);
|
||||
byHour[local.Hour]++;
|
||||
byWeekday[((int)local.DayOfWeek + 6) % 7]++;
|
||||
}
|
||||
|
||||
return new GrowthSignups
|
||||
{
|
||||
Total = window.Count,
|
||||
PreviousWindowTotal = tenants.Count(t => t.SignupUtc is { } s && s.Date >= previousStart && s.Date < windowStart),
|
||||
BucketSize = weekly ? "week" : "day",
|
||||
Buckets = buckets,
|
||||
PeakDays = peaks,
|
||||
ByHour = byHour,
|
||||
ByWeekday = byWeekday,
|
||||
};
|
||||
}
|
||||
|
||||
private static double? Median(IEnumerable<int?> values)
|
||||
{
|
||||
var sorted = values.OfType<int>().OrderBy(v => v).ToList();
|
||||
if (sorted.Count == 0) return null;
|
||||
var mid = sorted.Count / 2;
|
||||
return sorted.Count % 2 == 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/raw/tables/{tableName} — Query any table directly
|
||||
/// Query params: partitionKey, rowKeyPrefix, filter, limit
|
||||
/// Query params: partitionKey, rowKeyPrefix, filter, fields, pageSize, continuation
|
||||
/// </summary>
|
||||
[Function("QueryRawTable")]
|
||||
public async Task<HttpResponseData> QueryTable(
|
||||
@@ -100,6 +100,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
var partitionKey = query["partitionKey"];
|
||||
var rowKeyPrefix = query["rowKeyPrefix"];
|
||||
var filter = query["filter"];
|
||||
var fields = query["fields"];
|
||||
|
||||
var pagination = RawTablePagination.ParsePagination(query);
|
||||
if (pagination.Error != null)
|
||||
@@ -132,25 +133,10 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
pageSize: pagination.PageSize,
|
||||
continuation: azureToken);
|
||||
|
||||
var entities = new List<Dictionary<string, object?>>(rawEntities.Count);
|
||||
foreach (var entity in rawEntities)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["PartitionKey"] = entity.PartitionKey,
|
||||
["RowKey"] = entity.RowKey,
|
||||
["Timestamp"] = entity.Timestamp,
|
||||
};
|
||||
|
||||
foreach (var kvp in entity)
|
||||
{
|
||||
if (kvp.Key is "odata.etag" or "PartitionKey" or "RowKey" or "Timestamp")
|
||||
continue;
|
||||
dict[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
entities.Add(dict);
|
||||
}
|
||||
// fields= is not part of the continuation fingerprint; BuildNextLink echoes it, so a
|
||||
// follow-up page keeps the projection.
|
||||
var entities = RawEntityProjection.Project(
|
||||
rawEntities.Select(RawEntityProjection.ToDictionary), fields);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(nextRawToken))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-instance cap on concurrent report uploads (singleton, shared by the session-report and the
|
||||
/// diagnostic-files route). A report body may be 20 MiB, roughly 150 MB of memory once read and
|
||||
/// parsed, and as gzip only a few KB on the wire; the slot is taken before the body is read and
|
||||
/// held until the response. Uploads are rare and seconds apart, so a caller who finds every slot
|
||||
/// taken is answered 429 at once. The first refusal per instance and hour raises
|
||||
/// <c>ReportUploadLimitReached</c> for the operators; later refusals in that window stay in the
|
||||
/// Warning trace, so nobody can flood the alarm.
|
||||
/// </summary>
|
||||
public sealed class ReportUploadGate : ConcurrencyGate
|
||||
{
|
||||
internal const int DefaultLimit = 3;
|
||||
internal static readonly TimeSpan AlertWindow = TimeSpan.FromHours(1);
|
||||
private const int RetryAfterSeconds = 5;
|
||||
|
||||
private readonly OpsEventService _opsEvents;
|
||||
private readonly ILogger<ReportUploadGate> _logger;
|
||||
private long _nextAlertTicks;
|
||||
|
||||
public ReportUploadGate(OpsEventService opsEvents, ILogger<ReportUploadGate> logger)
|
||||
: this(DefaultLimit, opsEvents, logger)
|
||||
{
|
||||
}
|
||||
|
||||
internal ReportUploadGate(int limit, OpsEventService opsEvents, ILogger<ReportUploadGate> logger) : base(limit)
|
||||
{
|
||||
_opsEvents = opsEvents;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>The 429 for a caller who found every slot taken.</summary>
|
||||
public async Task<HttpResponseData> RefuseAsync(HttpRequestData req, string route, string? tenantId, string? upn)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Report upload refused on {Route}: {Limit} uploads already in progress on this instance (tenant {TenantId})",
|
||||
route, Limit, tenantId);
|
||||
|
||||
if (TakeAlert(DateTime.UtcNow))
|
||||
await _opsEvents.RecordReportUploadLimitReachedAsync(route, Limit, AlertWindow, tenantId, upn);
|
||||
|
||||
return await req.ErrorAsync(HttpStatusCode.TooManyRequests, Constants.ApiErrorCodes.RateLimited,
|
||||
"Too many reports are being uploaded right now. Try again in a few seconds.",
|
||||
retryAfterSeconds: RetryAfterSeconds);
|
||||
}
|
||||
|
||||
/// <summary>True for the first refusal of an alert window, the one that raises the ops event.</summary>
|
||||
internal bool TakeAlert(DateTime utcNow)
|
||||
{
|
||||
var next = Interlocked.Read(ref _nextAlertTicks);
|
||||
return utcNow.Ticks >= next
|
||||
&& Interlocked.CompareExchange(ref _nextAlertTicks, utcNow.Add(AlertWindow).Ticks, next) == next;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -24,6 +24,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly TelegramNotificationService _telegramNotificationService;
|
||||
private readonly GlobalNotificationService _globalNotificationService;
|
||||
private readonly ReportUploadGate _uploadGate;
|
||||
private readonly OpsEventService? _opsEvents;
|
||||
|
||||
public SubmitDiagFilesReportFunction(
|
||||
@@ -32,6 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
TelegramNotificationService telegramNotificationService,
|
||||
GlobalNotificationService globalNotificationService,
|
||||
ReportUploadGate uploadGate,
|
||||
OpsEventService? opsEvents = null)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -39,6 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_telegramNotificationService = telegramNotificationService;
|
||||
_globalNotificationService = globalNotificationService;
|
||||
_uploadGate = uploadGate;
|
||||
_opsEvents = opsEvents;
|
||||
}
|
||||
|
||||
@@ -55,6 +58,11 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
var tenantId = requestCtx.TenantId;
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
|
||||
// The same upload slots as SubmitSessionReport, taken before the body is read.
|
||||
using var uploadSlot = _uploadGate.TryEnter();
|
||||
if (uploadSlot == null)
|
||||
return await _uploadGate.RefuseAsync(req, "diagnostics/files", tenantId, userIdentifier);
|
||||
|
||||
// Same body cap as SubmitSessionReport; the portal sizes its attachment budget from it.
|
||||
var read = await req.ReadAsync<SubmitDiagFilesReportRequest>(Constants.SubmissionLimits.ReportRequestMaxBytes);
|
||||
if (read.Error != null) return read.Error;
|
||||
|
||||
+8
@@ -18,6 +18,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly TelegramNotificationService _telegramNotificationService;
|
||||
private readonly GlobalNotificationService _globalNotificationService;
|
||||
private readonly ReportUploadGate _uploadGate;
|
||||
private readonly OpsEventService? _opsEvents;
|
||||
|
||||
public SubmitSessionReportFunction(
|
||||
@@ -26,6 +27,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
TelegramNotificationService telegramNotificationService,
|
||||
GlobalNotificationService globalNotificationService,
|
||||
ReportUploadGate uploadGate,
|
||||
OpsEventService? opsEvents = null)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -33,6 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_telegramNotificationService = telegramNotificationService;
|
||||
_globalNotificationService = globalNotificationService;
|
||||
_uploadGate = uploadGate;
|
||||
_opsEvents = opsEvents;
|
||||
}
|
||||
|
||||
@@ -50,6 +53,11 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
var tenantId = requestCtx.TenantId;
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
|
||||
// A report upload slot of this instance, taken before the body is read (ReportUploadGate).
|
||||
using var uploadSlot = _uploadGate.TryEnter();
|
||||
if (uploadSlot == null)
|
||||
return await _uploadGate.RefuseAsync(req, "sessions/{sessionId}/report", tenantId, userIdentifier);
|
||||
|
||||
// Body cap shared with the portal's attachment budget: base64 attachments (+33 %)
|
||||
// plus the CSV/TXT exports must fit inside it.
|
||||
var read = await req.ReadAsync<SubmitSessionReportRequest>(Constants.SubmissionLimits.ReportRequestMaxBytes);
|
||||
|
||||
@@ -306,42 +306,20 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
/// <summary>
|
||||
/// Process-wide cap on concurrent pattern tests (singleton). A slot is held from the regex
|
||||
/// construction to the end of the evaluation; a caller finding no free slot is answered 429
|
||||
/// at once — no queueing — so this endpoint can never occupy more than <see cref="Limit"/>
|
||||
/// worker threads of an instance, whatever the request rate. Deliberately local to this one
|
||||
/// endpoint: D-203 refuses a process-wide semaphore in front of storage I/O because it would
|
||||
/// queue the ingest behind portal scans; nothing but this test waits on this gate.
|
||||
/// at once, so this endpoint can never occupy more than <see cref="ConcurrencyGate.Limit"/>
|
||||
/// worker threads of an instance, whatever the request rate. Nothing but this test waits on
|
||||
/// this gate.
|
||||
/// </summary>
|
||||
public sealed class TestLogPatternGate
|
||||
public sealed class TestLogPatternGate : ConcurrencyGate
|
||||
{
|
||||
internal const int DefaultLimit = 2;
|
||||
|
||||
private readonly SemaphoreSlim _slots;
|
||||
|
||||
public TestLogPatternGate() : this(DefaultLimit)
|
||||
{
|
||||
}
|
||||
|
||||
internal TestLogPatternGate(int limit)
|
||||
internal TestLogPatternGate(int limit) : base(limit)
|
||||
{
|
||||
Limit = limit;
|
||||
_slots = new SemaphoreSlim(limit, limit);
|
||||
}
|
||||
|
||||
public int Limit { get; }
|
||||
|
||||
/// <summary>A slot to dispose when the test ends, or null when every slot is taken.</summary>
|
||||
public IDisposable? TryEnter() => _slots.Wait(0) ? new Slot(_slots) : null;
|
||||
|
||||
private sealed class Slot : IDisposable
|
||||
{
|
||||
private SemaphoreSlim? _slots;
|
||||
|
||||
public Slot(SemaphoreSlim slots)
|
||||
{
|
||||
_slots = slots;
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _slots, null)?.Release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-4
@@ -85,14 +85,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
try
|
||||
{
|
||||
// Parse request
|
||||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||||
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
|
||||
&& contentLength > 1_048_576) // 1 MB limit
|
||||
using var body = await req.ReadCappedAsync(1_048_576); // 1 MB limit
|
||||
if (body == null)
|
||||
{
|
||||
var errorResponse = await CreateErrorResponse(req, HttpStatusCode.BadRequest, "Request body too large");
|
||||
return new RegisterSessionOutput { HttpResponse = errorResponse };
|
||||
}
|
||||
var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
|
||||
var requestBody = await new StreamReader(body).ReadToEndAsync();
|
||||
var request = JsonConvert.DeserializeObject<RegisterSessionRequest>(requestBody);
|
||||
|
||||
if (request?.Registration == null)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Per-instance cap on concurrent work of one kind; each kind is its own singleton subclass. A
|
||||
/// caller that finds no free slot gets null at once, without queueing, so the guarded work never
|
||||
/// holds more than <see cref="Limit"/> slots of an instance, whatever the request rate. A gate
|
||||
/// stays local to the endpoints that take its slots: D-203 refuses a process-wide semaphore in
|
||||
/// front of storage I/O because it would queue the ingest behind portal scans.
|
||||
/// </summary>
|
||||
public abstract class ConcurrencyGate
|
||||
{
|
||||
private readonly SemaphoreSlim _slots;
|
||||
|
||||
protected ConcurrencyGate(int limit)
|
||||
{
|
||||
Limit = limit;
|
||||
_slots = new SemaphoreSlim(limit, limit);
|
||||
}
|
||||
|
||||
public int Limit { get; }
|
||||
|
||||
/// <summary>A slot to dispose when the work ends, or null when every slot is taken.</summary>
|
||||
public IDisposable? TryEnter() => _slots.Wait(0) ? new Slot(_slots) : null;
|
||||
|
||||
private sealed class Slot : IDisposable
|
||||
{
|
||||
private SemaphoreSlim? _slots;
|
||||
|
||||
public Slot(SemaphoreSlim slots)
|
||||
{
|
||||
_slots = slots;
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _slots, null)?.Release();
|
||||
}
|
||||
}
|
||||
@@ -6,22 +6,22 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialises raw Azure-Table <see cref="TableEntity"/> rows to plain dictionaries for the
|
||||
/// "raw" reader endpoints (<c>/api/raw/sessions</c>, <c>/api/raw/events</c> and their
|
||||
/// <c>/global</c> twins). Unlike the curated DTO mappers (<c>MapIndexEntityToSessionSummary</c>,
|
||||
/// "raw" reader endpoints (<c>/api/raw/sessions</c>, <c>/api/raw/events</c>, their
|
||||
/// <c>/global</c> twins and <c>/api/global/raw/tables/{name}</c>). Unlike the curated DTO mappers (<c>MapIndexEntityToSessionSummary</c>,
|
||||
/// <c>MapToEnrollmentEvent</c>), this preserves <b>every stored column verbatim</b> — real
|
||||
/// PascalCase names plus <c>PartitionKey</c>/<c>RowKey</c>/<c>Timestamp</c> — so the raw tools
|
||||
/// honour their name. The enriched/typed views live behind <c>search_sessions</c> /
|
||||
/// <c>get_session</c> / <c>get_session_events</c> / <c>search_events</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ToDictionary"/> runs in the repository layer (it needs the Azure SDK type); the
|
||||
/// For sessions and events <see cref="ToDictionary"/> runs in the repository layer (it needs the Azure SDK type); the
|
||||
/// <c>fields=</c> projection in <see cref="Project"/> runs in the function layer over the
|
||||
/// Azure-free dictionaries the interface exposes. The projection is a <b>pure pass-through</b>:
|
||||
/// it narrows the emitted columns (case-insensitive) but never silently drops a real column —
|
||||
/// any requested key the row actually has is returned, and unknown keys are simply absent. This
|
||||
/// is the opposite of the former hard-coded whitelist on <c>QueryRawSessionsFunction</c>, which
|
||||
/// dropped ~20 genuine columns. <c>PartitionKey</c> + <c>RowKey</c> are always retained (row
|
||||
/// identity + cursor stability), mirroring the client-side projection on <c>query_table</c>.
|
||||
/// identity + cursor stability). <c>/api/global/raw/tables/{name}</c> uses the same pair.
|
||||
/// </remarks>
|
||||
public static class RawEntityProjection
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using JsonConvert = Newtonsoft.Json.JsonConvert;
|
||||
|
||||
@@ -32,18 +35,35 @@ public sealed class BodyResult<T> where T : class
|
||||
/// The one way a function reads an HTTP request body. The generic overloads constrain on
|
||||
/// <see cref="IApiRequest"/>, so every body type is a Shared wire type (exported to TypeScript
|
||||
/// by SharedManifestParityTests); TypedRequestGuardTests refuses the raw deserializers in
|
||||
/// handlers. Empty, malformed or non-object bodies are a 400 envelope here, never a 500 from an
|
||||
/// unhandled <see cref="JsonException"/>. The optional byte cap is per site (a session report
|
||||
/// carries base64 attachments; most admin bodies fit in a kilobyte).
|
||||
/// handlers. Empty, malformed, non-object or incomplete bodies (the client aborted mid-body) are
|
||||
/// a 400 envelope here, never a 500 from an unhandled <see cref="JsonException"/> or
|
||||
/// <see cref="BadHttpRequestException"/>. Every read is capped on the bytes it actually reads,
|
||||
/// after UseRequestDecompression, so neither chunked transfer nor Content-Encoding gets past the
|
||||
/// cap. The cap is per site (a session report carries base64 attachments), and
|
||||
/// <see cref="DefaultMaxBytes"/> applies where a site names none.
|
||||
/// </summary>
|
||||
public static class RequestBody
|
||||
{
|
||||
/// <summary>
|
||||
/// The cap of a site that names none. Every such body is a small admin or portal object
|
||||
/// (strings, short lists); the largest, an email template, stays far below it.
|
||||
/// </summary>
|
||||
public const long DefaultMaxBytes = 1_048_576;
|
||||
|
||||
/// <summary>
|
||||
/// The worker's request body limit: Kestrel's MaxRequestBodySize, set in
|
||||
/// <c>AddWorkerHttpPipeline</c>, which UseRequestDecompression also applies to the inflated body.
|
||||
/// It equals the largest cap a route reads with; a route cap above it would never be reached.
|
||||
/// </summary>
|
||||
public const long WorkerMaxBytes = Constants.SubmissionLimits.ReportRequestMaxBytes;
|
||||
|
||||
private const string RequiredMessage = "Request body is required";
|
||||
private const string InvalidMessage = "Invalid JSON body";
|
||||
private const string TooLargeMessage = "Request body too large";
|
||||
private const string IncompleteMessage = "Request body incomplete";
|
||||
|
||||
/// <summary>System.Text.Json with <see cref="ApiJsonOptions.Read"/> — the default reader.</summary>
|
||||
public static async Task<BodyResult<T>> ReadAsync<T>(this HttpRequestData req, long? maxBytes = null)
|
||||
public static async Task<BodyResult<T>> ReadAsync<T>(this HttpRequestData req, long maxBytes = DefaultMaxBytes)
|
||||
where T : class, IApiRequest
|
||||
{
|
||||
var text = await ReadTextAsync(req, maxBytes);
|
||||
@@ -70,7 +90,7 @@ public static class RequestBody
|
||||
/// name the blob by query, a revert defaulting to the latest backup, a usage-plan reset).
|
||||
/// A malformed body is still a 400.
|
||||
/// </summary>
|
||||
public static async Task<BodyResult<T>> ReadOptionalAsync<T>(this HttpRequestData req, long? maxBytes = null)
|
||||
public static async Task<BodyResult<T>> ReadOptionalAsync<T>(this HttpRequestData req, long maxBytes = DefaultMaxBytes)
|
||||
where T : class, IApiRequest
|
||||
{
|
||||
var text = await ReadTextAsync(req, maxBytes, allowEmpty: true);
|
||||
@@ -94,7 +114,7 @@ public static class RequestBody
|
||||
/// and errors as <see cref="ReadAsync{T}"/>; TypedRequestGuardTests carries the shrinking
|
||||
/// baseline of its call sites — a new site is a regression, port the downstream instead.
|
||||
/// </summary>
|
||||
public static async Task<BodyResult<T>> ReadNewtonsoftAsync<T>(this HttpRequestData req, long? maxBytes = null)
|
||||
public static async Task<BodyResult<T>> ReadNewtonsoftAsync<T>(this HttpRequestData req, long maxBytes = DefaultMaxBytes)
|
||||
where T : class, IApiRequest
|
||||
{
|
||||
var text = await ReadTextAsync(req, maxBytes);
|
||||
@@ -121,7 +141,7 @@ public static class RequestBody
|
||||
/// its key names come from the request DTO by <c>nameof</c>, see PlanManagementFunction.
|
||||
/// The caller disposes the document.
|
||||
/// </summary>
|
||||
public static async Task<BodyResult<JsonDocument>> ReadDocumentAsync(this HttpRequestData req, long? maxBytes = null)
|
||||
public static async Task<BodyResult<JsonDocument>> ReadDocumentAsync(this HttpRequestData req, long maxBytes = DefaultMaxBytes)
|
||||
{
|
||||
var text = await ReadTextAsync(req, maxBytes);
|
||||
if (text.Error != null) return BodyResult<JsonDocument>.Fail(text.Error);
|
||||
@@ -145,18 +165,71 @@ public static class RequestBody
|
||||
return BodyResult<JsonDocument>.Ok(doc);
|
||||
}
|
||||
|
||||
private static async Task<BodyResult<string>> ReadTextAsync(HttpRequestData req, long? maxBytes, bool allowEmpty = false)
|
||||
/// <summary>
|
||||
/// The body, buffered under <paramref name="maxBytes"/>, for the routes that parse with their own
|
||||
/// reader (the agent wire, a foreign schema). Null when the body is larger; an aborted body throws
|
||||
/// <see cref="BadHttpRequestException"/> as before. Same counting as every read above.
|
||||
/// </summary>
|
||||
public static Task<MemoryStream?> ReadCappedAsync(this HttpRequestData req, long maxBytes)
|
||||
=> ReadWithinAsync(req.Body, maxBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Buffers <paramref name="body"/> while counting the bytes actually read and asks for at most one
|
||||
/// byte past the cap — that byte alone proves the body too large. The count runs on what the
|
||||
/// handler receives, after UseRequestDecompression, so a missing (chunked) or compressed
|
||||
/// Content-Length changes nothing. Null when the body is larger than <paramref name="maxBytes"/>
|
||||
/// (a body exactly at the cap is read) or when the worker's own limit refused it first: Kestrel and
|
||||
/// the decompression stream raise a 413 <see cref="BadHttpRequestException"/>. Every other
|
||||
/// exception propagates, so an aborted body stays the caller's "incomplete".
|
||||
/// </summary>
|
||||
internal static async Task<MemoryStream?> ReadWithinAsync(Stream body, long maxBytes)
|
||||
{
|
||||
if (maxBytes is long cap
|
||||
&& req.Headers.TryGetValues("Content-Length", out var values)
|
||||
&& long.TryParse(values.FirstOrDefault(), out var contentLength)
|
||||
&& contentLength > cap)
|
||||
var buffered = new MemoryStream();
|
||||
var chunk = new byte[8192];
|
||||
try
|
||||
{
|
||||
return BodyResult<string>.Fail(await req.BadRequestAsync(TooLargeMessage));
|
||||
while (true)
|
||||
{
|
||||
var remaining = maxBytes - buffered.Length;
|
||||
var wanted = remaining < chunk.Length ? (int)remaining + 1 : chunk.Length;
|
||||
var read = await body.ReadAsync(chunk.AsMemory(0, wanted));
|
||||
if (read == 0) break;
|
||||
if (read > remaining) return null;
|
||||
buffered.Write(chunk, 0, read);
|
||||
}
|
||||
}
|
||||
catch (BadHttpRequestException ex) when (ex.StatusCode == StatusCodes.Status413PayloadTooLarge)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var text = await req.ReadAsStringAsync();
|
||||
if (!string.IsNullOrWhiteSpace(text)) return BodyResult<string>.Ok(text!);
|
||||
buffered.Position = 0;
|
||||
return buffered;
|
||||
}
|
||||
|
||||
private static async Task<BodyResult<string>> ReadTextAsync(HttpRequestData req, long maxBytes, bool allowEmpty = false)
|
||||
{
|
||||
MemoryStream? buffered;
|
||||
try
|
||||
{
|
||||
buffered = await ReadWithinAsync(req.Body, maxBytes);
|
||||
}
|
||||
catch (BadHttpRequestException)
|
||||
{
|
||||
// The client stopped sending mid-body (tab closed, navigation, network drop) and Kestrel
|
||||
// reports "Unexpected end of request content". The server is fine, so this is a 4xx.
|
||||
return BodyResult<string>.Fail(await req.BadRequestAsync(IncompleteMessage));
|
||||
}
|
||||
|
||||
if (buffered == null)
|
||||
return BodyResult<string>.Fail(await req.BadRequestAsync(TooLargeMessage));
|
||||
|
||||
// Decoded as HttpRequestData.ReadAsStringAsync decodes: UTF-8 unless a byte-order mark says otherwise.
|
||||
string text;
|
||||
using (var reader = new StreamReader(buffered, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
|
||||
text = await reader.ReadToEndAsync();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(text)) return BodyResult<string>.Ok(text);
|
||||
return allowEmpty
|
||||
? BodyResult<string>.Empty()
|
||||
: BodyResult<string>.Fail(await req.BadRequestAsync(RequiredMessage));
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
using System.IO.Compression;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// The worker's HTTP pipeline beyond the Functions defaults: gzip in both directions and the
|
||||
/// request body limit. Program.cs and the pipeline tests both register it through
|
||||
/// <see cref="AddWorkerHttpPipeline"/>, so the tests exercise the production registration.
|
||||
/// </summary>
|
||||
internal static class WorkerHttpPipelineRegistration
|
||||
{
|
||||
public static IServiceCollection AddWorkerHttpPipeline(this IServiceCollection services)
|
||||
{
|
||||
// UseResponseCompression: backend → agent (config, ingest ack, ...). Triggered by Accept-Encoding.
|
||||
// UseRequestDecompression: agent → backend (telemetry ingest). Triggered by Content-Encoding.
|
||||
services.AddResponseCompression(options =>
|
||||
{
|
||||
options.EnableForHttps = true;
|
||||
options.Providers.Add<GzipCompressionProvider>();
|
||||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes
|
||||
.Concat(new[] { "application/json", "application/x-ndjson" });
|
||||
});
|
||||
services.Configure<GzipCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
|
||||
services.AddRequestDecompression();
|
||||
services.AddTransient<IStartupFilter, HttpCompressionStartupFilter>();
|
||||
|
||||
// Pinned rather than left at Kestrel's default, because UseRequestDecompression applies the
|
||||
// same limit to the inflated body. PostConfigure, so that neither registration order nor a
|
||||
// Kestrel:Limits app setting can lift it.
|
||||
services.PostConfigure<KestrelServerOptions>(o => o.Limits.MaxRequestBodySize = RequestBody.WorkerMaxBytes);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
// Registers ASP.NET Core response-compression and request-decompression middleware into the
|
||||
// Kestrel pipeline that the Azure Functions isolated worker hosts via
|
||||
// Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore. Using the standard middleware
|
||||
// keeps gzip handling out of function code.
|
||||
// keeps gzip handling out of function code. A startup filter, because
|
||||
// FunctionsApplication.CreateBuilder's Build() returns IHost, not WebApplication — there is no
|
||||
// app.UseXxx() to call.
|
||||
internal sealed class HttpCompressionStartupFilter : IStartupFilter
|
||||
{
|
||||
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.DataAccess;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
@@ -11,8 +10,6 @@ using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
@@ -152,21 +149,9 @@ builder.Services.AddAuthorization();
|
||||
// Enable ASP.NET Core integration for authentication
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// HTTP compression — bidirectional gzip for bandwidth-sensitive agent links.
|
||||
// UseResponseCompression: backend → agent (config, ingest ack, ...). Triggered by Accept-Encoding.
|
||||
// UseRequestDecompression: agent → backend (ingest NDJSON). Triggered by Content-Encoding.
|
||||
// Registered via IStartupFilter because FunctionsApplication.CreateBuilder's Build() returns IHost,
|
||||
// not WebApplication — we can't call app.UseXxx() directly.
|
||||
builder.Services.AddResponseCompression(options =>
|
||||
{
|
||||
options.EnableForHttps = true;
|
||||
options.Providers.Add<GzipCompressionProvider>();
|
||||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes
|
||||
.Concat(new[] { "application/json", "application/x-ndjson" });
|
||||
});
|
||||
builder.Services.Configure<GzipCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
|
||||
builder.Services.AddRequestDecompression();
|
||||
builder.Services.AddTransient<IStartupFilter, HttpCompressionStartupFilter>();
|
||||
// HTTP compression — bidirectional gzip for bandwidth-sensitive agent links — and the worker's
|
||||
// request body limit (RequestBody.WorkerMaxBytes), which also bounds what a gzip body inflates to.
|
||||
builder.Services.AddWorkerHttpPipeline();
|
||||
|
||||
// Register our services
|
||||
builder.Services.AddMemoryCache();
|
||||
@@ -175,6 +160,8 @@ builder.Services.AddSingleton<ResiliencePolicies>();
|
||||
builder.Services.AddSingleton<TableStorageService>();
|
||||
// Per-instance cap on concurrent logparser pattern tests (caller-controlled regex work, D-379).
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Functions.Rules.TestLogPatternGate>();
|
||||
// Per-instance cap on concurrent report uploads (20 MiB bodies), with an ops event when it is hit.
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Functions.Reports.ReportUploadGate>();
|
||||
// Shared QueueClient bootstrap (code-quality audit D2): single Managed-Identity/connection-string
|
||||
// resolution consumed by every queue producer / worker / probe.
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Queueing.QueueClientFactory>();
|
||||
|
||||
@@ -564,6 +564,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
// Operator-only classifier diagnostics and drift alerts; the handler reads ?tenantId= itself. None, so no
|
||||
// delegated (MSP) reader reaches them.
|
||||
new("GET", "global/metrics/verdict-calibration", EndpointPolicy.GlobalReadOrAdmin),
|
||||
// Operator growth figures across all tenants (signups, activation, plan state); no tenant parameter.
|
||||
new("GET", "global/metrics/growth", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/apps/list", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
|
||||
+25
-32
@@ -1,6 +1,7 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Diagnosis;
|
||||
|
||||
@@ -178,7 +179,7 @@ internal static class DiagnosisCulpritFinder
|
||||
State = app.State,
|
||||
ErrorCode = app.Failure != null ? app.ErrorCode : null,
|
||||
ExitCode = app.Failure != null ? app.ExitCode : null,
|
||||
DetectionResult = app.DetectionResult,
|
||||
PostInstallDetection = app.PostInstallDetection,
|
||||
InstallStarts = app.Starts > 0 ? app.Starts : null,
|
||||
LastStartedAt = app.LastStartedAt,
|
||||
FailedAt = app.Failure?.Timestamp,
|
||||
@@ -200,35 +201,16 @@ internal static class DiagnosisCulpritFinder
|
||||
|| string.Equals(intent, "Unknown", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The app's failure is the one the ESP reports: the same code (hex HRESULT on both sides, or the
|
||||
/// same number in hex and decimal), or a detection failure for the ESP's "not detected" HRESULT —
|
||||
/// IME reports those without a code of its own.
|
||||
/// The app's failure is the one the ESP reports: the same code, as hex on both sides or the same
|
||||
/// HRESULT in hex and in the signed decimal the IME log writes (IME's own 0x87D1041C report
|
||||
/// arrives as -2016345060). <c>detectionResult</c> is IME's check BEFORE the install and never
|
||||
/// matches a detection failure (D-392).
|
||||
/// </summary>
|
||||
private static bool MatchesEspError(AppTrack app, string? espCode) =>
|
||||
SameCode(app.ErrorCode, espCode)
|
||||
|| (string.Equals(espCode, Constants.AppFailureTypes.DetectionFailureHResult, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(app.DetectionResult, "NotDetected", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool SameCode(string? appCode, string? espCode)
|
||||
private static bool MatchesEspError(AppTrack app, string? espCode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(appCode) || string.IsNullOrEmpty(espCode)) return false;
|
||||
if (string.Equals(appCode, espCode, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return TryParseCode(appCode!, out var a) && TryParseCode(espCode!, out var b) && a == b;
|
||||
}
|
||||
|
||||
private static bool TryParseCode(string code, out uint value)
|
||||
{
|
||||
var s = code.Trim();
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return uint.TryParse(s.Substring(2), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out value);
|
||||
if (long.TryParse(s, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var signed)
|
||||
&& signed >= int.MinValue && signed <= uint.MaxValue)
|
||||
{
|
||||
value = unchecked((uint)signed);
|
||||
return true;
|
||||
}
|
||||
value = 0;
|
||||
return false;
|
||||
if (string.IsNullOrEmpty(app.ErrorCode) || string.IsNullOrEmpty(espCode)) return false;
|
||||
if (string.Equals(app.ErrorCode, espCode, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return ErrorCodeCatalog.TryParseHResult(app.ErrorCode, out var a) && ErrorCodeCatalog.TryParseHResult(espCode, out var b) && a == b;
|
||||
}
|
||||
|
||||
private static AppTrack? Lookup(Dictionary<string, AppTrack> apps, string? appId, string name) =>
|
||||
@@ -241,7 +223,7 @@ internal static class DiagnosisCulpritFinder
|
||||
public AppTrack(string? appId, string name) { AppId = appId; Name = name; }
|
||||
public string? AppId { get; }
|
||||
public string Name { get; set; }
|
||||
public string? AppType, Intent, Targeted, State, DetectionResult, ErrorCode, ExitCode, FailureType;
|
||||
public string? AppType, Intent, Targeted, State, ErrorCode, ExitCode, FailureType, PostInstallDetection;
|
||||
public int Starts;
|
||||
public DateTime? LastStartedAt;
|
||||
public EnrollmentEvent? Failure;
|
||||
@@ -257,7 +239,8 @@ internal static class DiagnosisCulpritFinder
|
||||
{
|
||||
var type = evt.EventType;
|
||||
if (type != Constants.EventTypes.AppInstallStart && type != Constants.EventTypes.AppInstallComplete
|
||||
&& type != Constants.EventTypes.AppInstallFailed && type != Constants.EventTypes.AppStateReconciliation)
|
||||
&& type != Constants.EventTypes.AppInstallFailed && type != Constants.EventTypes.AppStateReconciliation
|
||||
&& type != Constants.EventTypes.AppPostInstallDetection)
|
||||
continue;
|
||||
|
||||
var appId = DiagnosisEspEvidence.NonEmpty(EventDataReader.String(evt, "appId"));
|
||||
@@ -272,7 +255,12 @@ internal static class DiagnosisCulpritFinder
|
||||
app.AppType = DiagnosisEspEvidence.NonEmpty(EventDataReader.String(evt, "appType")) ?? app.AppType;
|
||||
app.Intent = DiagnosisEspEvidence.NonEmpty(EventDataReader.String(evt, "intent")) ?? app.Intent;
|
||||
app.Targeted = DiagnosisEspEvidence.NonEmpty(EventDataReader.String(evt, "targeted")) ?? app.Targeted;
|
||||
app.DetectionResult = DiagnosisEspEvidence.NonEmpty(EventDataReader.String(evt, "detectionResult")) ?? app.DetectionResult;
|
||||
// IME's check right after the install; the latest wins (the one after a restart).
|
||||
if (type == Constants.EventTypes.AppPostInstallDetection)
|
||||
{
|
||||
app.PostInstallDetection = DiagnosisEspEvidence.NonEmpty(EventDataReader.String(evt, "postInstallDetection")) ?? app.PostInstallDetection;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type == Constants.EventTypes.AppInstallStart)
|
||||
{
|
||||
@@ -325,9 +313,14 @@ internal static class DiagnosisCulpritFinder
|
||||
private static string? ErrorCode(string? raw)
|
||||
{
|
||||
var code = DiagnosisEspEvidence.NonEmpty(raw);
|
||||
return code == null || (TryParseCode(code, out var value) && value == 0) ? null : code;
|
||||
return code == null || IsZero(code) ? null : code;
|
||||
}
|
||||
|
||||
private static bool IsZero(string code) =>
|
||||
long.TryParse(code, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var number)
|
||||
? number == 0
|
||||
: ErrorCodeCatalog.TryParseHResult(code, out var hresult) && hresult == 0;
|
||||
|
||||
private static void RecordFailure(AppTrack app, EnrollmentEvent evt, string? errorCode, string? exitCode)
|
||||
{
|
||||
app.Failure = evt;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
@@ -105,12 +106,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
summary.ExitCode = exitCode;
|
||||
}
|
||||
if (evt.Data.TryGetValue("detectionResult", out var detectionObj))
|
||||
{
|
||||
var detection = detectionObj?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(detection))
|
||||
summary.DetectionResult = detection.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
switch (evt.EventType)
|
||||
@@ -184,17 +179,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
?? (summary.StartedAt != DateTime.MinValue ? summary.StartedAt : (DateTime?)null);
|
||||
if (failedAttemptStart.HasValue)
|
||||
summary.DurationSeconds = Math.Max(1, EventTimestampValidator.SafeDurationSeconds(failedAttemptStart.Value, evt.Timestamp));
|
||||
// FailureCode preference: canonical `failureType` > raw `errorCode` > empty.
|
||||
// FailureCode preference: canonical `failureType` > `errorCode` > empty.
|
||||
// c117946b debrief (2026-05-12): the V2 termination handler tags promoted
|
||||
// "likely stuck" apps with failureType=esp_apps_timeout so the UI can
|
||||
// distinguish confirmed failures from ESP-timeout-induced presumptions.
|
||||
// The code is stored canonical: IME logs an HRESULT as a signed decimal
|
||||
// (-2016345060), the ESP path as hex, and the rankings group by the string.
|
||||
var failureType = evt.Data?.ContainsKey("failureType") == true
|
||||
? evt.Data["failureType"]?.ToString() : null;
|
||||
var errorCodeRaw = evt.Data?.ContainsKey("errorCode") == true
|
||||
? evt.Data["errorCode"]?.ToString() : null;
|
||||
summary.FailureCode = !string.IsNullOrWhiteSpace(failureType)
|
||||
? failureType!
|
||||
: (errorCodeRaw ?? string.Empty);
|
||||
: (ErrorCodeCatalog.NormalizeCode(errorCodeRaw) ?? string.Empty);
|
||||
// FailureMessage preference: explicit `errorMessage` > `errorDetail` > evt.Message.
|
||||
var errorMessage = evt.Data?.ContainsKey("errorMessage") == true
|
||||
? evt.Data["errorMessage"]?.ToString() : null;
|
||||
|
||||
@@ -496,6 +496,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantId, principalKey,
|
||||
new { applicationId, oid = objectId, tid = tenantId, accessGrant });
|
||||
|
||||
/// <summary>
|
||||
/// Every report-upload slot of this instance was taken and a caller got 429
|
||||
/// (<c>ReportUploadGate</c>). Raised for the first refusal of a window only; the gate keeps the
|
||||
/// rest in the Warning trace. Recorded against the refused caller's tenant.
|
||||
/// </summary>
|
||||
public Task RecordReportUploadLimitReachedAsync(string route, int limit, TimeSpan window, string? tenantId, string? upn)
|
||||
=> WriteAsync(OpsEventCategory.Security, OpsEventTypes.ReportUploadLimitReached, OpsEventSeverity.Warning,
|
||||
$"Report upload limit reached: {limit} uploads in progress on this instance, a caller on {route} got 429; further refusals in the next {window.TotalMinutes:0} min stay in the Warning trace",
|
||||
tenantId, upn ?? "System.ReportUploadGate",
|
||||
new { route, limit, windowMinutes = (int)window.TotalMinutes, upn });
|
||||
|
||||
/// <summary>
|
||||
/// The reporter's per-instance budget is spent: one Critical marker (same event type, so the same
|
||||
/// alert rule pushes it) that says further denials this window are trace-only. A storm is a
|
||||
@@ -654,7 +665,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// of signal product wants to be notified about so feedback gets read promptly.
|
||||
/// </summary>
|
||||
public Task RecordOffboardingFeedbackReceivedAsync(
|
||||
string tenantId, string submittedBy, string? domainName, string historyRowKey)
|
||||
string tenantId, string submittedBy, string? domainName, string historyRowKey, string feedbackId)
|
||||
{
|
||||
var tenantLabel = TenantLabel(tenantId, domainName);
|
||||
|
||||
@@ -662,6 +673,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
["domainName"] = domainName,
|
||||
["historyRowKey"] = historyRowKey,
|
||||
["feedbackId"] = feedbackId,
|
||||
["portalPath"] = Constants.PortalUserFeedbackEntryPath(feedbackId),
|
||||
};
|
||||
|
||||
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.OffboardingFeedbackReceived, OpsEventSeverity.Info,
|
||||
@@ -686,7 +699,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["feedbackId"] = feedbackId,
|
||||
["length"] = length,
|
||||
["hasContactEmail"] = hasContactEmail,
|
||||
["portalPath"] = Constants.PortalUserFeedbackPath,
|
||||
["portalPath"] = Constants.PortalUserFeedbackEntryPath(feedbackId),
|
||||
};
|
||||
|
||||
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FeedbackReceived, OpsEventSeverity.Info,
|
||||
@@ -697,18 +710,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// <summary>
|
||||
/// Fired when a member rates the portal in the feedback bubble — at most once per person, never
|
||||
/// for a dismissal. Info. Like <see cref="RecordFeedbackReceivedAsync"/>, message and details carry
|
||||
/// neither the comment nor the sender: only the stars and the comment length; the comment is read
|
||||
/// in the portal (User Feedback).
|
||||
/// neither the comment nor the sender: only the entry's id, the stars and the comment length; the comment
|
||||
/// is read in the portal (User Feedback).
|
||||
/// </summary>
|
||||
public Task RecordFeedbackRatingReceivedAsync(
|
||||
string tenantId, string submittedBy, string? domainName, int rating, int commentLength)
|
||||
string tenantId, string submittedBy, string? domainName, string feedbackId, int rating, int commentLength)
|
||||
{
|
||||
var details = new Dictionary<string, object?>
|
||||
{
|
||||
["domainName"] = domainName,
|
||||
["feedbackId"] = feedbackId,
|
||||
["rating"] = rating,
|
||||
["commentLength"] = commentLength,
|
||||
["portalPath"] = Constants.PortalUserFeedbackPath,
|
||||
["portalPath"] = Constants.PortalUserFeedbackEntryPath(feedbackId),
|
||||
};
|
||||
|
||||
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FeedbackRatingReceived, OpsEventSeverity.Info,
|
||||
|
||||
@@ -271,9 +271,9 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
await SendSystemAsync(device, wipe).ConfigureAwait(false);
|
||||
}
|
||||
await _repo.DeleteDeviceAsync(device.Scope, device.DeviceId).ConfigureAwait(false);
|
||||
Track("PushPairing", new Dictionary<string, string>
|
||||
Track("PushPairing", device.Scope, new Dictionary<string, string>
|
||||
{
|
||||
["action"] = "revoked", ["reason"] = reason, ["scope"] = scope.IsPlatform ? "platform" : "tenant", ["platform"] = device.Platform,
|
||||
["action"] = "revoked", ["reason"] = reason, ["platform"] = device.Platform,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -334,13 +334,12 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
["StatusReason"] = reason,
|
||||
["StatusChangedUtc"] = now,
|
||||
}).ConfigureAwait(false);
|
||||
Track("PushPairing", new Dictionary<string, string> { ["action"] = "paused", ["reason"] = reason, ["scope"] = scope.IsPlatform ? "platform" : "tenant", ["platform"] = device.Platform });
|
||||
Track("PushPairing", device.Scope, new Dictionary<string, string> { ["action"] = "paused", ["reason"] = reason, ["platform"] = device.Platform });
|
||||
}
|
||||
|
||||
/// <summary>An owner-inactivity pause lifts itself once the owner signs in again (at the sign-in, the maintenance run as fallback) — no re-pairing.</summary>
|
||||
public async Task ResumeAsync(PushDevice device, DateTime now)
|
||||
{
|
||||
var scope = NotificationScope.FromKey(device.Scope);
|
||||
var reason = device.StatusReason ?? string.Empty;
|
||||
var landed = await _repo.MutateDeviceAsync(device.Scope, device.DeviceId, current =>
|
||||
current.Status != Constants.Push.DeviceStatus.Paused ? null : new Dictionary<string, object?>
|
||||
@@ -350,7 +349,7 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
["StatusChangedUtc"] = now,
|
||||
}).ConfigureAwait(false);
|
||||
if (landed)
|
||||
Track("PushPairing", new Dictionary<string, string> { ["action"] = "resumed", ["reason"] = reason, ["scope"] = scope.IsPlatform ? "platform" : "tenant", ["platform"] = device.Platform });
|
||||
Track("PushPairing", device.Scope, new Dictionary<string, string> { ["action"] = "resumed", ["reason"] = reason, ["platform"] = device.Platform });
|
||||
}
|
||||
|
||||
// ── Flood control (K19): the window lives on the row, merged under CAS ────
|
||||
@@ -401,14 +400,13 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
{
|
||||
var keys = _settings.Keys!;
|
||||
var service = ClassifyService(device.Endpoint);
|
||||
var scopeKind = device.Scope == Constants.Push.PlatformScope ? "platform" : "tenant";
|
||||
|
||||
var key = keys.Resolve(device.VapidKid);
|
||||
if (key == null)
|
||||
{
|
||||
await MarkStaleAsync(device, "vapid_key_mismatch", null).ConfigureAwait(false);
|
||||
await RaiseDeliveryFailedAsync(service, null, device.VapidKid, "vapid_key_mismatch", alertEventType).ConfigureAwait(false);
|
||||
Track(DeliveryEventName, new Dictionary<string, string> { ["service"] = service, ["outcome"] = "config_error", ["statusCode"] = "", ["scope"] = scopeKind, ["kid"] = device.VapidKid });
|
||||
Track(DeliveryEventName, device.Scope, new Dictionary<string, string> { ["service"] = service, ["outcome"] = "config_error", ["statusCode"] = "", ["kid"] = device.VapidKid });
|
||||
return PushOutcome.ConfigError;
|
||||
}
|
||||
|
||||
@@ -431,12 +429,11 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
}
|
||||
|
||||
await ApplyOutcomeAsync(device, result, service, alertEventType).ConfigureAwait(false);
|
||||
Track(DeliveryEventName, new Dictionary<string, string>
|
||||
Track(DeliveryEventName, device.Scope, new Dictionary<string, string>
|
||||
{
|
||||
["service"] = service,
|
||||
["outcome"] = OutcomeName(result.Outcome),
|
||||
["statusCode"] = result.StatusCode?.ToString() ?? string.Empty,
|
||||
["scope"] = scopeKind,
|
||||
["kid"] = device.VapidKid,
|
||||
["type"] = payload.Type,
|
||||
});
|
||||
@@ -552,10 +549,14 @@ public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker
|
||||
return "other";
|
||||
}
|
||||
|
||||
private void Track(string name, Dictionary<string, string> properties)
|
||||
/// <summary>Every event names the device's scope kind and, for a tenant scope, the tenant — never the device.</summary>
|
||||
private void Track(string name, string scopeKey, Dictionary<string, string> properties)
|
||||
{
|
||||
try
|
||||
{
|
||||
var platform = scopeKey == Constants.Push.PlatformScope;
|
||||
properties["scope"] = platform ? "platform" : "tenant";
|
||||
properties["tenantId"] = platform ? string.Empty : scopeKey;
|
||||
_telemetry?.TrackEvent(name, properties);
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The paired devices as the 2-hourly maintenance run finds them, reported as the <c>PushDeviceInventory</c>
|
||||
/// event: how many tenants and devices use push, on which platforms, and how many devices opened the receiver
|
||||
/// app or received a push lately. Counts only — no device, owner or tenant id leaves the backend this way.
|
||||
/// Everything except <see cref="PlatformDevices"/> counts the tenant scope; the platform scope holds the
|
||||
/// operators' own devices.
|
||||
/// </summary>
|
||||
public sealed class PushDeviceInventory
|
||||
{
|
||||
public const string EventName = "PushDeviceInventory";
|
||||
|
||||
/// <summary>Tenants with at least one Active device.</summary>
|
||||
public int Tenants { get; private init; }
|
||||
public int Devices { get; private init; }
|
||||
public int Active { get; private init; }
|
||||
public int Pending { get; private init; }
|
||||
public int Paused { get; private init; }
|
||||
public int Stale { get; private init; }
|
||||
/// <summary>Devices whose receiver app asked for its status (every open does) in the last 7 / 30 days.</summary>
|
||||
public int Opened7d { get; private init; }
|
||||
public int Opened30d { get; private init; }
|
||||
public int Delivered7d { get; private init; }
|
||||
/// <summary>Active devices per platform, under the short names the measurements carry.</summary>
|
||||
public IReadOnlyDictionary<string, int> ActiveByPlatform { get; private init; } = new Dictionary<string, int>();
|
||||
public int PlatformDevices { get; private init; }
|
||||
|
||||
public static PushDeviceInventory Count(IEnumerable<PushDevice> devices, DateTime now)
|
||||
{
|
||||
var tenantDevices = new List<PushDevice>();
|
||||
var platformDevices = 0;
|
||||
foreach (var device in devices)
|
||||
{
|
||||
if (device.Scope == Constants.Push.PlatformScope)
|
||||
platformDevices++;
|
||||
else
|
||||
tenantDevices.Add(device);
|
||||
}
|
||||
|
||||
var active = tenantDevices.Where(d => d.Status == Constants.Push.DeviceStatus.Active).ToList();
|
||||
var byPlatform = PlatformNames.Values.Distinct().ToDictionary(name => name, _ => 0);
|
||||
foreach (var device in active)
|
||||
byPlatform[PlatformName(device.Platform)]++;
|
||||
|
||||
return new PushDeviceInventory
|
||||
{
|
||||
Tenants = active.Select(d => d.Scope).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
Devices = tenantDevices.Count,
|
||||
Active = active.Count,
|
||||
Pending = tenantDevices.Count(d => d.Status == Constants.Push.DeviceStatus.Pending),
|
||||
Paused = tenantDevices.Count(d => d.Status == Constants.Push.DeviceStatus.Paused),
|
||||
Stale = tenantDevices.Count(d => d.Status == Constants.Push.DeviceStatus.Stale),
|
||||
Opened7d = tenantDevices.Count(d => d.LastOpenedUtc >= now.AddDays(-7)),
|
||||
Opened30d = tenantDevices.Count(d => d.LastOpenedUtc >= now.AddDays(-30)),
|
||||
Delivered7d = tenantDevices.Count(d => d.LastDeliveredUtc >= now.AddDays(-7)),
|
||||
ActiveByPlatform = byPlatform,
|
||||
PlatformDevices = platformDevices,
|
||||
};
|
||||
}
|
||||
|
||||
public Dictionary<string, double> ToMeasurements()
|
||||
{
|
||||
var measurements = new Dictionary<string, double>
|
||||
{
|
||||
["tenants"] = Tenants,
|
||||
["devices"] = Devices,
|
||||
["active"] = Active,
|
||||
["pending"] = Pending,
|
||||
["paused"] = Paused,
|
||||
["stale"] = Stale,
|
||||
["opened7d"] = Opened7d,
|
||||
["opened30d"] = Opened30d,
|
||||
["delivered7d"] = Delivered7d,
|
||||
["platformDevices"] = PlatformDevices,
|
||||
};
|
||||
foreach (var (name, count) in ActiveByPlatform)
|
||||
measurements[name] = count;
|
||||
return measurements;
|
||||
}
|
||||
|
||||
/// <summary>The closed platform list (<see cref="Constants.Push.Platforms"/>) under KQL-friendly names.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> PlatformNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ios-homescreen"] = "ios",
|
||||
["android-chrome"] = "android",
|
||||
["windows-chromium"] = "windows",
|
||||
["macos-safari"] = "macos",
|
||||
["firefox"] = "firefox",
|
||||
["other"] = "other",
|
||||
};
|
||||
|
||||
private static string PlatformName(string platform)
|
||||
=> PlatformNames.TryGetValue(platform ?? string.Empty, out var name) ? name : "other";
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Push;
|
||||
@@ -15,7 +16,8 @@ namespace AutopilotMonitor.Functions.Services.Push;
|
||||
/// devices are deleted <see cref="PushSettings.PausedDeviceRetentionDays"/> after the owner's
|
||||
/// last sign-in (without an owner row: after the pause itself); expired session watches go.
|
||||
/// Nothing here deletes a device a sign-in could still resume. Every step is fail-soft and
|
||||
/// reports counts; nothing here is a hot path.
|
||||
/// reports counts; nothing here is a hot path. The device sweep also reports the inventory it
|
||||
/// found (<see cref="PushDeviceInventory"/>), the one usage figure the credential table gives.
|
||||
/// </summary>
|
||||
public class PushMaintenanceService
|
||||
{
|
||||
@@ -23,17 +25,20 @@ public class PushMaintenanceService
|
||||
private readonly PushSettings _settings;
|
||||
private readonly PushDeliveryService _delivery;
|
||||
private readonly ILogger<PushMaintenanceService> _logger;
|
||||
private readonly TelemetryClient? _telemetry;
|
||||
|
||||
public PushMaintenanceService(
|
||||
IPushDeviceRepository repo,
|
||||
PushSettings settings,
|
||||
PushDeliveryService delivery,
|
||||
ILogger<PushMaintenanceService> logger)
|
||||
ILogger<PushMaintenanceService> logger,
|
||||
TelemetryClient? telemetry = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_settings = settings;
|
||||
_delivery = delivery;
|
||||
_logger = logger;
|
||||
_telemetry = telemetry;
|
||||
}
|
||||
|
||||
public async Task<PushMaintenanceResult> RunAsync(DateTime? nowUtc = null)
|
||||
@@ -66,7 +71,9 @@ public class PushMaintenanceService
|
||||
try
|
||||
{
|
||||
var lastSignIns = new Dictionary<string, DateTime?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var device in await _repo.GetAllDevicesAsync().ConfigureAwait(false))
|
||||
var devices = await _repo.GetAllDevicesAsync().ConfigureAwait(false);
|
||||
TrackInventory(PushDeviceInventory.Count(devices, now));
|
||||
foreach (var device in devices)
|
||||
{
|
||||
switch (device.Status)
|
||||
{
|
||||
@@ -134,6 +141,18 @@ public class PushMaintenanceService
|
||||
return result;
|
||||
}
|
||||
|
||||
private void TrackInventory(PushDeviceInventory inventory)
|
||||
{
|
||||
try
|
||||
{
|
||||
_telemetry?.TrackEvent(PushDeviceInventory.EventName, metrics: inventory.ToMeasurements());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Telemetry never fails the maintenance run.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DateTime?> LastSignInAsync(PushDevice device, Dictionary<string, DateTime?> cache)
|
||||
{
|
||||
var key = $"{device.OwnerHomeTenantId}|{device.OwnerObjectId}";
|
||||
|
||||
@@ -581,6 +581,7 @@ public class PushPairingService
|
||||
{
|
||||
["action"] = action,
|
||||
["scope"] = scope.IsPlatform ? "platform" : "tenant",
|
||||
["tenantId"] = scope.IsPlatform ? string.Empty : scope.Key,
|
||||
["platform"] = platform ?? string.Empty,
|
||||
["reason"] = reason ?? string.Empty,
|
||||
});
|
||||
|
||||
@@ -538,7 +538,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// structural PartitionKey (TenantId) / RowKey (SessionId). CompletedAt / IsPreProvisioned /
|
||||
/// ResumedAt must stay in the set for ComputeEffectiveDuration parity (WhiteGlove Part-2
|
||||
/// branch and the completed-at fallback); EnrollmentType / IsSelfDeployingProfile /
|
||||
/// IsHybridJoin feed the deployment-type breakdown. Everything else on the wide Sessions row — most
|
||||
/// IsHybridJoin feed the deployment-type breakdown; SerialNumber feeds the hardware lists'
|
||||
/// device count. Everything else on the wide Sessions row — most
|
||||
/// notably FailureSnapshotJson — is never read by the compute, so the projected scan skips
|
||||
/// it. Non-projected columns map to null/default via the Safe*/`?? ` getters in
|
||||
/// MapToSessionSummary. internal so UsageMetricsProjectionEquivalenceTests derives its
|
||||
@@ -547,7 +548,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
internal static readonly string[] UsageMetricsSessionProjection =
|
||||
{
|
||||
"PartitionKey", "RowKey", "StartedAt", "CompletedAt", "Status", "DurationSeconds",
|
||||
"Manufacturer", "Model", "IsPreProvisioned", "ResumedAt",
|
||||
"Manufacturer", "Model", "SerialNumber", "IsPreProvisioned", "ResumedAt",
|
||||
"EnrollmentType", "IsSelfDeployingProfile", "IsHybridJoin",
|
||||
"PlatformScriptCount", "RemediationScriptCount"
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
@@ -66,79 +67,35 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
// The daily job writes the all-tenants row of a day under this RowKey.
|
||||
private const string UsageMetricsPlatformRowKey = "global";
|
||||
|
||||
/// <summary>
|
||||
/// Gets historical metrics for a date range
|
||||
/// Tenant days with sessions between two UTC days (both inclusive): one UsageMetrics row per
|
||||
/// tenant and day, the platform row excluded, only the session count read. Not fail-soft —
|
||||
/// a swallowed failure would read as "no tenant was active".
|
||||
/// </summary>
|
||||
public async Task<List<UsageMetricsSnapshot>> GetUsageMetricsSnapshotAsync(string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100)
|
||||
public async Task<List<TenantActivityDay>> GetTenantActivityDaysAsync(DateTime fromDay, DateTime toDay)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
try
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics);
|
||||
var filter = $"PartitionKey ge '{UsageDayKey(fromDay)}' and PartitionKey le '{UsageDayKey(toDay)}'"
|
||||
+ $" and RowKey ne '{UsageMetricsPlatformRowKey}'";
|
||||
var result = new List<TenantActivityDay>();
|
||||
await foreach (var row in tableClient.QueryAsync<TableEntity>(
|
||||
filter: filter, select: new[] { "PartitionKey", "RowKey", "SessionsTotal" }).ConfigureAwait(false))
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UsageMetrics);
|
||||
|
||||
// Build filter
|
||||
var filters = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(startDate))
|
||||
filters.Add($"PartitionKey ge '{startDate}'");
|
||||
|
||||
if (!string.IsNullOrEmpty(endDate))
|
||||
filters.Add($"PartitionKey le '{endDate}'");
|
||||
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
filters.Add($"RowKey eq '{tenantId}'");
|
||||
|
||||
var filter = filters.Count > 0 ? string.Join(" and ", filters) : null;
|
||||
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
|
||||
|
||||
var results = new List<UsageMetricsSnapshot>();
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
results.Add(new UsageMetricsSnapshot
|
||||
{
|
||||
Date = entity.PartitionKey,
|
||||
TenantId = entity.RowKey,
|
||||
ComputedAt = entity.GetDateTimeOffset("ComputedAt")?.UtcDateTime ?? DateTime.UtcNow,
|
||||
ComputeDurationMs = entity.GetInt32("ComputeDurationMs") ?? 0,
|
||||
SessionsTotal = entity.GetInt32("SessionsTotal") ?? 0,
|
||||
SessionsSucceeded = entity.GetInt32("SessionsSucceeded") ?? 0,
|
||||
SessionsFailed = entity.GetInt32("SessionsFailed") ?? 0,
|
||||
SessionsInProgress = entity.GetInt32("SessionsInProgress") ?? 0,
|
||||
SessionsIncomplete = entity.GetInt32("SessionsIncomplete") ?? 0,
|
||||
SessionsSuccessRate = entity.GetDouble("SessionsSuccessRate") ?? 0,
|
||||
AvgDurationMinutes = entity.GetDouble("AvgDurationMinutes") ?? 0,
|
||||
MedianDurationMinutes = entity.GetDouble("MedianDurationMinutes") ?? 0,
|
||||
P95DurationMinutes = entity.GetDouble("P95DurationMinutes") ?? 0,
|
||||
P99DurationMinutes = entity.GetDouble("P99DurationMinutes") ?? 0,
|
||||
UniqueTenants = entity.GetInt32("UniqueTenants") ?? 0,
|
||||
UniqueUsers = entity.GetInt32("UniqueUsers") ?? 0,
|
||||
LoginCount = entity.GetInt32("LoginCount") ?? 0,
|
||||
TopManufacturers = entity.GetString("TopManufacturers") ?? "[]",
|
||||
TopModels = entity.GetString("TopModels") ?? "[]",
|
||||
UserDrivenSessions = entity.GetInt32("UserDrivenSessions") ?? 0,
|
||||
WhiteGloveSessions = entity.GetInt32("WhiteGloveSessions") ?? 0,
|
||||
AvgAppsPerSession = entity.GetDouble("AvgAppsPerSession") ?? 0,
|
||||
TotalUniqueApps = entity.GetInt32("TotalUniqueApps") ?? 0,
|
||||
AvgPlatformScriptsPerSession = entity.GetDouble("AvgPlatformScriptsPerSession") ?? 0,
|
||||
AvgRemediationScriptsPerSession = entity.GetDouble("AvgRemediationScriptsPerSession") ?? 0,
|
||||
TotalPlatformScripts = entity.GetInt32("TotalPlatformScripts") ?? 0,
|
||||
TotalRemediationScripts = entity.GetInt32("TotalRemediationScripts") ?? 0
|
||||
});
|
||||
|
||||
if (results.Count >= maxResults) break;
|
||||
}
|
||||
|
||||
return results.OrderByDescending(m => m.Date).Take(maxResults).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get historical metrics");
|
||||
return new List<UsageMetricsSnapshot>();
|
||||
var sessions = row.GetInt32("SessionsTotal") ?? 0;
|
||||
if (sessions > 0 && DateTime.TryParseExact(row.PartitionKey, UsageDayFormat, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var day))
|
||||
result.Add(new TenantActivityDay { Day = day, TenantId = row.RowKey, SessionsTotal = sessions });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private const string UsageDayFormat = "yyyy-MM-dd";
|
||||
|
||||
private static string UsageDayKey(DateTime day) => day.ToString(UsageDayFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a global usage metrics snapshot exists for a given date.
|
||||
/// Used by maintenance catch-up to determine which dates need aggregation.
|
||||
@@ -434,13 +391,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (existingExitCode.HasValue)
|
||||
summary.ExitCode = existingExitCode.Value;
|
||||
}
|
||||
// DetectionResult: preserve prior value.
|
||||
if (string.IsNullOrEmpty(summary.DetectionResult))
|
||||
{
|
||||
var existingDetection = existing.GetString("DetectionResult");
|
||||
if (!string.IsNullOrEmpty(existingDetection))
|
||||
summary.DetectionResult = existingDetection;
|
||||
}
|
||||
|
||||
// F1 PR1 — AppId identity across batches (audit Q3). First-seen appId wins
|
||||
// (mirrors the in-batch fold rule); a later batch carrying a DIFFERENT appId
|
||||
@@ -763,7 +713,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
"PartitionKey", "RowKey", "TenantId", "SessionId", "AppName", "Source", "AppType", "AppVersion",
|
||||
"Status", "TerminalState", "StartedAt", "CompletedAt", "DurationSeconds", "DownloadBytes",
|
||||
"AttemptNumber", "InstallerPhase", "FailureCode", "FailureMessage", "ExitCode", "DetectionResult",
|
||||
"AttemptNumber", "InstallerPhase", "FailureCode", "FailureMessage", "ExitCode",
|
||||
"AppId", "EspBlocking", "AppIdCollision",
|
||||
// Attempt-duration columns (2026-08): the radar's cutover gate reads the anchor,
|
||||
// the dashboard sessions table shows the pass count.
|
||||
@@ -911,7 +861,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AttemptNumber = entity.GetInt32("AttemptNumber") ?? 0,
|
||||
InstallerPhase = entity.GetString("InstallerPhase") ?? string.Empty,
|
||||
ExitCode = entity.GetInt32("ExitCode"),
|
||||
DetectionResult = entity.GetString("DetectionResult") ?? string.Empty,
|
||||
// F1 PR1 identity/blocking columns (absent on legacy rows → sentinels)
|
||||
AppId = entity.GetString("AppId") ?? string.Empty,
|
||||
EspBlocking = entity.GetBoolean("EspBlocking"),
|
||||
@@ -1168,6 +1117,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
private static TenantStats MapTenantStats(TableEntity entity) => new TenantStats
|
||||
{
|
||||
TenantId = entity.PartitionKey,
|
||||
TotalEnrollments = entity.GetInt64(nameof(TenantStats.TotalEnrollments)) ?? 0,
|
||||
SuccessfulEnrollments = entity.GetInt64(nameof(TenantStats.SuccessfulEnrollments)) ?? 0,
|
||||
TotalEventsProcessed = entity.GetInt64(nameof(TenantStats.TotalEventsProcessed)) ?? 0,
|
||||
@@ -2146,8 +2096,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["AppType"] = summary.AppType ?? string.Empty,
|
||||
["AttemptNumber"] = summary.AttemptNumber,
|
||||
["InstallerPhase"] = summary.InstallerPhase ?? string.Empty,
|
||||
["ExitCode"] = summary.ExitCode,
|
||||
["DetectionResult"] = summary.DetectionResult ?? string.Empty
|
||||
["ExitCode"] = summary.ExitCode
|
||||
};
|
||||
|
||||
// Sentinel-gated lifecycle columns: only write when the current batch observed them.
|
||||
|
||||
@@ -314,25 +314,33 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return performanceMetrics;
|
||||
}
|
||||
|
||||
private static HardwareMetrics BuildHardwareMetrics(List<SessionSummary> sessions) => new()
|
||||
internal static HardwareMetrics BuildHardwareMetrics(IReadOnlyCollection<SessionSummary> sessions) => new()
|
||||
{
|
||||
TopManufacturers = TopHardware(sessions, s => s.Manufacturer),
|
||||
TopModels = TopHardware(sessions, s => s.Model)
|
||||
};
|
||||
|
||||
private static List<HardwareCount> TopHardware(List<SessionSummary> sessions, Func<SessionSummary, string> key)
|
||||
private static List<HardwareCount> TopHardware(IReadOnlyCollection<SessionSummary> sessions, Func<SessionSummary, string> key)
|
||||
=> sessions
|
||||
.GroupBy(key)
|
||||
.Select(g => new HardwareCount
|
||||
{
|
||||
Name = g.Key,
|
||||
Count = g.Count(),
|
||||
DeviceCount = g.Select(DeviceKey).Distinct().Count(),
|
||||
Percentage = MetricsMath.SharePct(g.Count(), sessions.Count)
|
||||
})
|
||||
.OrderByDescending(x => x.Count)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The device-journey key (normalized serial) per tenant; a session without a usable serial
|
||||
/// keys on itself, so it counts as one device and DeviceCount never exceeds Count.
|
||||
/// </summary>
|
||||
private static (string TenantId, string Key) DeviceKey(SessionSummary session)
|
||||
=> (session.TenantId, DeviceJourneyCalculator.NormalizeSerial(session.SerialNumber) ?? "session:" + session.SessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Splits the window by the fleet enrollment class (<see cref="TimeAttributionCalculator.GetEnrollmentClass"/>),
|
||||
/// so the deployment modes exclude each other and sum to the Autopilot v1 count. The session's
|
||||
|
||||
+251
-237
@@ -17,7 +17,7 @@
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.12",
|
||||
"undici": "^8.11.2",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"bin": {
|
||||
@@ -30,11 +30,11 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.41.0",
|
||||
"knip": "^6.42.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.71.2",
|
||||
"vitest": "^4.1.11"
|
||||
"typescript-eslint": "^8.72.0",
|
||||
"vitest": "^4.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -1283,9 +1283,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1384,9 +1384,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm-eabi": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.153.0.tgz",
|
||||
"integrity": "sha512-BcWu25dMwMjHpaDCEidmpAi7rGg1qZoihJeUUH9bD55pVZ0z4Wa5CQgqSTo0R41H81oozisQKW3WqvAN2m77IQ==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.154.0.tgz",
|
||||
"integrity": "sha512-sDdEtDl7vKdCvf54j72RJbowE3jCmyRz/+oOB6IAwQxAHZ0eI+Dcj4hvVnm0b9Dy31plUkdmfds9C+/Zt895Tg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1401,9 +1401,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-android-arm64": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.153.0.tgz",
|
||||
"integrity": "sha512-9l5rdp2DhRqVriBHsNpu5cEf+hizZ4B58rt93HzyQEBUgZiC6qe2oFpaAAnXGCxHGnQExI1s7S2G0QhFacUZ6w==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.154.0.tgz",
|
||||
"integrity": "sha512-UHrDPELzvWZoVkIDdSycj4bzcTMCJG3IbN++mSUgVBaUtNtWHjQ98U6Vz5bpF/1Ic3FYJyYhMr74huO5tVvpZw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1418,9 +1418,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-darwin-arm64": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.153.0.tgz",
|
||||
"integrity": "sha512-EcpsyRXHhENDpekpG4QNaSZj2F/BAz4Gh4o89zZwNss86R8KyRSQ6sXbAj74Xyeb4z3j/yBZZ6joQZhraIWyfw==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.154.0.tgz",
|
||||
"integrity": "sha512-mokYzajOSXxtSRDWCdHARGdntrKH+rZZ24Jzo8AtafyNzz0NARmpMm26Tc5B6qiGrr6UliWibHCOif1qRN2vaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1435,9 +1435,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-darwin-x64": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.153.0.tgz",
|
||||
"integrity": "sha512-hlj80HUQy07/2lYp6rmxH+3u+H2ik3rjLR52CUdPApxmZCNxASDUf/+ykTzAHS/fehftmVMATKvPaWmuLHuasg==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.154.0.tgz",
|
||||
"integrity": "sha512-WbtUBrclOVItWfLRSvs+Z/WLtXxJ2Ctv50PyuX894HzDN5/e0qzxVpTNPIVMzOerBhgKtFsSMcfxlYIAbvdVgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1452,9 +1452,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-freebsd-x64": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.153.0.tgz",
|
||||
"integrity": "sha512-fWRcv2pMSh1qtez46MOu8vuUJ5fiaQZPHSphbWulbL7SHBMKfRgZl2PzjXZYQJbq7NEXyXbgIuIuUm+/Rmkv8w==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.154.0.tgz",
|
||||
"integrity": "sha512-YooHZSLawOe2fqm+lDdG8oAaQh2NYV/RMYTYzWRLy72stm2BgE9O/8Al6kFENjkmzyyBccDLLBirGxVtpzQfrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1469,9 +1469,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.153.0.tgz",
|
||||
"integrity": "sha512-paZeUK1GXShl/bOJQET1/8AQxayFeuN4SIWAkgTRxE7ZQwv9uYSB2n0648smygP22N7jcI3zxAm0Z+ALNiIKxg==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.154.0.tgz",
|
||||
"integrity": "sha512-hKOwhZbrXdTwc4bGVfbLCXl9f2swl6zWdISocLQSNX9MgPmwHE3qGzOUkCrkuGpwdCVHPnkfOwsqTuYsrP5uNg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1486,9 +1486,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.153.0.tgz",
|
||||
"integrity": "sha512-qFPp5NwMf4K1C0O+DCVGmcbOaYyYAq98Rvv9p4VSfb777iUwlJCqMUxf6QG9N5f5ls4TEX6LitOZHi+6GL6rYQ==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.154.0.tgz",
|
||||
"integrity": "sha512-tnjiooXEU013349O/yxKbF5Cusp1CfHWQ8kYR8tGeRzR/P4sOpQGQo21fsS71M0fMD6ESLq+lbvXFWi43PuKeQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1503,13 +1503,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm64-gnu": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.153.0.tgz",
|
||||
"integrity": "sha512-oufEhZCX7saeo27Logra8cvBR1jQ/yw2zUI2hHmWw3qA/XYqEW31FOvLQBv7DWN32NjVdlcaP11WBBZTJo4k9g==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.154.0.tgz",
|
||||
"integrity": "sha512-Cao2kj+8d7BT6XJU1gV9eVQjV+Cr+e8vQm3tddDRZiM2hHrMDxwy3j6t+ofaSl0idyuPPCZ4AlpmQoj8yOT1QA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1520,13 +1523,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-arm64-musl": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.153.0.tgz",
|
||||
"integrity": "sha512-zXYzuAqng85h6+LI3iRm0zeMLX1/K2z41ZAMWxVRgt0D10VvZUaV5q+TEB46KDhoKBpdexoTLC6QoB/EiP2oqQ==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.154.0.tgz",
|
||||
"integrity": "sha512-uiwA0V3q8llWA7/8r+a9BBjk4j5YMDmi6wfOsyQfGOJMQSLHEiZ3HI8ulea766ykUa9wJFWk9XWEl4uJrZLQqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1537,13 +1543,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.153.0.tgz",
|
||||
"integrity": "sha512-J7+NLwLEzdkKM9cKyPHH/AjXXstWOs98KYgLrPPnO4N9MO8EZRBfPNnBcS3S67uMOBkRpUmCl2FhRnWdQ5CbBA==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.154.0.tgz",
|
||||
"integrity": "sha512-LICfN0Xs4Zdko+bJy6eOAEQojDuIR26suF2JtOsMaXjgbCrpRSH7/NCvJhY13YvYBx12V6hnwrL8n0lk0rbNJQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1554,13 +1563,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.153.0.tgz",
|
||||
"integrity": "sha512-sD0K11GQTLWylln54nPMMTtgmBbQrR5It/vMpZpy/fogiw8O2AvIE+9LuWUXddrWk9zvFIHGRcSgFn4IHOfoYg==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.154.0.tgz",
|
||||
"integrity": "sha512-gbbSYxLF8ssRbQ2qQPy+2EhGNwNH1bAoRdkk6cnWaKHqmCikpSIPtpXjFbCCtjTi0uojqOM3ghFQwQPbDQsU0Q==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1571,13 +1583,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-riscv64-musl": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.153.0.tgz",
|
||||
"integrity": "sha512-7L66uql2BhvSz51LwfExjOdn6et/ydTiQxl23VApdKzJhTc3TczA1Qt6VR/UcvlQrL2ZbytIJb447vh7p5wmUw==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.154.0.tgz",
|
||||
"integrity": "sha512-nSnRYYDLCcRVjSQzF3g6rKqCNYwo5o+aQgnn8BBXfe+ofCgMGlGqkfRyvXm6ptZvoxNkkWtLlqM8W7Xc3j6ZnQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1588,13 +1603,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-s390x-gnu": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.153.0.tgz",
|
||||
"integrity": "sha512-hXV3T7mNyrPR19rbPYkglkQJtoM/UNo/xwbZeeqdAxEg8PXgb4LXXuxPpGgI4bzRRCWwLUxdDSS58xIyC2u1TA==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.154.0.tgz",
|
||||
"integrity": "sha512-BxBQoJKtzRQgZEKQIt6CDUm8RGKC5T8Qo4576yDa5/xgc10X3fBDItMbRabzLtbUBfTqKLl+8AVr/H6CF7W22g==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1605,13 +1623,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-x64-gnu": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.153.0.tgz",
|
||||
"integrity": "sha512-Da8Vqs1xdtoDYtEjmozD7wGgJeLBQkMbR0WUBECOSxodyX5DXy8ySlvJWbukyO2Q0+TM9EK+4LT6tbq0jCg4hQ==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.154.0.tgz",
|
||||
"integrity": "sha512-/whkust2vYy+GjRUVL5cWaJmEu6jkdFfo5mkxgckyeOX61eMwo4iBoiwzeJZeMWAdzb/XOtQyLApHhFht3sOWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1622,13 +1643,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-linux-x64-musl": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.153.0.tgz",
|
||||
"integrity": "sha512-zGAPxNTIIw6X5WyTRscIFE/oDjpdNtrrl32ing6NOXdGjzRucadnxBilexLdeYQ9YxixF93GujpXyQ0jw7ddSg==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.154.0.tgz",
|
||||
"integrity": "sha512-EXURTyW9riw4B/kZnnE+58emAtw6+3VEszQfXUZkRqGaKIcmWDYkZtKB+xY/blHvuZ+Qy/fDclc31nmUCgtkSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1639,9 +1663,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-openharmony-arm64": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.153.0.tgz",
|
||||
"integrity": "sha512-GoHzpqP5XyPIp9DHokoEdCp+K4UPl/h1neFj1uXnUB3pJYyOQ7NOhFVOttNUgCGcj2eywAvu5cRivTOSq4S10g==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.154.0.tgz",
|
||||
"integrity": "sha512-ZRFooyH3qsR6vuR4jFGlG3XM36490SvvXtNaUahBK4zHpooMdTHNaOMtGGJI0EW38m5hi4rOco9kw7ZwCIp28w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1656,9 +1680,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.153.0.tgz",
|
||||
"integrity": "sha512-xGcy12tO9k7zFbeyQzEuilfWfQpyRq23/fLPfN3wjSWuWv58k46fQT1hsO1KFNykZuVGW9xSJE/z592TmAoSDA==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.154.0.tgz",
|
||||
"integrity": "sha512-U0Vp+4lvwUa/PnOCJTZOjydj1rX2+VLHz4Y2vSFqlN8/7LTiURgD7z4uQ9yNv9O/NlU3mELBnPDlu7aaSYG1zQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1673,9 +1697,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-ia32-msvc": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.153.0.tgz",
|
||||
"integrity": "sha512-o1ykUjWncvXX6N5a4KebgLjtD8zRui+f1llMhSGoSC9oP2KOqapRpdwmZ3NcNG0axFouwVKmhGBjd+IhA8BksA==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.154.0.tgz",
|
||||
"integrity": "sha512-Hc8QgsFY5eUUP25we8TISUruibG2cDt0H4IcPG9RKdabb6odkCzIqjV6Gk9beGWGuOwMVCr81H/Su1q363LCCg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1690,9 +1714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-x64-msvc": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.153.0.tgz",
|
||||
"integrity": "sha512-gxQz7G96Up136fd+2zWrP+o6FYGs9NO4Dyv55IoeJFJUDwLFHcLYY11hhA5wQKs0HoM57Q8esQ3nEVhnCdsjAQ==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.154.0.tgz",
|
||||
"integrity": "sha512-hD7judkUMA/hZPoamvKADs6+an1smp8UTK0aMYRijkvi00K/1vl7vEtmyn2JyQ9S+vibtnfcNzxqTLGyJUZCRQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2476,17 +2500,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz",
|
||||
"integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
|
||||
"integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/type-utils": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.0",
|
||||
"@typescript-eslint/type-utils": "8.72.0",
|
||||
"@typescript-eslint/utils": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -2499,15 +2523,15 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.71.2",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.12",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
|
||||
"integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz",
|
||||
"integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2515,16 +2539,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz",
|
||||
"integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz",
|
||||
"integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.0",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2540,14 +2564,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz",
|
||||
"integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
|
||||
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.71.2",
|
||||
"@typescript-eslint/types": "^8.71.2",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.72.0",
|
||||
"@typescript-eslint/types": "^8.72.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2562,14 +2586,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz",
|
||||
"integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
|
||||
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2"
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2580,9 +2604,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz",
|
||||
"integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
|
||||
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2597,15 +2621,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz",
|
||||
"integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz",
|
||||
"integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||
"@typescript-eslint/utils": "8.72.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -2622,9 +2646,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz",
|
||||
"integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
|
||||
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2636,16 +2660,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz",
|
||||
"integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
|
||||
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.71.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"@typescript-eslint/project-service": "8.72.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.72.0",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -2703,16 +2727,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz",
|
||||
"integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
|
||||
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2"
|
||||
"@typescript-eslint/scope-manager": "8.72.0",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2727,13 +2751,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz",
|
||||
"integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
|
||||
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2758,16 +2782,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -2776,13 +2800,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -2803,9 +2827,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2816,13 +2840,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -2830,14 +2854,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -2846,9 +2870,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2856,13 +2880,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -2980,18 +3004,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^2.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
@@ -3063,9 +3087,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3757,9 +3781,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4151,9 +4175,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -4327,9 +4351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.41.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.41.0.tgz",
|
||||
"integrity": "sha512-DrhQlIuV6xScd5Q+WTF1znd1w41QzxFGCtAHXCajsH/ndGMotHoNYbdXbuFtzW3mIOmniHtZ7zCtvnQkUmJtQQ==",
|
||||
"version": "6.42.1",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.42.1.tgz",
|
||||
"integrity": "sha512-gKJVsiT4LJQ0lfc8uSHOCzkOY8adAXNFGarIeODZbJOkBnQRVsG3e0UZFnO4LMwjA82A5tBWX/VgoeTYcjdKiQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4346,13 +4370,13 @@
|
||||
"formatly": "^0.11.4",
|
||||
"get-tsconfig": "4.14.3",
|
||||
"jiti": "^2.7.0",
|
||||
"oxc-parser": "^0.153.0",
|
||||
"oxc-parser": "^0.154.0",
|
||||
"oxc-resolver": "11.24.2",
|
||||
"picomatch": "^4.0.7",
|
||||
"smol-toml": "^1.9.0",
|
||||
"strip-json-comments": "5.0.3",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"unbash": "^5.0.0",
|
||||
"unbash": "^6.0.0",
|
||||
"yaml": "^2.9.1",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
@@ -4960,13 +4984,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-parser": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.153.0.tgz",
|
||||
"integrity": "sha512-cEnpyyV1OJRXv++SFNw0z8i+2o/cr88rVLaOYJafELDoHM5yHvCAbwGRLvxuany6SNLcbblSMUyZaGzH+quSYg==",
|
||||
"version": "0.154.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.154.0.tgz",
|
||||
"integrity": "sha512-Gr/6fkjZwi937c4Tsz8y0iO4U0FLPkZI6JSVucLAIvQyqh379px7RS8ORIc0HOORbFjXZ6zyqWLl53qxhSeofw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.153.0"
|
||||
"@oxc-project/types": "^0.154.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
@@ -4975,35 +4999,25 @@
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxc-parser/binding-android-arm-eabi": "0.153.0",
|
||||
"@oxc-parser/binding-android-arm64": "0.153.0",
|
||||
"@oxc-parser/binding-darwin-arm64": "0.153.0",
|
||||
"@oxc-parser/binding-darwin-x64": "0.153.0",
|
||||
"@oxc-parser/binding-freebsd-x64": "0.153.0",
|
||||
"@oxc-parser/binding-linux-arm-gnueabihf": "0.153.0",
|
||||
"@oxc-parser/binding-linux-arm-musleabihf": "0.153.0",
|
||||
"@oxc-parser/binding-linux-arm64-gnu": "0.153.0",
|
||||
"@oxc-parser/binding-linux-arm64-musl": "0.153.0",
|
||||
"@oxc-parser/binding-linux-ppc64-gnu": "0.153.0",
|
||||
"@oxc-parser/binding-linux-riscv64-gnu": "0.153.0",
|
||||
"@oxc-parser/binding-linux-riscv64-musl": "0.153.0",
|
||||
"@oxc-parser/binding-linux-s390x-gnu": "0.153.0",
|
||||
"@oxc-parser/binding-linux-x64-gnu": "0.153.0",
|
||||
"@oxc-parser/binding-linux-x64-musl": "0.153.0",
|
||||
"@oxc-parser/binding-openharmony-arm64": "0.153.0",
|
||||
"@oxc-parser/binding-win32-arm64-msvc": "0.153.0",
|
||||
"@oxc-parser/binding-win32-ia32-msvc": "0.153.0",
|
||||
"@oxc-parser/binding-win32-x64-msvc": "0.153.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-parser/node_modules/@oxc-project/types": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
|
||||
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
"@oxc-parser/binding-android-arm-eabi": "0.154.0",
|
||||
"@oxc-parser/binding-android-arm64": "0.154.0",
|
||||
"@oxc-parser/binding-darwin-arm64": "0.154.0",
|
||||
"@oxc-parser/binding-darwin-x64": "0.154.0",
|
||||
"@oxc-parser/binding-freebsd-x64": "0.154.0",
|
||||
"@oxc-parser/binding-linux-arm-gnueabihf": "0.154.0",
|
||||
"@oxc-parser/binding-linux-arm-musleabihf": "0.154.0",
|
||||
"@oxc-parser/binding-linux-arm64-gnu": "0.154.0",
|
||||
"@oxc-parser/binding-linux-arm64-musl": "0.154.0",
|
||||
"@oxc-parser/binding-linux-ppc64-gnu": "0.154.0",
|
||||
"@oxc-parser/binding-linux-riscv64-gnu": "0.154.0",
|
||||
"@oxc-parser/binding-linux-riscv64-musl": "0.154.0",
|
||||
"@oxc-parser/binding-linux-s390x-gnu": "0.154.0",
|
||||
"@oxc-parser/binding-linux-x64-gnu": "0.154.0",
|
||||
"@oxc-parser/binding-linux-x64-musl": "0.154.0",
|
||||
"@oxc-parser/binding-openharmony-arm64": "0.154.0",
|
||||
"@oxc-parser/binding-win32-arm64-msvc": "0.154.0",
|
||||
"@oxc-parser/binding-win32-ia32-msvc": "0.154.0",
|
||||
"@oxc-parser/binding-win32-x64-msvc": "0.154.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-resolver": {
|
||||
@@ -5261,9 +5275,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
|
||||
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -5757,9 +5771,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
||||
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
|
||||
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5872,16 +5886,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz",
|
||||
"integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.0.tgz",
|
||||
"integrity": "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.71.2",
|
||||
"@typescript-eslint/parser": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2"
|
||||
"@typescript-eslint/eslint-plugin": "8.72.0",
|
||||
"@typescript-eslint/parser": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||
"@typescript-eslint/utils": "8.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -5896,9 +5910,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unbash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbash/-/unbash-5.1.0.tgz",
|
||||
"integrity": "sha512-YVGyy4XqoZ2AVgHjVdL9cchMSurlXFHWviORa8RiU/Bnol4ooW5Lpyur/5XmbgDdyCKxymmgsEQsLGBMIJ/Agg==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unbash/-/unbash-6.0.0.tgz",
|
||||
"integrity": "sha512-vD5CvvFLVQDhXdNyNEPPa/tHPCK3HLySddOine+OZLDYIH1cMImGNgnw9+NkizToaPsFsQlwiyPGMiK+uwtYVw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
@@ -5906,9 +5920,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
|
||||
"integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==",
|
||||
"version": "8.12.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
|
||||
"integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -6027,19 +6041,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -6067,12 +6081,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.12",
|
||||
"undici": "^8.11.2",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -40,15 +40,15 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.41.0",
|
||||
"knip": "^6.42.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.71.2",
|
||||
"vitest": "^4.1.11"
|
||||
"typescript-eslint": "^8.72.0",
|
||||
"vitest": "^4.1.12"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.35.5",
|
||||
"fast-uri": "^3.1.4",
|
||||
"fast-uri": "^3.1.9",
|
||||
"global-agent": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,9 @@ interface Role {
|
||||
maxCatalogChars: number;
|
||||
}
|
||||
const ROLES: Role[] = [
|
||||
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 92_000 },
|
||||
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 77_000 },
|
||||
// Raised 2026-10-28 by +1,100 each: the platform-only get_growth_metrics (measured 1,106 chars).
|
||||
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 93_100 },
|
||||
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_100 },
|
||||
{ label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin', maxCatalogChars: 53_000 },
|
||||
// Lowered 2026-10-11: Operators/Viewers and delegated callers without the home Admin role no longer
|
||||
// list the two rule dry-runs (measured 49,385 and 53,645 chars).
|
||||
|
||||
@@ -127,6 +127,8 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'get_fleet_overview',
|
||||
'get_geographic_metrics',
|
||||
'get_geographic_sessions',
|
||||
// Platform scope (GA + Global Reader): GlobalReadOrAdmin endpoint, operator growth figures.
|
||||
'get_growth_metrics',
|
||||
'get_ime_pattern_health',
|
||||
'get_ime_version_history',
|
||||
'get_metrics',
|
||||
@@ -224,6 +226,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'get_api_usage',
|
||||
'get_deployment_state',
|
||||
'get_fleet_overview',
|
||||
'get_growth_metrics',
|
||||
'get_ime_pattern_health',
|
||||
'get_ops_events',
|
||||
'get_platform_metrics',
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Unit tests for get_growth_metrics: platform roles only, and every argument reaches the backend
|
||||
* query, so the join of signups, counters and daily usage rows stays server-side.
|
||||
*
|
||||
* apiFetch is mocked so these run with no backend / token.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() }));
|
||||
|
||||
vi.mock('../client.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../client.js')>();
|
||||
return { ...actual, apiFetch: apiFetchMock };
|
||||
});
|
||||
|
||||
import { registerAdminTools } from '../tools/admin.js';
|
||||
|
||||
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
|
||||
|
||||
function handlers(ga: boolean, strictGa: boolean): Record<string, Handler> {
|
||||
const registered: Record<string, Handler> = {};
|
||||
const fake = { registerTool: (name: string, _def: unknown, handler: Handler) => { registered[name] = handler; } };
|
||||
registerAdminTools(fake as never, ga, strictGa, false);
|
||||
return registered;
|
||||
}
|
||||
|
||||
const params = (path: string) => new URLSearchParams(path.slice(path.indexOf('?') + 1));
|
||||
|
||||
describe('get_growth_metrics — registration', () => {
|
||||
it('is listed for Global Admins and Global Readers only', () => {
|
||||
expect(handlers(true, true)).toHaveProperty('get_growth_metrics');
|
||||
expect(handlers(true, false)).toHaveProperty('get_growth_metrics');
|
||||
expect(handlers(false, false)).not.toHaveProperty('get_growth_metrics');
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_growth_metrics — request', () => {
|
||||
beforeEach(() => apiFetchMock.mockReset());
|
||||
|
||||
it('forwards days, timeZone and includeTenants to the growth route', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce({ windowDays: 90 });
|
||||
|
||||
await handlers(true, true).get_growth_metrics({ days: 90, timeZone: 'Europe/Berlin', includeTenants: true });
|
||||
|
||||
const path = apiFetchMock.mock.calls[0][0] as string;
|
||||
expect(path.startsWith('/api/global/metrics/growth?')).toBe(true);
|
||||
const q = params(path);
|
||||
expect(q.get('days')).toBe('90');
|
||||
expect(q.get('timeZone')).toBe('Europe/Berlin');
|
||||
expect(q.get('includeTenants')).toBe('true');
|
||||
});
|
||||
|
||||
it('sends the excluded tenants as one comma-separated parameter', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce({ windowDays: 30, excludedTenants: 2 });
|
||||
const ids = ['aaaaaaaa-0000-4000-8000-000000000000', 'bbbbbbbb-0000-4000-8000-000000000000'];
|
||||
|
||||
await handlers(true, true).get_growth_metrics({ excludeTenantIds: ids });
|
||||
|
||||
const q = params(apiFetchMock.mock.calls[0][0] as string);
|
||||
expect(q.getAll('excludeTenantIds')).toEqual([ids.join(',')]);
|
||||
});
|
||||
|
||||
it('leaves unset arguments to the backend defaults', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce({ windowDays: 30 });
|
||||
|
||||
await handlers(true, true).get_growth_metrics({});
|
||||
|
||||
expect(apiFetchMock.mock.calls[0][0]).toBe('/api/global/metrics/growth');
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,40 @@ describe('query_table — OData filter forwarded verbatim and URL-safely encoded
|
||||
});
|
||||
});
|
||||
|
||||
// ── query_table: the fields= projection rides along with the nextLink ───────
|
||||
describe('query_table — fields projection is forwarded and kept across pages', () => {
|
||||
it('sends fields to the backend on the first page', async () => {
|
||||
const handler = gaHandler('query_table');
|
||||
const { calls } = stubFetchCapture();
|
||||
|
||||
await runWithCaller(GA, () => handler({ tableName: 'Sessions', fields: 'Status,StartedAt' }, extra));
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(new URL(calls[0].url, 'https://base.invalid').searchParams.get('fields')).toBe('Status,StartedAt');
|
||||
});
|
||||
|
||||
it('keeps the projection the backend echoed in its nextLink', async () => {
|
||||
const handler = gaHandler('query_table');
|
||||
const { calls } = stubFetchCapture();
|
||||
const ownLink = '/api/global/raw/tables/Sessions?pageSize=200&continuation=abc&fields=Status%2CStartedAt';
|
||||
|
||||
await runWithCaller(GA, () => handler({ tableName: 'Sessions', continuation: ownLink }, extra));
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(new URL(calls[0].url, 'https://base.invalid').searchParams.get('fields')).toBe('Status,StartedAt');
|
||||
});
|
||||
|
||||
it('lets a follow-up call replace the projection', async () => {
|
||||
const handler = gaHandler('query_table');
|
||||
const { calls } = stubFetchCapture();
|
||||
const ownLink = '/api/global/raw/tables/Sessions?pageSize=200&continuation=abc&fields=Status';
|
||||
|
||||
await runWithCaller(GA, () => handler({ tableName: 'Sessions', continuation: ownLink, fields: 'DeviceName' }, extra));
|
||||
|
||||
expect(new URL(calls[0].url, 'https://base.invalid').searchParams.get('fields')).toBe('DeviceName');
|
||||
});
|
||||
});
|
||||
|
||||
// ── query_backend_logs: KQL forwarded verbatim over POST ────────────────────
|
||||
describe('query_backend_logs — KQL forwarded verbatim in POST body (backend is the gate)', () => {
|
||||
const queries: Array<[label: string, query: string]> = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user