mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be239b74b8 | |||
| 4fee61bf68 | |||
| e8b8a8de0c | |||
| c287cb3eae | |||
| 182ea2e220 | |||
| a6e8fe320f | |||
| e5b69325a2 | |||
| ee1566ae20 | |||
| a675c70e2b | |||
| b45a7ae2ff | |||
| b2f6b7bd7f | |||
| bd064b1164 | |||
| a3f98abcb5 | |||
| fb4ba57a7a | |||
| 51200702c9 | |||
| d093be3e8d | |||
| 4868a4448d | |||
| 03a8bf92c9 | |||
| 7265052936 | |||
| 0c01f3ed76 | |||
| caa6952ae7 | |||
| 6bc503f670 | |||
| 87c5446cde | |||
| cb9b34a7aa | |||
| 2bb3dcd69e | |||
| 36aeec1b45 | |||
| 033781d083 | |||
| 8a733be369 | |||
| 91251ac42a | |||
| 2a03bebc58 | |||
| 2e6fa5ac74 | |||
| a61a50cd53 | |||
| 1271658b16 | |||
| 5485f75620 | |||
| 609fcbacaf | |||
| 7b7d35f428 | |||
| a5a1039d22 | |||
| 9d50ff8901 | |||
| 0ce59b700c | |||
| 309e15bd33 | |||
| 28da10e9fb | |||
| 36aafb655f | |||
| 24ea5c76c8 | |||
| f5117e2290 | |||
| 0cd8baa85d | |||
| cd009071db | |||
| f13b823387 | |||
| 2517e8eb28 | |||
| d378fabc01 | |||
| be0e37008a | |||
| 04fae4fe86 | |||
| 2b29019b59 | |||
| aa578def0e | |||
| 7eceed16e1 | |||
| 27454d5744 | |||
| 46b8503207 | |||
| 50bb09eb92 | |||
| aaf9b74426 | |||
| e31a5d2875 | |||
| 5dc17377f6 | |||
| e2e12055bf | |||
| 134ea71d63 | |||
| 2f9a404323 | |||
| 964a548e81 | |||
| 5d5a893557 | |||
| d4e4170813 | |||
| 6d313d2832 | |||
| 0618df3c56 | |||
| c6fda24843 | |||
| 32bfa8b5b7 | |||
| c7bfc1e2f1 | |||
| 2fb533ab92 | |||
| b0b38c1cfa | |||
| 34c8715c5a | |||
| 5e240d7b81 | |||
| a621a00252 | |||
| d1063f17cb | |||
| ed517b5b40 | |||
| 46058ffb90 | |||
| 81d0fe6ed3 | |||
| a26cd5edf7 |
+1
-1
Submodule .claude/commands updated: d05399a386...1be456ea91
@@ -65,7 +65,7 @@ updates:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
# Validator tooling for rules/ (ajv-cli); combine-rules.yml installs it from this
|
||||
# Validator tooling for rules/ (ajv for validate.js); combine-rules.yml installs it from this
|
||||
# lockfile with contents:write in hand, so the pins must not go stale.
|
||||
- package-ecosystem: npm
|
||||
directory: /rules/scripts
|
||||
|
||||
@@ -26,7 +26,10 @@ name: Workflow gates
|
||||
#
|
||||
# The pins are not maintained by hand — .github/dependabot.yml opens a monthly grouped
|
||||
# PR that rewrites both the SHA and the trailing version comment. The comment is
|
||||
# therefore required too: it is what makes a pinned line reviewable.
|
||||
# therefore required too: it is what makes a pinned line reviewable. It names the exact
|
||||
# release tag (# v7.0.1), never the floating major (# v7): a major tag moves with every
|
||||
# upstream release, after which the comment no longer names the pinned commit and
|
||||
# zizmor's ref-version-mismatch flags every line that carries it.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -45,7 +48,7 @@ jobs:
|
||||
name: Verify action pins
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -59,13 +62,15 @@ jobs:
|
||||
# nothing to pin.
|
||||
case "$ref" in ./*|\$/*) continue ;; esac
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*[^[:space:]] ]]; then
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+([[:space:]]|$) ]]; then
|
||||
printf 'ok %s:%s %s\n' "$file" "$line" "$ref"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]*$ ]]; then
|
||||
echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@<sha> # vN' so the line stays reviewable."
|
||||
echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@<sha> # vX.Y.Z' so the line stays reviewable."
|
||||
elif [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+# ]]; then
|
||||
echo "::error file=$file,line=$line::The version comment must name the exact release tag the SHA resolves to ('# vX.Y.Z'), not a floating major: '$ref'. Find it with: gh api repos/<owner>/<action>/git/matching-refs/tags/vN"
|
||||
else
|
||||
echo "::error file=$file,line=$line::Action '$ref' is not pinned to a commit SHA. Tags are mutable -- resolve it with: gh api repos/<owner>/<action>/git/ref/tags/<tag>"
|
||||
fi
|
||||
@@ -89,7 +94,7 @@ jobs:
|
||||
security-events: write # SARIF upload: findings land in the Code scanning tab next to CodeQL
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -464,7 +464,7 @@ jobs:
|
||||
# Skipped on runs that publish nothing -- no log entry for bytes nobody receives.
|
||||
- name: Attest build provenance
|
||||
if: inputs.upload_blob || inputs.create_release
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: |
|
||||
${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
@@ -472,13 +472,13 @@ jobs:
|
||||
staging/agent/AutopilotMonitor.SummaryDialog.exe
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
|
||||
path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload version metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -240,12 +240,12 @@ jobs:
|
||||
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (MSI)
|
||||
if: inputs.upload_blob
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
|
||||
+14
-11
@@ -98,11 +98,11 @@ jobs:
|
||||
# windows-latest is required: the agent test projects target net48.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -124,6 +124,9 @@ jobs:
|
||||
- name: Summary Dialog tests
|
||||
run: dotnet test src/Agent/AutopilotMonitor.SummaryDialog.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
|
||||
|
||||
- name: Push tests
|
||||
run: dotnet test src/Push/AutopilotMonitor.Push.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
|
||||
|
||||
web:
|
||||
name: Web (typecheck + tests)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -131,11 +134,11 @@ jobs:
|
||||
run:
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -167,11 +170,11 @@ jobs:
|
||||
run:
|
||||
working-directory: src/McpServer/autopilot-monitor-mcp
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -189,7 +192,7 @@ jobs:
|
||||
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
|
||||
# is honored by vector-search-provider.ts.
|
||||
- name: Cache embedding model
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ github.workspace }}/.hf-cache
|
||||
key: hf-model-Xenova-all-MiniLM-L6-v2
|
||||
@@ -203,11 +206,11 @@ jobs:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -226,11 +229,11 @@ jobs:
|
||||
name: Generated wire types match shared-manifests.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
@@ -104,11 +104,11 @@ jobs:
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
if: matrix.language == 'csharp'
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -24,49 +24,26 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Lockfile and no lifecycle scripts: this job holds contents:write and pushes to
|
||||
# main, so an install-time script from a hijacked package would run with a token that
|
||||
# can rewrite the repository. rules/scripts/package-lock.json pins ajv-cli, ajv-formats
|
||||
# can rewrite the repository. rules/scripts/package-lock.json pins ajv, ajv-formats
|
||||
# and every transitive package (Dependabot keeps it current); ajv is pure JS and
|
||||
# needs no install hooks.
|
||||
- name: Install ajv-cli from the lockfile
|
||||
- name: Install the rules validator from the lockfile
|
||||
working-directory: rules/scripts
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Validate Gather Rules
|
||||
run: |
|
||||
for f in rules/gather/*.json; do
|
||||
echo "Validating $f..."
|
||||
ajv validate -s rules/schema/gather-rule.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
- name: Validate Analyze Rules
|
||||
run: |
|
||||
for f in rules/analyze/*.json; do
|
||||
echo "Validating $f..."
|
||||
ajv validate -s rules/schema/analyze-rule.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
- name: Validate IME Log Patterns
|
||||
run: |
|
||||
for f in rules/ime-log-patterns/*.json; do
|
||||
echo "Validating $f..."
|
||||
ajv validate -s rules/schema/ime-log-pattern.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
# guardrails.json values are baked verbatim into generated TypeScript that is
|
||||
# auto-committed to main and shipped to the portal - the schema forbids control
|
||||
# and line-terminator characters so a "data" PR can never become code.
|
||||
- name: Validate Guardrails
|
||||
run: ajv validate -s rules/schema/guardrails.schema.json -d rules/guardrails.json --spec=draft2020 -c ajv-formats
|
||||
# Gather, analyze and IME log pattern files plus guardrails.json, each against its
|
||||
# schema; the guardrails schema is what keeps a "data" PR from becoming code in the
|
||||
# generated TypeScript (see validate.js).
|
||||
- name: Validate rules and guardrails against their schemas
|
||||
run: node rules/scripts/validate.js
|
||||
|
||||
- name: Combine rules into dist/
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Build and Deploy Backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
|
||||
|
||||
- name: Deploy to Azure Functions
|
||||
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1
|
||||
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1.5.7
|
||||
with:
|
||||
app-name: ${{ env.FUNCTION_APP }}
|
||||
package: ./publish
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
name: Build and Deploy MCP Server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
# re-run actually rolls a new revision). /health reports the SHA captured
|
||||
# below so that staleness is checkable.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json
|
||||
# records which docs commit is live.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -140,12 +140,12 @@ jobs:
|
||||
# commit, workflow file) in a public transparency log.
|
||||
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (portal export)
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the storage writes (secret-less)
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
# when someone wants to look at the file.
|
||||
- name: Upload signed scripts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: signed-scripts
|
||||
path: ${{ runner.temp }}/signed-scripts
|
||||
|
||||
@@ -31,6 +31,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.C
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.SummaryDialog.Tests", "src\Agent\AutopilotMonitor.SummaryDialog.Tests\AutopilotMonitor.SummaryDialog.Tests.csproj", "{4F1234C1-9387-4D2C-93E0-713698A2DFCB}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Push", "Push", "{09266D87-DDB8-2F61-A22F-10D1DD295F4F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Push", "src\Push\AutopilotMonitor.Push\AutopilotMonitor.Push.csproj", "{2C3E71E4-0049-45A5-839D-F4171B47D5F3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Push.Tests", "src\Push\AutopilotMonitor.Push.Tests\AutopilotMonitor.Push.Tests.csproj", "{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -161,6 +167,30 @@ Global
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -179,5 +209,8 @@ Global
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{09266D87-DDB8-2F61-A22F-10D1DD295F4F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3} = {09266D87-DDB8-2F61-A22F-10D1DD295F4F}
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302} = {09266D87-DDB8-2F61-A22F-10D1DD295F4F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
+1
-1
Submodule infra updated: be0165a223...f93e475b78
+1
-1
Submodule internal updated: 4b5afc55c1...54d7076308
@@ -5,7 +5,7 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -17,7 +17,7 @@
|
||||
"confidenceThreshold": 50,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "clock_ahead",
|
||||
"signal": "clock_behind",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
@@ -26,7 +26,7 @@
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "clock_behind",
|
||||
"signal": "clock_ahead",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-013",
|
||||
"title": "OOBE Quality Update Failed",
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "update_failure",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "in",
|
||||
"value": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_install_success",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "regex",
|
||||
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "os_build_unchanged",
|
||||
"source": "event_type",
|
||||
"eventType": "os_build_changed",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "page_names_complete",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "cxhEvent",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "names_capped",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "update_page_failed",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "result",
|
||||
"operator": "equals",
|
||||
"value": "fail",
|
||||
"filterField": "page",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "OobeNDUP",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "update_skipped",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "regex",
|
||||
"value": "SkipDownloadInstallButtonClicked$",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "update_page_failed",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "The Windows quality update page at the end of OOBE reported `{{update_failure}}`, and no successful install was recorded afterwards. When the download or install of the update fails, the page gives up and setup continues, so the enrollment itself can still succeed — but the device most likely finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. Selecting Skip on the page after the failure leads to the same result.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Find out why the update failed",
|
||||
"steps": [
|
||||
"Open the oobe_update_page events around the failure in the session timeline: the download progress before it shows how far the download got, and a Skip after it shows that someone chose to continue.",
|
||||
"Check the network path the device uses during OOBE for Windows Update and Delivery Optimization: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
|
||||
"Compare with other devices: the same failure on many devices of one site points at the network path, on one model at the device, and across the fleet at the update itself."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Make sure the device gets the update",
|
||||
"steps": [
|
||||
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule.",
|
||||
"Where the OOBE quality update keeps failing for one network or device group, decide per Enrollment Status Page profile whether \"Install Windows quality updates\" should stay on."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"quality-update"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-014",
|
||||
"title": "OOBE Quality Update Not Offered After an Earlier Failure",
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "previous_failures",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "gt",
|
||||
"value": "0",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "nothing_offered",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "days_out_of_date",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "gt",
|
||||
"value": "0",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_update_failure",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_install_success",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "regex",
|
||||
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "page_names_complete",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "cxhEvent",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "names_capped",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The Windows quality update page at the end of OOBE checked for updates and found none to install, although Windows reports the device as {{days_out_of_date}} days out of date. The page's failure counter for this device stands at {{previous_failures}}: an earlier download or install of an OOBE quality update on this device failed. The device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the earlier attempt",
|
||||
"steps": [
|
||||
"Look at the device's earlier enrollment sessions — the failed attempt and its ANALYZE-DEV-013 finding are usually there.",
|
||||
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Reduce how far behind devices start",
|
||||
"steps": [
|
||||
"Devices that start OOBE many months behind need a long update during setup; a current OS image or factory build shortens it and gives a failed attempt less to repeat."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"quality-update"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-015",
|
||||
"title": "OOBE Update Check Ended Without a Result",
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "update_allowed",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "scan_started",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "equals",
|
||||
"value": "ExpeditedUpdate_startWUScanStartedSuccedded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_scan_result",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "regex",
|
||||
"filterValue": "^ExpeditedUpdate_getUpdateResults",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "update_page_ended",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "equals",
|
||||
"value": "page_stopped",
|
||||
"filterField": "page",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "OobeNDUP",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "days_out_of_date",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "gt",
|
||||
"value": "0",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "page_names_complete",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "cxhEvent",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "names_capped",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The Windows quality update page at the end of OOBE started a Windows Update scan, but no scan result arrived before the page ended. The device is {{days_out_of_date}} days out of date, so an update was due — the device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. The page waits only a short time for the scan (two minutes in the observed sessions): a scan that takes longer, or cannot reach Windows Update, ends the page without an update.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the path to Windows Update during OOBE",
|
||||
"steps": [
|
||||
"Verify that devices in OOBE reach the Windows Update service: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
|
||||
"Check whether an update policy applied during the Enrollment Status Page points the scan at another source, such as WSUS."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Shorten the scan",
|
||||
"steps": [
|
||||
"Devices far behind scan longer; a current OS image or factory build keeps the OOBE scan short.",
|
||||
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"quality-update"
|
||||
]
|
||||
}
|
||||
+354
-3
@@ -1654,7 +1654,7 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -1666,7 +1666,7 @@
|
||||
"confidenceThreshold": 50,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "clock_ahead",
|
||||
"signal": "clock_behind",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
@@ -1675,7 +1675,7 @@
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "clock_behind",
|
||||
"signal": "clock_ahead",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
@@ -1930,6 +1930,357 @@
|
||||
"environment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-DEV-013",
|
||||
"title": "OOBE Quality Update Failed",
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "update_failure",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "in",
|
||||
"value": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_install_success",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "regex",
|
||||
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "os_build_unchanged",
|
||||
"source": "event_type",
|
||||
"eventType": "os_build_changed",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "page_names_complete",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "cxhEvent",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "names_capped",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "update_page_failed",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "result",
|
||||
"operator": "equals",
|
||||
"value": "fail",
|
||||
"filterField": "page",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "OobeNDUP",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "update_skipped",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "regex",
|
||||
"value": "SkipDownloadInstallButtonClicked$",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "update_page_failed",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "The Windows quality update page at the end of OOBE reported `{{update_failure}}`, and no successful install was recorded afterwards. When the download or install of the update fails, the page gives up and setup continues, so the enrollment itself can still succeed — but the device most likely finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. Selecting Skip on the page after the failure leads to the same result.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Find out why the update failed",
|
||||
"steps": [
|
||||
"Open the oobe_update_page events around the failure in the session timeline: the download progress before it shows how far the download got, and a Skip after it shows that someone chose to continue.",
|
||||
"Check the network path the device uses during OOBE for Windows Update and Delivery Optimization: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
|
||||
"Compare with other devices: the same failure on many devices of one site points at the network path, on one model at the device, and across the fleet at the update itself."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Make sure the device gets the update",
|
||||
"steps": [
|
||||
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule.",
|
||||
"Where the OOBE quality update keeps failing for one network or device group, decide per Enrollment Status Page profile whether \"Install Windows quality updates\" should stay on."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"quality-update"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-DEV-014",
|
||||
"title": "OOBE Quality Update Not Offered After an Earlier Failure",
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "previous_failures",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "gt",
|
||||
"value": "0",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "nothing_offered",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "days_out_of_date",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "gt",
|
||||
"value": "0",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_update_failure",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_install_success",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "regex",
|
||||
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "page_names_complete",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "cxhEvent",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "names_capped",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The Windows quality update page at the end of OOBE checked for updates and found none to install, although Windows reports the device as {{days_out_of_date}} days out of date. The page's failure counter for this device stands at {{previous_failures}}: an earlier download or install of an OOBE quality update on this device failed. The device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the earlier attempt",
|
||||
"steps": [
|
||||
"Look at the device's earlier enrollment sessions — the failed attempt and its ANALYZE-DEV-013 finding are usually there.",
|
||||
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Reduce how far behind devices start",
|
||||
"steps": [
|
||||
"Devices that start OOBE many months behind need a long update during setup; a current OS image or factory build shortens it and gives a failed attempt less to repeat."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"quality-update"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-DEV-015",
|
||||
"title": "OOBE Update Check Ended Without a Result",
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "update_allowed",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "scan_started",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "equals",
|
||||
"value": "ExpeditedUpdate_startWUScanStartedSuccedded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_scan_result",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "regex",
|
||||
"filterValue": "^ExpeditedUpdate_getUpdateResults",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "update_page_ended",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "equals",
|
||||
"value": "page_stopped",
|
||||
"filterField": "page",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "OobeNDUP",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "days_out_of_date",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "gt",
|
||||
"value": "0",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "page_names_complete",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "cxhEvent",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "names_capped",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The Windows quality update page at the end of OOBE started a Windows Update scan, but no scan result arrived before the page ended. The device is {{days_out_of_date}} days out of date, so an update was due — the device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. The page waits only a short time for the scan (two minutes in the observed sessions): a scan that takes longer, or cannot reach Windows Update, ends the page without an update.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the path to Windows Update during OOBE",
|
||||
"steps": [
|
||||
"Verify that devices in OOBE reach the Windows Update service: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
|
||||
"Check whether an update policy applied during the Enrollment Status Page points the scan at another source, such as WSUS."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Shorten the scan",
|
||||
"steps": [
|
||||
"Devices far behind scan longer; a current OS image or factory build keeps the OOBE scan short.",
|
||||
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"quality-update"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-ENRL-001",
|
||||
"title": "Enrollment Failed",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# ajv-cli declares ts-node as a peer dependency it never needs for JSON schemas; do not
|
||||
# pull ts-node + typescript into a job that pushes to main.
|
||||
legacy-peer-deps=true
|
||||
Generated
+1
-226
@@ -6,7 +6,7 @@
|
||||
"": {
|
||||
"name": "autopilot-monitor-rules-tooling",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
}
|
||||
},
|
||||
@@ -27,33 +27,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-cli": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-cli/-/ajv-cli-5.0.0.tgz",
|
||||
"integrity": "sha512-LY4m6dUv44HTyhV+u2z5uX4EhPYTM38Iv1jdgDJJJCyOOuqB8KtZEGjPZ2T+sh5ZIJrXUfgErYx/j3gLd3+PlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0",
|
||||
"fast-json-patch": "^2.0.0",
|
||||
"glob": "^7.1.0",
|
||||
"js-yaml": "^3.14.0",
|
||||
"json-schema-migrate": "^2.0.0",
|
||||
"json5": "^2.1.3",
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"ajv": "dist/index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ts-node": ">=9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ts-node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
@@ -72,55 +45,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -128,13 +52,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-patch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz",
|
||||
"integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
@@ -152,78 +69,6 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.15.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
|
||||
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-migrate": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz",
|
||||
"integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -231,62 +76,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -296,20 +85,6 @@
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"private": true,
|
||||
"description": "Validator tooling for rules/ - combine-rules.yml installs from the lockfile so nothing unpinned reaches a job that pushes to main.",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-json-patch": "3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validates every rule file and rules/guardrails.json against its JSON Schema
|
||||
* (draft 2020-12). combine-rules.yml runs this before combine.js; any invalid
|
||||
* file fails the run with exit code 1.
|
||||
*
|
||||
* Run: node rules/scripts/validate.js (after `npm ci --ignore-scripts` in rules/scripts)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv2020 = require('ajv/dist/2020');
|
||||
const addFormats = require('ajv-formats');
|
||||
|
||||
const rulesRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const jsonFilesIn = (dir) =>
|
||||
fs.readdirSync(path.join(rulesRoot, dir)).filter(f => f.endsWith('.json')).sort().map(f => `${dir}/${f}`);
|
||||
|
||||
const sets = [
|
||||
{ schema: 'gather-rule.schema.json', files: jsonFilesIn('gather') },
|
||||
{ schema: 'analyze-rule.schema.json', files: jsonFilesIn('analyze') },
|
||||
{ schema: 'ime-log-pattern.schema.json', files: jsonFilesIn('ime-log-patterns') },
|
||||
// guardrails.json values are baked verbatim into generated TypeScript that is
|
||||
// auto-committed to main and shipped to the portal - the schema forbids control
|
||||
// and line-terminator characters so a "data" PR can never become code.
|
||||
{ schema: 'guardrails.schema.json', files: ['guardrails.json'] }
|
||||
];
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true });
|
||||
addFormats(ajv);
|
||||
|
||||
let invalid = 0;
|
||||
|
||||
for (const set of sets) {
|
||||
const validate = ajv.compile(JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', set.schema), 'utf8')));
|
||||
|
||||
for (const file of set.files) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(path.join(rulesRoot, file), 'utf8'));
|
||||
} catch (err) {
|
||||
invalid++;
|
||||
console.error(`INVALID rules/${file}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validate(data)) {
|
||||
invalid++;
|
||||
console.error(`INVALID rules/${file}`);
|
||||
for (const e of validate.errors) {
|
||||
console.error(` ${e.instancePath || '(root)'} ${e.message} ${JSON.stringify(e.params)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${set.schema}: ${set.files.length} files checked`);
|
||||
}
|
||||
|
||||
if (invalid > 0) {
|
||||
console.error(`ERROR: ${invalid} file(s) failed schema validation`);
|
||||
process.exit(1);
|
||||
}
|
||||
+1
-1
@@ -144,7 +144,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(4, svc.LastFetchAttempts);
|
||||
}
|
||||
|
||||
// ── Network-link gate (tenant aebdce78 audit 2026-08-24) ────────────
|
||||
// ── Network-link gate (kiosk-tenant audit 2026-08-24) ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_InitialFetch_WaitsForNetworkLink()
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Uninstall-key strings are read the way Windows shows them: up to the first NUL. Installers
|
||||
/// that pad REG_SZ data with NULs otherwise leak them into the normalized vendor/name, which
|
||||
/// the backend turns into a table key.
|
||||
/// </summary>
|
||||
public sealed class SoftwareInventoryRegistryTextTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Contoso Widget", "Contoso Widget")]
|
||||
[InlineData("Contoso Widget\0\0\0\0\0", "Contoso Widget")]
|
||||
[InlineData("Contoso\0stale buffer", "Contoso")]
|
||||
[InlineData("\0\0\0", "")]
|
||||
[InlineData("", "")]
|
||||
public void CutAtNul_KeepsTheTextBeforeTheFirstNul(string raw, string expected)
|
||||
{
|
||||
Assert.Equal(expected, SoftwareInventoryAnalyzer.CutAtNul(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutAtNul_Null_StaysNull()
|
||||
{
|
||||
Assert.Null(SoftwareInventoryAnalyzer.CutAtNul(null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -174,7 +174,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
[Fact]
|
||||
public void Reevaluate_does_not_re_emit_starved_warnings()
|
||||
{
|
||||
// sits-d Cloud-PC fix (2026-08-19): the synthesis re-check now runs on EVERY IME
|
||||
// Cloud-PC fix (2026-08-19): the synthesis re-check now runs on EVERY IME
|
||||
// app-state transition. It must not turn the one-shot starvation warning into a
|
||||
// per-transition stream — the re-check path suppresses the emission entirely
|
||||
// (the per-appId dedupe would swallow the duplicates, but walking the probe
|
||||
@@ -198,7 +198,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
[Fact]
|
||||
public void Starved_apps_that_settle_later_still_complete_the_enrollment()
|
||||
{
|
||||
// The real sits-d shape: the ESP exits with apps still starved (warning emitted),
|
||||
// The real field shape: the ESP exits with apps still starved (warning emitted),
|
||||
// the apps then all settle, and the re-check must open the gate — the warning
|
||||
// already on the timeline is history, not a permanent veto.
|
||||
using var f = new Fixture();
|
||||
|
||||
+2
-2
@@ -172,8 +172,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// sits-d Cloud-PC fix (2026-08-19) — the synthesis is no longer edge-only.
|
||||
// Sessions 8110e262 / a89aac2d: the ESP page exited while 138 required user-ESP apps
|
||||
// Cloud-PC fix (2026-08-19) — the synthesis is no longer edge-only.
|
||||
// Two field sessions: the ESP page exited while 138 required user-ESP apps
|
||||
// were still in flight, they all reached a terminal state (0 failed) minutes later,
|
||||
// and because nothing re-checked, the enrollment never completed.
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
+4
-3
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
@@ -173,16 +174,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
public void UpdatePageRecords_ArePageStartsStopsAndNames_OfTheUpdatePageOnly()
|
||||
{
|
||||
var start = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStarted, start!.CxhEvent);
|
||||
Assert.Equal(OobeUpdateVocabulary.PageStarted, start!.CxhEvent);
|
||||
Assert.Equal("OobeNDUP", start.Page);
|
||||
|
||||
var stop = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "RebootNDUP");
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStopped, stop!.CxhEvent);
|
||||
Assert.Equal(OobeUpdateVocabulary.PageStopped, stop!.CxhEvent);
|
||||
Assert.Equal("RebootNDUP", stop.Page);
|
||||
Assert.Equal("success", stop.Result);
|
||||
|
||||
var name = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.EventName, name!.CxhEvent);
|
||||
Assert.Equal(OobeUpdateVocabulary.EventName, name!.CxhEvent);
|
||||
Assert.Equal("ExpeditedUpdate_startWUScanStarted", name.Name);
|
||||
Assert.Null(name.Value);
|
||||
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// The first run's update page backfill (<see cref="ShellCoreTracker.BackfillUpdatePageTelemetry"/>)
|
||||
/// and the page a stop belongs to across backfill and live records. The first run has no
|
||||
/// replay — its backfill is telemetry and nothing else, so a Hello-wizard start, an ESP exit
|
||||
/// or an ESP failure in the window must not leave the tracker on any rail.
|
||||
/// </summary>
|
||||
public sealed class ShellCoreTrackerUpdatePageBackfillTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 5, 13, 10, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string NdupStarted = "CloudExperienceHost Web App Activity started. CXID: 'OobeNDUP'.";
|
||||
private const string RebootNdupStarted = "CloudExperienceHost Web App Activity started. CXID: 'RebootNDUP'.";
|
||||
private const string AadHelloStarted = "CloudExperienceHost Web App Activity started. CXID: 'AADHello'.";
|
||||
private const string PageStopped = "CloudExperienceHost Web App Activity stopped. Result: 'success'.";
|
||||
private const string DownloadSucceeded =
|
||||
"CloudExperienceHost Web App Event 1. Name: 'SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded'.";
|
||||
private const string EspExit = "CommercialOOBE_ESPProgress_Page_Exiting";
|
||||
private const string EspFailure = "CommercialOOBE_ESPProgress_Failure";
|
||||
private const string WhiteGloveSuccess = "CommercialOOBE_ESPProgress_WhiteGlove_Success";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private int _registryReads;
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ShellCoreTracker Build() =>
|
||||
new ShellCoreTracker("S1", "T1",
|
||||
new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
helloTracker: null,
|
||||
oobeUpdateRegistryReader: () => { _registryReads++; return Array.Empty<OobeUpdateRegistrySnapshot.KeyState>(); },
|
||||
stateDirectory: Path.Combine(_tmp.Path, "state"));
|
||||
|
||||
private static void Live(ShellCoreTracker tracker, int eventId, string description, DateTime at, long recordId) =>
|
||||
tracker.ProcessEvent(eventId, description, at, "Microsoft-Windows-Shell-Core", isBackfill: false, recordId: recordId);
|
||||
|
||||
private static ShellCoreRecord Record(int eventId, string description, DateTime at, long recordId) =>
|
||||
new ShellCoreRecord(eventId, description, at, recordId);
|
||||
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, object>> Pages() =>
|
||||
_sink.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == Constants.EventTypes.OobeUpdatePage)
|
||||
.Select(p => (IReadOnlyDictionary<string, object>)p.TypedPayload!)
|
||||
.ToList();
|
||||
|
||||
// ------------------------------------------------------------ first run ----
|
||||
|
||||
[Fact]
|
||||
public void FirstRunBackfill_ReportsTheUpdatePage_AndNothingElse()
|
||||
{
|
||||
using var tracker = Build();
|
||||
var raised = new List<string>();
|
||||
tracker.HelloWizardStarted += (_, _) => raised.Add("hello");
|
||||
tracker.FinalizingSetupPhaseTriggered += (_, reason) => raised.Add($"finalizing:{reason}");
|
||||
tracker.EspExited += (_, _) => raised.Add("exit");
|
||||
tracker.EspFailureDetected += (_, _) => raised.Add("failure");
|
||||
tracker.WhiteGloveCompleted += (_, _) => raised.Add("whiteglove");
|
||||
|
||||
tracker.ReplayUpdatePageTelemetry(new[]
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspExit, At.AddMinutes(-41), 95),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspFailure, At.AddSeconds(-2430), 96),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, WhiteGloveSuccess, At.AddSeconds(-2420), 97),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-40), 100),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEventName, DownloadSucceeded, At.AddMinutes(-30), 101),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, AadHelloStarted, At.AddMinutes(-2), 110),
|
||||
});
|
||||
|
||||
Assert.Empty(raised);
|
||||
Assert.False(tracker.IsEspExitedForTest);
|
||||
Assert.False(tracker.IsWhiteGloveDetectedForTest);
|
||||
var pages = Pages();
|
||||
Assert.Equal(new object[] { "page_started", "event_name" }, pages.Select(d => d["cxhEvent"]).ToArray());
|
||||
Assert.All(pages, d => Assert.Equal(true, d["backfill"]));
|
||||
// No agent_trace, no ESP or Hello event — only the two page rows.
|
||||
Assert.Equal(2, _sink.Posted.Count);
|
||||
// The registry shows the state now, not at the records' time.
|
||||
Assert.Equal(0, _registryReads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstRunBackfill_KeepsTheEventTimes()
|
||||
{
|
||||
using var tracker = Build();
|
||||
|
||||
tracker.ReplayUpdatePageTelemetry(new[]
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-40), 100),
|
||||
});
|
||||
|
||||
var posted = Assert.Single(_sink.Posted);
|
||||
Assert.Equal(At.AddMinutes(-40), posted.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveStop_FindsThePageThatStartedBeforeTheAgent()
|
||||
{
|
||||
using var tracker = Build();
|
||||
tracker.ReplayUpdatePageTelemetry(new[]
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-40), 100),
|
||||
});
|
||||
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(5), 120);
|
||||
|
||||
var stop = Pages().Last();
|
||||
Assert.Equal("page_stopped", stop["cxhEvent"]);
|
||||
Assert.Equal("OobeNDUP", stop["page"]);
|
||||
Assert.Equal(false, stop["backfill"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecord_FromTheLiveWatcherAndTheFirstRunBackfill_IsReportedOnce()
|
||||
{
|
||||
// The live watcher is armed before the backfill reads, so both can deliver a record.
|
||||
using var tracker = Build();
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, 700);
|
||||
tracker.ReplayUpdatePageTelemetry(new[]
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, 700),
|
||||
});
|
||||
|
||||
Assert.Single(Pages(), d => (string)d["cxhEvent"] == "page_started");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- the page of a stop ----
|
||||
|
||||
[Fact]
|
||||
public void ReplayedOlderStart_DoesNotTakeOverTheNextLiveStop()
|
||||
{
|
||||
// A restart replay reads records older than the live watcher's; before, its page start
|
||||
// overwrote the live page and the next live stop was attributed to the old page.
|
||||
using var tracker = Build();
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, RebootNdupStarted, At, 900);
|
||||
tracker.ReplayBackfillRecords(new[]
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-20), 850),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEventName, DownloadSucceeded, At.AddMinutes(-19), 851),
|
||||
});
|
||||
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(1), 901);
|
||||
|
||||
var stop = Pages().Last();
|
||||
Assert.Equal("page_stopped", stop["cxhEvent"]);
|
||||
Assert.Equal("RebootNDUP", stop["page"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledStop_BelongsToTheStartOfItsOwnBatch()
|
||||
{
|
||||
using var tracker = Build();
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, RebootNdupStarted, At, 900);
|
||||
|
||||
tracker.ReplayUpdatePageTelemetry(new[]
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At.AddMinutes(-20), 850),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(-18), 852),
|
||||
});
|
||||
|
||||
var stop = Pages().Last();
|
||||
Assert.Equal("page_stopped", stop["cxhEvent"]);
|
||||
Assert.Equal("OobeNDUP", stop["page"]);
|
||||
Assert.Equal(true, stop["backfill"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledStopWithoutItsStart_IsNotGivenTheLivePage()
|
||||
{
|
||||
using var tracker = Build();
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, 900);
|
||||
|
||||
tracker.ReplayUpdatePageTelemetry(new[]
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(-18), 852),
|
||||
});
|
||||
|
||||
Assert.DoesNotContain(Pages(), d => (string)d["cxhEvent"] == "page_stopped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewerBackfilledStart_MovesTheLivePageForward()
|
||||
{
|
||||
// Records written between arming the watcher and the backfill's read are newer than
|
||||
// the last live start the watcher delivered before them.
|
||||
using var tracker = Build();
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, At, 900);
|
||||
tracker.ReplayUpdatePageTelemetry(new[]
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, RebootNdupStarted, At.AddSeconds(30), 905),
|
||||
});
|
||||
|
||||
Live(tracker, ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, At.AddMinutes(1), 906);
|
||||
|
||||
Assert.Equal("RebootNDUP", Pages().Last()["page"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePageLookback_IsInsideTheReplayClamp()
|
||||
{
|
||||
Assert.Equal(ShellCoreTracker.UpdatePageBackfillLookbackMinutes,
|
||||
ShellCoreTracker.ClampLookbackMinutes(ShellCoreTracker.UpdatePageBackfillLookbackMinutes));
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-14
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
@@ -56,7 +57,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void Page_IsReported_WithItsEventTime_AndFields()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"),
|
||||
new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStopped, 62405, page: "OobeNDUP", result: "success"),
|
||||
At.AddMinutes(-3), isBackfill: true);
|
||||
|
||||
var page = Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage));
|
||||
@@ -75,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void EventName_CarriesItsSafeValue()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62407, name: "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", value: "true"),
|
||||
new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62407, name: "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", value: "true"),
|
||||
At, isBackfill: false);
|
||||
|
||||
var data = Data(Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage)));
|
||||
@@ -89,9 +90,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_progress"), At.AddSeconds(i), isBackfill: false);
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: "ExpeditedUpdate_progress"), At.AddSeconds(i), isBackfill: false);
|
||||
}
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"), At.AddMinutes(1), isBackfill: false);
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStopped, 62405, page: "OobeNDUP", result: "success"), At.AddMinutes(1), isBackfill: false);
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey + 1, pages.Count);
|
||||
@@ -101,15 +102,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageEvents_AreCappedPerRun()
|
||||
public void PageEvents_AreCappedPerRun_AndTheCapIsMarkedOnce()
|
||||
{
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun + 20; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false);
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At.AddSeconds(i), isBackfill: false);
|
||||
}
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun, ByType(Constants.EventTypes.OobeUpdatePage).Count);
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun + 1, pages.Count);
|
||||
// A name missing after the marker proves nothing — the rules check for it.
|
||||
var marker = Data(pages.Last());
|
||||
Assert.Equal(OobeUpdateVocabulary.NamesCapped, marker["cxhEvent"]);
|
||||
Assert.Equal(false, marker["backfill"]);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun, marker["limit"]);
|
||||
Assert.Equal(At.AddSeconds(OobeUpdateTelemetry.MaxPageEventsPerRun), pages.Last().OccurredAtUtc); // the first name over the budget
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledNames_HaveTheirOwnBudget()
|
||||
{
|
||||
// The records from before the agent started cannot spend the live page's budget.
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxBackfillPageEventsPerRun + 5; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: $"ExpeditedUpdate_old{i}"), At.AddMinutes(-30), isBackfill: true);
|
||||
}
|
||||
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: "ExpeditedUpdate_live"), At, isBackfill: false));
|
||||
|
||||
var markers = ByType(Constants.EventTypes.OobeUpdatePage)
|
||||
.Where(p => (string)Data(p)["cxhEvent"] == OobeUpdateVocabulary.NamesCapped).ToList();
|
||||
var marker = Assert.Single(markers);
|
||||
Assert.Equal(true, Data(marker)["backfill"]);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxBackfillPageEventsPerRun, Data(marker)["limit"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -119,16 +146,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun; i++)
|
||||
{
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false));
|
||||
}
|
||||
|
||||
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_late"), At, isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "RebootNDUP"), At.AddMinutes(1), isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "RebootNDUP", result: "success"), At.AddMinutes(2), isBackfill: false));
|
||||
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: "ExpeditedUpdate_late"), At, isBackfill: false));
|
||||
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: "ExpeditedUpdate_later"), At, isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStarted, 62404, page: "RebootNDUP"), At.AddMinutes(1), isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStopped, 62405, page: "RebootNDUP", result: "success"), At.AddMinutes(2), isBackfill: false));
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun + 2, pages.Count);
|
||||
Assert.Equal(new object[] { "page_started", "page_stopped" },
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun + 3, pages.Count);
|
||||
Assert.Equal(new object[] { OobeUpdateVocabulary.NamesCapped, "page_started", "page_stopped" },
|
||||
pages.Skip(OobeUpdateTelemetry.MaxPageEventsPerRun).Select(p => Data(p)["cxhEvent"]).ToArray());
|
||||
}
|
||||
|
||||
@@ -138,7 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// Outside the run budget, but a page that restarts in a loop still cannot flood.
|
||||
var telemetry = Build();
|
||||
var reported = Enumerable.Range(0, 10)
|
||||
.Count(i => telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "OobeNDUP"), At.AddMinutes(i), isBackfill: false));
|
||||
.Count(i => telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStarted, 62404, page: "OobeNDUP"), At.AddMinutes(i), isBackfill: false));
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey, reported);
|
||||
}
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
[Fact]
|
||||
public void Ime_app_state_callback_is_chained_into_the_user_apps_settled_recheck()
|
||||
{
|
||||
// sits-d Cloud-PC fix (2026-08-19): the AccountSetup synthesis is re-evaluated on every
|
||||
// Cloud-PC fix (2026-08-19): the AccountSetup synthesis is re-evaluated on every
|
||||
// terminal IME app transition, which only works while the factory keeps this chain
|
||||
// wired. It is five lines that a refactor can drop silently and whose absence costs a
|
||||
// whole class of sessions their completion — so pin it here.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Contract tests for <see cref="AgentBootstrap.RetrySelfUpdateAfterEnrollment"/> —
|
||||
/// the post-cert one-shot self-update retry (field case sits-d.cloud, 2026-08-09):
|
||||
/// the post-cert one-shot self-update retry (field case 2026-08-09):
|
||||
/// guard on <see cref="SelfUpdater.LastVersionCheckSucceeded"/>, the relaxed
|
||||
/// timeouts/trigger it passes to the updater, and the swallow-everything exception
|
||||
/// contract (bootstrap must continue on any updater failure). Uses the
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal("FromCache", data["outcome"]);
|
||||
}
|
||||
|
||||
// ── Post-registration recovery (Phase 6.5, tenant aebdce78 audit 2026-08-24) ─
|
||||
// ── Post-registration recovery (Phase 6.5, kiosk-tenant audit 2026-08-24) ─
|
||||
|
||||
[Fact]
|
||||
public void EmitFetchFailedIfAny_AfterRecovery_MarksRecoveredAndDemotesToInfo()
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
[Fact]
|
||||
public void Succeeded_outcome_is_an_info_about_tail_only_coverage_without_alarm_framing()
|
||||
{
|
||||
// The 659c3a90 shape: healthy enrollment, bootstrap merely ran late in the ESP queue.
|
||||
// The field shape: healthy enrollment, bootstrap merely ran late in the ESP queue.
|
||||
Describe(EnrollmentTerminationOutcome.Succeeded, "in_progress",
|
||||
out var severity, out _, out var note);
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
/// 401/403 classification in <see cref="BackendApiClient"/>: a backend JSON 403 is a real
|
||||
/// device-authorization verdict, while an HTML body means the response came from the Azure
|
||||
/// platform / an edge proxy (stopped or retired app) and must NOT be reported as
|
||||
/// "device is not authorized". Field case sits-d.cloud 2026-08-09: a pre-cutover agent hit
|
||||
/// "device is not authorized". Field case 2026-08-09: a pre-cutover agent hit
|
||||
/// the stopped legacy Function App and support chased a non-existent authorization problem.
|
||||
/// </summary>
|
||||
public sealed class BackendApiClientAuthClassificationTests
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// Upper bound for the pre-fetch network-link wait on the initial startup fetch.
|
||||
/// Deliberately longer than the registration gate's 15s: the fetch runs FIRST after a
|
||||
/// boot-time relaunch, when Wi-Fi may still be associating (802.1X, OOBE), and a fetch
|
||||
/// burned into a dead link strands the whole session on built-in defaults — tenant aebdce78
|
||||
/// burned into a dead link strands the whole session on built-in defaults — kiosk-tenant
|
||||
/// audit 2026-08-24: ~50% of sessions lost their tenant config exactly this way.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan InitialFetchNetworkLinkWaitMax = TimeSpan.FromSeconds(60);
|
||||
|
||||
+10
-3
@@ -50,12 +50,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
// Session caa6cf50 gate-starvation fix (2026-06-11) — fire-once guard for the
|
||||
// user-ESP-apps-settled AccountSetup synthesis (see MaybeSynthesizeAccountSetupComplete).
|
||||
// Interlocked rather than a plain bool: since the sits-d Cloud-PC fix the synthesis is
|
||||
// Interlocked rather than a plain bool: since the Cloud-PC fix the synthesis is
|
||||
// ALSO re-evaluated from the IME app-state-change thread, so two threads can reach the
|
||||
// guard concurrently (Shell-Core watcher thread + IME log thread).
|
||||
private int _userAppsSettledSynthesisFired;
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19) — set once a CONFIRMED post-AccountSetup Shell-Core
|
||||
// Cloud-PC fix (2026-08-19) — set once a CONFIRMED post-AccountSetup Shell-Core
|
||||
// exit has been observed (live OR backfill). The synthesis needs two facts: the ESP exit
|
||||
// is an EDGE that happens once, the settled user-ESP apps are a LEVEL that may only be
|
||||
// reached minutes later. Recording the edge lets ReevaluateUserAppsSettledSynthesis
|
||||
@@ -367,7 +367,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
=> _shellCoreTracker?.BackfillRecentHelloWizardStart(lookbackMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// sits-d Cloud-PC fix (2026-08-19) — re-check the user-apps-settled AccountSetup
|
||||
/// First run only: the update page's records from before the agent started, as telemetry
|
||||
/// and nothing else — see <see cref="ShellCoreTracker.BackfillUpdatePageTelemetry"/>.
|
||||
/// </summary>
|
||||
public void BackfillUpdatePageTelemetry(int lookbackMinutes)
|
||||
=> _shellCoreTracker?.BackfillUpdatePageTelemetry(lookbackMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// Cloud-PC fix (2026-08-19) — re-check the user-apps-settled AccountSetup
|
||||
/// synthesis after the ESP exit has already been observed. Wired to the IME tracker's
|
||||
/// app-state-change callback in <c>DefaultComponentFactory</c>.
|
||||
/// <para>
|
||||
|
||||
+78
-28
@@ -14,10 +14,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
internal sealed class OobeUpdatePageRecord
|
||||
{
|
||||
public const string PageStarted = "page_started";
|
||||
public const string PageStopped = "page_stopped";
|
||||
public const string EventName = "event_name";
|
||||
|
||||
public OobeUpdatePageRecord(string cxhEvent, int windowsEventId, string page = null, string result = null, string name = null, string value = null)
|
||||
{
|
||||
CxhEvent = cxhEvent;
|
||||
@@ -28,7 +24,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary><see cref="PageStarted"/>, <see cref="PageStopped"/> or <see cref="EventName"/>.</summary>
|
||||
/// <summary>
|
||||
/// <see cref="OobeUpdateVocabulary.PageStarted"/>, <see cref="OobeUpdateVocabulary.PageStopped"/> or
|
||||
/// <see cref="OobeUpdateVocabulary.EventName"/> (the shared vocabulary the backend and the portal read).
|
||||
/// </summary>
|
||||
public string CxhEvent { get; }
|
||||
public int WindowsEventId { get; }
|
||||
|
||||
@@ -54,6 +53,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal sealed class OobeUpdateTelemetry
|
||||
{
|
||||
internal const int MaxPageEventsPerRun = 150;
|
||||
|
||||
/// <summary>
|
||||
/// Event names the backfills (first run, restart) report per run — their own budget, so
|
||||
/// the records from before the agent started cannot spend the live page's.
|
||||
/// </summary>
|
||||
internal const int MaxBackfillPageEventsPerRun = 150;
|
||||
|
||||
internal const int MaxPageEventsPerKey = 3;
|
||||
internal const int MaxStateEventsPerRun = 6;
|
||||
internal const int MaxStateLineLength = 1000;
|
||||
@@ -67,7 +73,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public const string AgentStop = "agent_stop";
|
||||
}
|
||||
|
||||
private const string Source = "ShellCoreTracker";
|
||||
private const string Source = Constants.EventSources.ShellCoreTracker;
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
@@ -77,8 +83,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private readonly Dictionary<string, int> _pageEventsPerKey = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private int _pageEvents;
|
||||
private bool _pageCapLogged;
|
||||
private int _livePageEvents;
|
||||
private int _backfillPageEvents;
|
||||
private bool _liveCapReported;
|
||||
private bool _backfillCapReported;
|
||||
private int _stateEvents;
|
||||
private string _lastStateContent;
|
||||
|
||||
@@ -99,33 +107,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <summary>
|
||||
/// One <c>oobe_update_page</c> per update-page record, at most
|
||||
/// <see cref="MaxPageEventsPerKey"/> per page/name — a page that logs the same name in a
|
||||
/// loop cannot flood the session. Event names also share <see cref="MaxPageEventsPerRun"/>
|
||||
/// per run; a page start or stop is outside that budget, because the time attribution
|
||||
/// reads the update's span from them. True when the record was reported.
|
||||
/// loop cannot flood the session. Event names also share a budget per run: live ones
|
||||
/// <see cref="MaxPageEventsPerRun"/>, backfilled ones <see cref="MaxBackfillPageEventsPerRun"/>;
|
||||
/// the first name over a budget sends the <see cref="OobeUpdateVocabulary.NamesCapped"/>
|
||||
/// marker. A page start or stop is outside the budgets, because the time attribution reads
|
||||
/// the update's span from them. True when the record was reported.
|
||||
/// </summary>
|
||||
public bool ReportPage(OobeUpdatePageRecord record, DateTime occurredAtUtc, bool isBackfill)
|
||||
{
|
||||
if (record == null) return false;
|
||||
|
||||
var isEventName = record.CxhEvent == OobeUpdatePageRecord.EventName;
|
||||
var isEventName = record.CxhEvent == OobeUpdateVocabulary.EventName;
|
||||
var key = $"{record.CxhEvent}:{record.Page ?? record.Name}";
|
||||
int occurrence;
|
||||
var budget = isBackfill ? MaxBackfillPageEventsPerRun : MaxPageEventsPerRun;
|
||||
var occurrence = 0;
|
||||
bool overBudget;
|
||||
var firstOverBudget = false;
|
||||
lock (_lock)
|
||||
{
|
||||
if (isEventName && _pageEvents >= MaxPageEventsPerRun)
|
||||
overBudget = isEventName && (isBackfill ? _backfillPageEvents : _livePageEvents) >= budget;
|
||||
if (overBudget)
|
||||
{
|
||||
if (!_pageCapLogged)
|
||||
{
|
||||
_pageCapLogged = true;
|
||||
_logger.Info($"OOBE update page telemetry: {MaxPageEventsPerRun} event names reached, the rest of this run stays in agent.log");
|
||||
}
|
||||
return false;
|
||||
firstOverBudget = isBackfill ? !_backfillCapReported : !_liveCapReported;
|
||||
if (isBackfill) _backfillCapReported = true;
|
||||
else _liveCapReported = true;
|
||||
}
|
||||
_pageEventsPerKey.TryGetValue(key, out occurrence);
|
||||
if (occurrence >= MaxPageEventsPerKey) return false;
|
||||
occurrence++;
|
||||
_pageEventsPerKey[key] = occurrence;
|
||||
if (isEventName) _pageEvents++;
|
||||
else
|
||||
{
|
||||
_pageEventsPerKey.TryGetValue(key, out occurrence);
|
||||
if (occurrence >= MaxPageEventsPerKey) return false;
|
||||
occurrence++;
|
||||
_pageEventsPerKey[key] = occurrence;
|
||||
if (isEventName)
|
||||
{
|
||||
if (isBackfill) _backfillPageEvents++;
|
||||
else _livePageEvents++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (overBudget)
|
||||
{
|
||||
if (firstOverBudget) ReportNamesCapped(budget, occurredAtUtc, isBackfill);
|
||||
return false;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
@@ -141,6 +165,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (record.Name != null) data["name"] = record.Name;
|
||||
if (record.Value != null) data["value"] = record.Value;
|
||||
|
||||
return EmitPage(occurredAtUtc, DescribePage(record), data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The marker that a budget is spent: from here on this run's live or backfilled event
|
||||
/// names stay in agent.log, so a name missing from the session proves nothing — the
|
||||
/// analyze rules that read an absent name check for this marker first.
|
||||
/// </summary>
|
||||
private void ReportNamesCapped(int budget, DateTime occurredAtUtc, bool isBackfill)
|
||||
{
|
||||
var kind = isBackfill ? "backfilled " : string.Empty;
|
||||
_logger.Info($"OOBE update page telemetry: {budget} {kind}event names reached, the rest of this run stays in agent.log");
|
||||
|
||||
EmitPage(occurredAtUtc,
|
||||
$"OOBE update page: {budget} {kind}event names reached, later ones of this agent run are not sent",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "cxhEvent", OobeUpdateVocabulary.NamesCapped },
|
||||
{ "eventTime", occurredAtUtc.ToString("o") },
|
||||
{ "backfill", isBackfill },
|
||||
{ "limit", budget },
|
||||
});
|
||||
}
|
||||
|
||||
private bool EmitPage(DateTime occurredAtUtc, string message, Dictionary<string, object> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
_post.Emit(new EnrollmentEvent
|
||||
@@ -152,7 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = Source,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = DescribePage(record),
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = false,
|
||||
});
|
||||
@@ -236,9 +286,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
switch (record.CxhEvent)
|
||||
{
|
||||
case OobeUpdatePageRecord.PageStarted:
|
||||
case OobeUpdateVocabulary.PageStarted:
|
||||
return $"OOBE update page started: {record.Page}";
|
||||
case OobeUpdatePageRecord.PageStopped:
|
||||
case OobeUpdateVocabulary.PageStopped:
|
||||
return $"OOBE update page stopped: {record.Page} (result: {record.Result})";
|
||||
default:
|
||||
return record.Value == null
|
||||
|
||||
+6
-6
@@ -224,27 +224,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InitiatingChanges:
|
||||
step = "initiating";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.InstallStarted
|
||||
step = OobeUpdateVocabulary.StepInitiating;
|
||||
osStep = IsState(targetState, OobeUpdateVocabulary.InstalledState) ? OsUpdateSteps.InstallStarted
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.StagingStarted
|
||||
: null;
|
||||
message = $"Servicing started for {pkg}: {(string.IsNullOrEmpty(initialState) ? "?" : initialState)} → {target}";
|
||||
break;
|
||||
case EventId_StateReached:
|
||||
step = "state_reached";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.Installed
|
||||
step = OobeUpdateVocabulary.StepStateReached;
|
||||
osStep = IsState(targetState, OobeUpdateVocabulary.InstalledState) ? OsUpdateSteps.Installed
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.Staged
|
||||
: null;
|
||||
message = $"Servicing finished for {pkg}: now {target}";
|
||||
break;
|
||||
case EventId_RebootRequired:
|
||||
step = "reboot_required";
|
||||
step = OobeUpdateVocabulary.StepRebootRequired;
|
||||
osStep = OsUpdateSteps.RebootRequired;
|
||||
message = $"Servicing needs a restart to finish {pkg} → {target}";
|
||||
break;
|
||||
case EventId_ChangeFailed:
|
||||
case EventId_PartiallyInstalled:
|
||||
step = "failed";
|
||||
step = OobeUpdateVocabulary.StepFailed;
|
||||
osStep = OsUpdateSteps.Failed;
|
||||
severity = EventSeverity.Warning;
|
||||
message = $"Servicing FAILED for {pkg} → {target}" +
|
||||
|
||||
+116
-38
@@ -51,6 +51,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
internal const int BackfillLookbackMaxMinutes = 360;
|
||||
|
||||
/// <summary>
|
||||
/// Lookback of the first run's update page backfill (<see cref="BackfillUpdatePageTelemetry"/>)
|
||||
/// — the hour the servicing watcher reads back, so the page's records and the update's
|
||||
/// servicing steps cover the same time.
|
||||
/// </summary>
|
||||
internal const int UpdatePageBackfillLookbackMinutes = 60;
|
||||
|
||||
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_ShellCore_WebAppStarted,
|
||||
@@ -100,7 +107,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private bool _espExitDetected;
|
||||
private bool _whiteGloveDetected;
|
||||
private bool _helloWizardStartDetected;
|
||||
private string _lastCxhPage; // 62405 names no page, it ends the last one started
|
||||
// 62405 names no page, it ends the last one started. The live watcher's page; a backfill
|
||||
// moves it only with a newer record (see ObserveCxhRecord).
|
||||
private string _lastCxhPage;
|
||||
private long _lastCxhPageRecordId = -1;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
/// <summary>
|
||||
@@ -194,7 +204,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// WhiteGlove success — indistinguishable on the backend from a real no-signal
|
||||
// enrollment. Surface it as one-shot telemetry.
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
|
||||
collectorName: "ShellCoreTracker", reason: "watcher_arm_failed", ex: ex);
|
||||
collectorName: Constants.EventSources.ShellCoreTracker, reason: "watcher_arm_failed", ex: ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +268,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
IReadOnlyList<string> properties = null)
|
||||
{
|
||||
var fields = ReadCxhFields(eventId, description, properties);
|
||||
ObserveCxhRecord(eventId, fields, timestamp, isBackfill, recordId);
|
||||
ObserveCxhRecord(eventId, fields, timestamp, isBackfill, recordId, backfillPage: null);
|
||||
if (eventId == EventId_ShellCore_WebAppStopped || eventId == EventId_ShellCore_WebAppEventName) return;
|
||||
|
||||
string eventType;
|
||||
@@ -459,31 +469,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = ClampLookbackMinutes(lookbackMinutes) * 60 * 1000;
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
$"*[System[{WatchedEventIdsXPath} and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
|
||||
var records = new List<ShellCoreRecord>();
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
var description = record.FormatDescription() ?? "";
|
||||
// Preserve the historical event time across backfill so subscribers
|
||||
// (EspAndHelloTrackerAdapter) can stamp signals with the source time
|
||||
// rather than collapsing to wall-clock-now.
|
||||
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
|
||||
records.Add(new ShellCoreRecord(record.Id, description, timestamp,
|
||||
record.RecordId ?? -1, ReadProperties(record)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReplayBackfillRecords(records);
|
||||
ReplayBackfillRecords(ReadRecentRecords(lookbackMinutes));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -491,6 +477,76 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The FIRST run's counterpart of the restart replay, for telemetry only: the update
|
||||
/// page's records of the last <paramref name="lookbackMinutes"/> minutes (clamped like
|
||||
/// the replay) become <c>oobe_update_page</c> rows. The agent is installed during the ESP
|
||||
/// and can start after the update page began; without this its time attribution has no
|
||||
/// page start to begin the update with (field: a first run 7 min after the update's
|
||||
/// restart saw neither the page start nor the update's first half).
|
||||
/// <para>
|
||||
/// Nothing else is looked at: no Hello-wizard replay, no ESP exit or failure count, no
|
||||
/// <c>agent_trace</c>. The first run keeps its decision behaviour exactly — see
|
||||
/// <see cref="ReplayBackfillRecords"/> for why no replayed record may reach the engine and
|
||||
/// <c>DefaultComponentFactory.ResolveEspExitBackfillLookbackMinutes</c> for why the first
|
||||
/// run has no replay. No boot bound: an agent that first starts after the update's restart
|
||||
/// is exactly the case to cover; the RecordId watermark keeps it from re-sending.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void BackfillUpdatePageTelemetry(int lookbackMinutes)
|
||||
{
|
||||
try
|
||||
{
|
||||
ReplayUpdatePageTelemetry(ReadRecentRecords(lookbackMinutes));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Shell-Core update page backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The update page telemetry of a chronological batch of Shell-Core records — nothing else.</summary>
|
||||
internal void ReplayUpdatePageTelemetry(IReadOnlyList<ShellCoreRecord> records)
|
||||
{
|
||||
if (records == null || records.Count == 0) return;
|
||||
|
||||
var page = new BackfillPageCursor();
|
||||
foreach (var record in records)
|
||||
{
|
||||
var fields = ReadCxhFields(record.Id, record.Description ?? string.Empty, record.Properties);
|
||||
ObserveCxhRecord(record.Id, fields, record.OccurredAtUtc, isBackfill: true, recordId: record.RecordId, backfillPage: page);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The watched Shell-Core records of the last minutes, oldest first, with their original event time.</summary>
|
||||
private List<ShellCoreRecord> ReadRecentRecords(int lookbackMinutes)
|
||||
{
|
||||
var lookbackMs = ClampLookbackMinutes(lookbackMinutes) * 60 * 1000;
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
$"*[System[{WatchedEventIdsXPath} and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
|
||||
var records = new List<ShellCoreRecord>();
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
var description = record.FormatDescription() ?? "";
|
||||
// Preserve the historical event time across backfill so subscribers
|
||||
// (EspAndHelloTrackerAdapter) can stamp signals with the source time
|
||||
// rather than collapsing to wall-clock-now.
|
||||
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
|
||||
records.Add(new ShellCoreRecord(record.Id, description, timestamp,
|
||||
record.RecordId ?? -1, ReadProperties(record)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays a chronological batch of Shell-Core records. <b>Only the Hello-wizard start
|
||||
/// (62404) is replayed.</b> ESP exits and ESP failures (62407) are counted and reported,
|
||||
@@ -546,12 +602,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var skippedFailures = 0;
|
||||
DateTime? oldestSkipped = null;
|
||||
DateTime? newestSkipped = null;
|
||||
var page = new BackfillPageCursor();
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
var description = record.Description ?? string.Empty;
|
||||
var fields = ReadCxhFields(record.Id, description, record.Properties);
|
||||
ObserveCxhRecord(record.Id, fields, record.OccurredAtUtc, isBackfill: true, recordId: record.RecordId);
|
||||
ObserveCxhRecord(record.Id, fields, record.OccurredAtUtc, isBackfill: true, recordId: record.RecordId, backfillPage: page);
|
||||
|
||||
if (record.Id == EventId_ShellCore_WebAppStarted)
|
||||
{
|
||||
@@ -594,7 +651,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.AgentTrace,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ShellCoreTracker",
|
||||
Source = Constants.EventSources.ShellCoreTracker,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message =
|
||||
$"Shell-Core replay skipped {skippedExits} ESP exit(s) and {skippedFailures} ESP failure(s) " +
|
||||
@@ -662,6 +719,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// CloudExperienceHost navigation: breadcrumbs and OOBE update page telemetry (D-310)
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>The last page started within one backfill batch.</summary>
|
||||
private sealed class BackfillPageCursor
|
||||
{
|
||||
public string Page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes one agent.log line per CloudExperienceHost page start/stop and per update-page
|
||||
/// event name, so a diagnostics package shows the OOBE navigation. Records of the update
|
||||
@@ -669,8 +732,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// are also reported as <c>oobe_update_page</c> — once across agent runs, by RecordId — and
|
||||
/// at the page's live start and stop the registry state behind it as
|
||||
/// <c>oobe_update_state</c>. No decision.
|
||||
/// <para>
|
||||
/// A stop (62405) belongs to the page started last. A backfill batch
|
||||
/// (<paramref name="backfillPage"/>) reads records older than the live watcher's, which is
|
||||
/// armed first, so it keeps its own last page; it moves the live watcher's only with a
|
||||
/// record of a higher RecordId — a replayed older start must not take over the next live
|
||||
/// stop, and a first run's live stop still finds the page that started before the agent.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ObserveCxhRecord(int eventId, CxhFields fields, DateTime occurredAtUtc, bool isBackfill, long recordId)
|
||||
private void ObserveCxhRecord(int eventId, CxhFields fields, DateTime occurredAtUtc, bool isBackfill, long recordId,
|
||||
BackfillPageCursor backfillPage)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -678,8 +749,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (eventId == EventId_ShellCore_WebAppStarted && fields.Primary != null)
|
||||
_lastCxhPage = fields.Primary;
|
||||
page = _lastCxhPage;
|
||||
{
|
||||
if (backfillPage != null) backfillPage.Page = fields.Primary;
|
||||
if (backfillPage == null || (recordId >= 0 && recordId > _lastCxhPageRecordId))
|
||||
{
|
||||
_lastCxhPage = fields.Primary;
|
||||
if (recordId >= 0) _lastCxhPageRecordId = recordId;
|
||||
}
|
||||
}
|
||||
page = backfillPage != null ? backfillPage.Page : _lastCxhPage;
|
||||
}
|
||||
|
||||
var crumb = FormatCxhBreadcrumb(eventId, fields, page);
|
||||
@@ -698,10 +776,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_pageWatermark.MarkEmitted(recordId);
|
||||
|
||||
// The registry is read now — only a live page boundary is the page's moment.
|
||||
if (!isBackfill && record.CxhEvent != OobeUpdatePageRecord.EventName)
|
||||
if (!isBackfill && record.CxhEvent != OobeUpdateVocabulary.EventName)
|
||||
{
|
||||
_oobeUpdate.ReportState(
|
||||
record.CxhEvent == OobeUpdatePageRecord.PageStarted
|
||||
record.CxhEvent == OobeUpdateVocabulary.PageStarted
|
||||
? OobeUpdateTelemetry.Moments.UpdatePageStarted
|
||||
: OobeUpdateTelemetry.Moments.UpdatePageStopped,
|
||||
record.Page);
|
||||
@@ -801,11 +879,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted:
|
||||
return IsUpdatePageToken(fields.Primary)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, eventId, page: fields.Primary)
|
||||
? new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStarted, eventId, page: fields.Primary)
|
||||
: null;
|
||||
case EventId_ShellCore_WebAppStopped:
|
||||
return IsUpdatePageToken(lastPage)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, eventId,
|
||||
? new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStopped, eventId,
|
||||
page: lastPage, result: fields.Primary ?? "?")
|
||||
: null;
|
||||
case EventId_ShellCore_WebAppEventName:
|
||||
@@ -813,7 +891,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (!IsUpdatePageToken(fields.Primary)) return null;
|
||||
var value = eventId == EventId_ShellCore_WebAppEvent ? SafeValue(fields.Value) : null;
|
||||
return new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, eventId, name: fields.Primary, value: value);
|
||||
return new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, eventId, name: fields.Primary, value: value);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// - Explicit "Required app failed" lines from the ESP required-app enforcer
|
||||
//
|
||||
// TODO(stall-probe-imelog-scan): Also scan IntuneManagementExtension.log (not just
|
||||
// AppWorkload.log). Learning from the Oriflame session b4b5d37e-a993-453e-b16b-9b75098022e4
|
||||
// AppWorkload.log). Learning from a field session
|
||||
// (MS Intune AAD token service outage 2026-04-08): the ESP blocker was
|
||||
// "Failed to get AAD token. errorCode = 3399548929" (= 0xCAA90001 AADSTS_SERVER_ERROR)
|
||||
// and this string lives in IntuneManagementExtension.log, NOT AppWorkload.log. Add a third
|
||||
|
||||
+23
-7
@@ -254,9 +254,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
if (subKey == null)
|
||||
continue;
|
||||
|
||||
var displayName = subKey.GetValue("DisplayName")?.ToString();
|
||||
var displayName = ReadRegistryText(subKey, "DisplayName");
|
||||
var systemComponent = subKey.GetValue("SystemComponent");
|
||||
var parentKeyName = subKey.GetValue("ParentKeyName")?.ToString();
|
||||
var parentKeyName = ReadRegistryText(subKey, "ParentKeyName");
|
||||
|
||||
if (ShouldExclude(displayName, systemComponent, parentKeyName))
|
||||
continue;
|
||||
@@ -264,11 +264,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
results.Add(new SoftwareEntry
|
||||
{
|
||||
DisplayName = displayName,
|
||||
DisplayVersion = subKey.GetValue("DisplayVersion")?.ToString(),
|
||||
Publisher = subKey.GetValue("Publisher")?.ToString(),
|
||||
InstallDate = subKey.GetValue("InstallDate")?.ToString(),
|
||||
InstallLocation = subKey.GetValue("InstallLocation")?.ToString(),
|
||||
UninstallString = subKey.GetValue("UninstallString")?.ToString(),
|
||||
DisplayVersion = ReadRegistryText(subKey, "DisplayVersion"),
|
||||
Publisher = ReadRegistryText(subKey, "Publisher"),
|
||||
InstallDate = ReadRegistryText(subKey, "InstallDate"),
|
||||
InstallLocation = ReadRegistryText(subKey, "InstallLocation"),
|
||||
UninstallString = ReadRegistryText(subKey, "UninstallString"),
|
||||
IsWindowsInstaller = Convert.ToInt32(subKey.GetValue("WindowsInstaller") ?? 0) == 1,
|
||||
RegistrySource = source
|
||||
});
|
||||
@@ -287,6 +287,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadRegistryText(RegistryKey key, string name)
|
||||
=> CutAtNul(key.GetValue(name)?.ToString());
|
||||
|
||||
/// <summary>
|
||||
/// A registry string the way Windows shows it: up to the first NUL. Some installers write
|
||||
/// REG_SZ data padded with NULs, and .NET Framework drops only one terminator — the rest
|
||||
/// would ride into the normalized identity and, server-side, into a table key.
|
||||
/// </summary>
|
||||
internal static string CutAtNul(string value)
|
||||
{
|
||||
if (value == null)
|
||||
return null;
|
||||
var nul = value.IndexOf('\0');
|
||||
return nul < 0 ? value : value.Substring(0, nul);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HKU per-user profile enumeration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -284,7 +284,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// Throws <see cref="BackendAuthException"/> for 401/403 responses so callers can
|
||||
/// distinguish authentication failures from transient server errors.
|
||||
/// <para>
|
||||
/// Field case (sits-d.cloud, 2026-08-09): a pre-cutover agent build pointed at a
|
||||
/// Field case (2026-08-09): a pre-cutover agent build pointed at a
|
||||
/// retired Function App; Azure answers for a stopped app with a platform-level 403
|
||||
/// carrying an HTML error page ("Web App - Unavailable"). Our backend never returns
|
||||
/// HTML on 401/403 — an HTML body therefore means the response did not come from the
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// signal once per second for at most <paramref name="maxWait"/>. It cannot prove backend
|
||||
/// reachability — the callers' retry loops handle that — it only keeps retry budgets from
|
||||
/// being burned into a link that is provably still down (BootTrigger relaunch after a
|
||||
/// mid-enrollment reboot, Wi-Fi still associating; tenant aebdce78 audits 2026-08-23/24).
|
||||
/// mid-enrollment reboot, Wi-Fi still associating; kiosk-tenant audits 2026-08-23/24).
|
||||
/// Free on the normal path — a live link returns immediately. Probe errors end the wait,
|
||||
/// never the caller's operation.
|
||||
/// </summary>
|
||||
|
||||
@@ -258,7 +258,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// on esp_exiting events long after Start.
|
||||
ImeLogHost? imeLogHostRef = null;
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19), narrowed after review: the Shell-Core ESP-exit
|
||||
// Cloud-PC fix (2026-08-19), narrowed after review: the Shell-Core ESP-exit
|
||||
// replay is restart recovery, so it stays OFF on a first run and the happy path keeps
|
||||
// its pre-fix behaviour exactly. See ResolveEspExitBackfillLookbackMinutes.
|
||||
var espExitBackfillLookbackMinutes = ResolveEspExitBackfillLookbackMinutes(
|
||||
@@ -453,7 +453,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
imeLogHost.Tracker.OnUserTokenAcquired = lineUtc => aadJoinHost.NotifyUserTokenAcquired(lineUtc);
|
||||
imeLogHost.Tracker.OnTokenFailureLine = (code, lineUtc) => aadJoinHost.NotifyTokenFailureLine(code, lineUtc);
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19): the user-apps-settled AccountSetup synthesis used
|
||||
// Cloud-PC fix (2026-08-19): the user-apps-settled AccountSetup synthesis used
|
||||
// to run ONLY on the EspExited edge. On a tenant with a large required-app set the ESP
|
||||
// page exits while apps are still in flight, the single attempt misses, and nothing
|
||||
// ever re-checks — sessions 8110e262 / a89aac2d reached 138/138 apps with 0 failed and
|
||||
|
||||
@@ -118,7 +118,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// the Shell-Core watcher only ever saw events written AFTER <see cref="Start"/>. Any
|
||||
/// agent restart therefore lost an ESP exit (62407) or Hello-wizard start (62404) that
|
||||
/// happened while the agent was down — and after a mid-ESP reboot that is exactly the
|
||||
/// window the completion signal lands in (sits-d Cloud PCs, 2026-08-19: five sessions
|
||||
/// window the completion signal lands in (Cloud PCs, 2026-08-19: five sessions
|
||||
/// hung in AccountSetup until the server-side timeout while the devices were fine).
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -142,6 +142,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// replayed 62407 cannot be placed in time; see
|
||||
/// <c>ShellCoreTracker.ReplayBackfillRecords</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The first run reads the last hour for the OOBE update page telemetry instead — no
|
||||
/// replay, nothing that reaches the decision engine; see
|
||||
/// <c>ShellCoreTracker.BackfillUpdatePageTelemetry</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
@@ -149,6 +154,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
if (_espExitBackfillLookbackMinutes > 0)
|
||||
_tracker.BackfillRecentHelloWizardStart(_espExitBackfillLookbackMinutes);
|
||||
else
|
||||
_tracker.BackfillUpdatePageTelemetry(ShellCoreTracker.UpdatePageBackfillLookbackMinutes);
|
||||
}
|
||||
|
||||
public void Stop() => _tracker.Stop();
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <item>6 attempts total; between attempts <c>2^attempt</c> seconds (2s, 4s, 8s, 16s, 32s —
|
||||
/// ~62 s of backoff). The original V1 budget of 5 attempts / ~30 s was too short for the
|
||||
/// reboot-relaunch on Wi-Fi kiosks: the relaunched agent gave up and exited 7 before
|
||||
/// the network came back, leaving the session silent forever (tenant aebdce78,
|
||||
/// the network came back, leaving the session silent forever (kiosk-tenant
|
||||
/// 2026-08-23 audit). A relaunch that misses here is lost — nothing retries later.</item>
|
||||
/// <item>On <c>response.Success == true</c> → stop immediately.</item>
|
||||
/// <item>On <see cref="BackendAuthException"/> (401/403) → report to the
|
||||
|
||||
+1
-1
@@ -824,7 +824,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// IME-log event) but the agent actually observed only the end-state. Severity and phrasing
|
||||
/// are outcome-calibrated via <see cref="ObservationCoverage.DescribeLateStart"/>: a failure
|
||||
/// stays a Warning with the post-mortem/hung-script note, a success is an Info about
|
||||
/// tail-only coverage (fleet 2026-07-28: tenant 659c3a90 collected 12 Warnings on healthy
|
||||
/// tail-only coverage (fleet 2026-07-28: one tenant collected 12 Warnings on healthy
|
||||
/// enrollments whose bootstrap merely ran late). No state mutation.
|
||||
/// </summary>
|
||||
private void MaybeEmitAgentLateStart(EnrollmentTerminatedEventArgs args)
|
||||
|
||||
@@ -188,7 +188,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// network yet — its failure says nothing about the latest version. The certificate is
|
||||
/// proof of connectivity, and this is the last chance to swap a stranded old build
|
||||
/// (stale baked-in ApiBaseUrl) for a current one BEFORE session registration hits a
|
||||
/// retired endpoint (field case sits-d.cloud, 2026-08-09). Timeouts are relaxed vs. the
|
||||
/// retired endpoint (field case 2026-08-09). Timeouts are relaxed vs. the
|
||||
/// boot-speed-tuned startup check; enrollment is already past OOBE's critical path here.
|
||||
/// On update success the process restarts and this method never returns; the
|
||||
/// await-enrollment config was already removed, so the new binary proceeds normally.
|
||||
|
||||
@@ -76,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 — config self-heal. When the Phase 4 fetch ran before the network link was
|
||||
/// up (agent relaunched at boot, Wi-Fi still associating — tenant aebdce78 audit 2026-08-24:
|
||||
/// up (agent relaunched at boot, Wi-Fi still associating — kiosk-tenant audit 2026-08-24:
|
||||
/// ~50% of sessions ran on ConfigVersion=0 exactly this way), a successful session
|
||||
/// registration has just proven the backend reachable. Retry the fetch once and apply
|
||||
/// the live tenant config instead of stranding the whole session on cache/defaults.
|
||||
|
||||
@@ -79,7 +79,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// write to the same state file; per-enrollment lifecycle like the other state files.
|
||||
var startupEventGate = new AutopilotMonitor.Agent.V2.Core.Persistence.StartupEventGate(stateSubdir, logger);
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19): a reboot_kill means the agent was down across the
|
||||
// Cloud-PC fix (2026-08-19): a reboot_kill means the agent was down across the
|
||||
// forced mid-ESP restart. Hand the boot timestamp to the factory so the Shell-Core
|
||||
// ESP-exit backfill can reach back over the whole downtime instead of 5 minutes.
|
||||
var previousBootUtc =
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
bool helloPolicyEnabledRead = false,
|
||||
bool allowContinueAnyway = false)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-2dac8298", "tenant-047b2e1f", T0);
|
||||
var state = DecisionState.CreateInitial("sess-2dac8298", "tenant-a", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
/// </summary>
|
||||
private static DecisionState SetupSession(DecisionEngine engine)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-4910a5a5", "tenant-e46bc88e", T0);
|
||||
var state = DecisionState.CreateInitial("sess-4910a5a5", "tenant-a", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
|
||||
@@ -105,7 +105,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
["failureType"] = "Provisioning_DeviceSetup_Apps_Failed",
|
||||
["failedSubcategory"] = "Apps",
|
||||
["category"] = "DeviceSetup",
|
||||
["likelyCulpritApps"] = "Oriflame Cosmectics - Teams Backgroundsv v2",
|
||||
["likelyCulpritApps"] = "Contoso Branding - Teams Backgrounds v2",
|
||||
["likelyCulpritAppCount"] = "1",
|
||||
}));
|
||||
|
||||
@@ -129,7 +129,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
|
||||
var advisory = FindTimelineEffect(step, "esp_failure_advisory");
|
||||
Assert.NotNull(advisory);
|
||||
Assert.Equal("Oriflame Cosmectics - Teams Backgroundsv v2", advisory!.Parameters!["likelyCulpritApps"]);
|
||||
Assert.Equal("Contoso Branding - Teams Backgrounds v2", advisory!.Parameters!["likelyCulpritApps"]);
|
||||
}
|
||||
|
||||
// ===================================================== recovery hook ====
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
var builder = DecisionState.CreateInitial("s", "t", Fixed.AddDays(-1))
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(452) // the restored StepIndex the sits-d sessions showed
|
||||
.WithStepIndex(452) // the restored StepIndex the field sessions showed
|
||||
.WithLastAppliedSignalOrdinal(9);
|
||||
|
||||
if (accountSetupEntered)
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
bool helloEnabled = false,
|
||||
bool includeDesktop = true)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-caa6cf50", "tenant-e46bc88e", T0);
|
||||
var state = DecisionState.CreateInitial("sess-caa6cf50", "tenant-a", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
|
||||
+6
-7
@@ -8,8 +8,7 @@ using Xunit;
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// sits-d Cloud-PC fix (2026-08-20, sessions 8110e262 / a89aac2d / e7ba63c9 / cb4a485a /
|
||||
/// 3d6278fb). On a SkipUser=true flow Windows never renders the user ESP page: Shell-Core
|
||||
/// Cloud-PC fix (2026-08-20, five field sessions). On a SkipUser=true flow Windows never renders the user ESP page: Shell-Core
|
||||
/// 62407 does not fire and the AccountSetup provisioning categories are never written —
|
||||
/// verified live (Shell-Core watcher armed across three agent runs, zero 62407 in six
|
||||
/// hours; SkipUserStatusPage read 10+ times across all runs, True every time). Arm B of
|
||||
@@ -20,14 +19,14 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
/// <para>
|
||||
/// The fix lets the observed skip stand in for the final-exit evidence inside
|
||||
/// <c>HandleImeUserSessionCompletedV1</c>'s completion attempt. These tests replay the real
|
||||
/// 8110e262 signal ordering end to end (including the Fix-10 AccountSetup bounce-back a
|
||||
/// field session's signal ordering end to end (including the Fix-10 AccountSetup bounce-back a
|
||||
/// restart re-emission triggers) and pin the negative space: no skip observed, no desktop,
|
||||
/// ghost IME completion, and Device Preparation flows are all unchanged.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ClassicSkipUserEspUserSessionCompletionTests
|
||||
{
|
||||
// 8110e262 run 1: agent_started 08:30:08Z.
|
||||
// Field session run 1: agent_started 08:30:08Z.
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 19, 8, 30, 8, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
@@ -35,7 +34,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial(
|
||||
"8110e262-da57-4282-9290-bb0c96d05614", "5ca2b350", T0);
|
||||
"33333333-3333-3333-3333-333333333333", "tenant-a", T0);
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
|
||||
@@ -94,7 +93,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
[Fact]
|
||||
public void Fix10_bounceback_is_healed_by_the_ime_reemission()
|
||||
{
|
||||
// Restart shape (8110e262 run 3): the promoted AwaitingHello gets bounced back by a
|
||||
// Restart shape (field session run 3): the promoted AwaitingHello gets bounced back by a
|
||||
// re-emitted AccountSetup phase line (Fix 10 cancels HelloSafety, deliberately — it
|
||||
// guards the premature-promotion case). The IME phase line always precedes the
|
||||
// user-session-complete line in log order, so the re-emitted completion re-knocks.
|
||||
@@ -116,7 +115,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
[Theory]
|
||||
// No EspConfigDetected at all — SkipUserEsp unknown.
|
||||
[InlineData(null)]
|
||||
// Explicit SkipUser=false (the two sits-d machines that succeeded had this shape and
|
||||
// Explicit SkipUser=false (the two field machines that succeeded had this shape and
|
||||
// a REAL page: exit + registry evidence. Without the exit the gate must stay shut.)
|
||||
[InlineData("false")]
|
||||
public void No_promotion_without_an_observed_skip(string? skipUser)
|
||||
|
||||
@@ -9,7 +9,7 @@ using Xunit;
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Continue-Anyway observation mode (tenant c9787ba2, session 53d1e9f6, 2026-08-08).
|
||||
/// Continue-Anyway observation mode (field case 2026-08-08).
|
||||
/// A Device-phase ESP terminal failure (AccountSetup never entered) on a
|
||||
/// Continue-Anyway-enabled profile is a hard fail by default; with the tenant opt-in
|
||||
/// (<c>EspContinueAnywayObservationEnabled</c> observation) it is defanged into a 60-min
|
||||
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
bool continueAnyway = true,
|
||||
bool helloPolicyDisabled = true)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-53d1e9f6", "tenant-c9787ba2", T0);
|
||||
var state = DecisionState.CreateInitial("sess-53d1e9f6", "tenant-a", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
var espConfig = new Dictionary<string, string>
|
||||
{
|
||||
|
||||
@@ -409,7 +409,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
[Fact]
|
||||
public void Timeout_fire_with_recent_first_deployment_activity_extends_instead_of_timing_out()
|
||||
{
|
||||
// Report 55e6afd61c9d (Douglas): timer armed at detection (phase 0, RJ agent MSI
|
||||
// Field report: timer armed at detection (phase 0, RJ agent MSI
|
||||
// install during DeviceSetup), first deployment only started 16 min later, Office
|
||||
// completed 3 s before the deadline — the hard cut truncated an actively working
|
||||
// deployment. The fire must now re-arm to lastActivity + 60 min instead.
|
||||
|
||||
@@ -70,7 +70,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
||||
{
|
||||
// PR1 (Session 4fa5a2d4, 2026-05-22) — ContinueAnyway-aware ESP terminal-failure
|
||||
// defang. The fixture replays the canonical race that produced 199/202 false
|
||||
// positives in tenant c9787ba2: ContinueAnyway=true profile, AccountSetup entered,
|
||||
// positives in one tenant: ContinueAnyway=true profile, AccountSetup entered,
|
||||
// then DeviceSetup/Certificates registry-write flips to failed. Without PR1 this
|
||||
// would land in Failed; PR1 records EspAdvisoryFailureRecordedUtc and keeps the
|
||||
// stage at EspAccountSetup so the agent stays in monitoring.
|
||||
|
||||
@@ -222,7 +222,7 @@ public class AppHomingServiceTests
|
||||
[Fact]
|
||||
public async Task Probe_reports_only_add_on_roles_when_manifest_and_add_on_roles_are_both_missing()
|
||||
{
|
||||
// Live 2026-09-03 shape (thesrgroup, 1 s after consent): validation role still
|
||||
// Live 2026-09-03 shape (1 s after consent): validation role still
|
||||
// propagating AND the add-on missing. The add-on decides (permanent); the propagating
|
||||
// manifest role is not the admin's problem and must not appear in the to-do list.
|
||||
SetupProbe(GraphTokenResult.Success(Jwt()));
|
||||
|
||||
@@ -118,7 +118,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
Assert.False(app.TryGetProperty("trendDelta", out _));
|
||||
}
|
||||
|
||||
// ── /apps/{appName}/analytics ───────────────────────────────────────────
|
||||
// ── /apps/analytics ─────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<JsonElement> BuildAnalyticsAsync(
|
||||
List<AppInstallSummary> summaries, ISessionRepository? sessionRepo = null)
|
||||
|
||||
@@ -32,6 +32,7 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<OpsEventService> _opsEventsMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
||||
@@ -96,6 +97,9 @@ public class AuthFunctionSideEffectTests
|
||||
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
|
||||
|
||||
_signalR = new FakeSignalRNotificationService();
|
||||
_opsEventsMock = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
_opsEventsMock.Setup(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
_sut = new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
globalAdminMock.Object,
|
||||
@@ -115,7 +119,8 @@ public class AuthFunctionSideEffectTests
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR,
|
||||
adminConfigService.Object,
|
||||
_offboardingRepo);
|
||||
_offboardingRepo,
|
||||
opsEvents: _opsEventsMock.Object);
|
||||
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
@@ -163,7 +168,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Empty(_signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn)
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn, string? tenantId = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
@@ -172,8 +177,9 @@ public class AuthFunctionSideEffectTests
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim("preferred_username", callerUpn) }, "test"));
|
||||
var claims = new List<Claim> { new("preferred_username", callerUpn) };
|
||||
if (tenantId != null) claims.Add(new Claim("tid", tenantId));
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(
|
||||
@@ -226,14 +232,19 @@ public class AuthFunctionSideEffectTests
|
||||
// OnboardedBy is the immutable copy of the first-login UPN that auto-promote on
|
||||
// preview approval reads — UpdatedBy may later be clobbered by background syncs.
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
// The DPA version rides along in the same onboarding write (D-252).
|
||||
Assert.Equal(CurrentDpa, config.DpaVersion);
|
||||
// A first sign-in without the get-started tick leaves the Terms + DPA pending: the portal asks.
|
||||
Assert.True(config.DpaAcceptancePending);
|
||||
Assert.Null(config.DpaVersion);
|
||||
var (written, source, _) = Assert.Single(_store.Writes);
|
||||
Assert.Equal("contoso.com", written.DomainName);
|
||||
Assert.Equal(Upn, written.OnboardedBy);
|
||||
Assert.Equal(CurrentDpa, written.DpaVersion);
|
||||
Assert.True(written.DpaAcceptancePending);
|
||||
Assert.Null(written.DpaVersion);
|
||||
Assert.Null(written.DpaAcceptedBy);
|
||||
Assert.Null(written.DpaAcceptedAt);
|
||||
Assert.Equal("auth", source);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record next to the ping
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Tenant Signup",
|
||||
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
|
||||
@@ -437,20 +448,139 @@ public class AuthFunctionSideEffectTests
|
||||
private const string CurrentDpa = AutopilotMonitor.Shared.Constants.CurrentDpaVersion;
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_WhenDomainAlreadySet_NeverStampsDpaVersion()
|
||||
public async Task HandleNewTenantDomain_ThroughTheGetStartedTick_AcceptsWithTheOnboarding()
|
||||
{
|
||||
// The DPA version is written once, with the onboarding (D-252): an existing tenant's
|
||||
// login writes nothing — no stamp, no save.
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = null!;
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn, signupConsent: true);
|
||||
|
||||
var written = Assert.Single(_store.Writes).Row;
|
||||
Assert.False(written.DpaAcceptancePending);
|
||||
Assert.Equal(CurrentDpa, written.DpaVersion);
|
||||
Assert.Equal(Upn, written.DpaAcceptedBy);
|
||||
Assert.NotNull(written.DpaAcceptedAt);
|
||||
Assert.True(written.DpaAcceptedAt >= before);
|
||||
// This login's view follows the stored row, so its auth/me carries no dialog.
|
||||
Assert.False(config.DpaAcceptancePending);
|
||||
Assert.Equal(CurrentDpa, config.DpaVersion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task HandleNewTenantDomain_WhenDomainAlreadySet_NeverAsksOrStamps(bool signupConsent)
|
||||
{
|
||||
// Decided once, with the onboarding: an existing tenant's login writes nothing — no stamp,
|
||||
// no pending flag, whichever button the user came through (tenants that pre-date the
|
||||
// acceptance are never asked).
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
config.DpaVersion = null;
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn, signupConsent: signupConsent);
|
||||
|
||||
Assert.Null(config.DpaVersion);
|
||||
Assert.False(config.DpaAcceptancePending);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// AcceptDpa — POST auth/dpa-acceptance (portal dialog)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static TenantConfiguration PendingTenant()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
config.OnboardedBy = "first@contoso.com";
|
||||
config.DpaAcceptancePending = true;
|
||||
return config;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_PendingTenant_RecordsTheAcceptanceAndClearsTheDialog()
|
||||
{
|
||||
_store.Row = PendingTenant();
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
var (written, source, _) = Assert.Single(_store.Writes);
|
||||
Assert.Equal("auth", source);
|
||||
Assert.False(written.DpaAcceptancePending);
|
||||
Assert.Equal(CurrentDpa, written.DpaVersion);
|
||||
Assert.Equal(Upn, written.DpaAcceptedBy);
|
||||
Assert.True(written.DpaAcceptedAt >= before);
|
||||
Assert.Equal("first@contoso.com", written.OnboardedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_NothingPending_IsANoOp()
|
||||
{
|
||||
// Already accepted (a double click, a second user) or a tenant that was never asked.
|
||||
var accepted = DefaultConfig();
|
||||
accepted.DomainName = "contoso.com";
|
||||
accepted.DpaVersion = "2026-01-1.0";
|
||||
accepted.DpaAcceptedBy = "first@contoso.com";
|
||||
_store.Row = accepted;
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId);
|
||||
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Equal("2026-01-1.0", _store.Row!.DpaVersion);
|
||||
Assert.Equal("first@contoso.com", _store.Row.DpaAcceptedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_OnlyEverWritesTheCallersOwnTenant()
|
||||
{
|
||||
// The row is keyed by the token's tid: a caller from another tenant reaches no pending row.
|
||||
const string otherTenant = "ffffffff-0000-1111-2222-333333333333";
|
||||
var otherStore = _tenantConfigMock.StubWrites(otherTenant, row: null);
|
||||
_store.Row = PendingTenant();
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: "user@fabrikam.com", tenantId: otherTenant);
|
||||
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Empty(otherStore.Writes);
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.True(_store.Row!.DpaAcceptancePending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_OffboardingTombstone_IsRefused()
|
||||
{
|
||||
var tombstone = PendingTenant();
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = AutopilotMonitor.Functions.Functions.Admin.TenantOffboardFunction.OffboardingDisabledReason;
|
||||
_store.Row = tombstone;
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId);
|
||||
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_LostWriteRace_AsksToRetry()
|
||||
{
|
||||
_store.Row = PendingTenant();
|
||||
_store.Force = TenantConfigUpdateStatus.Conflict;
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId);
|
||||
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_PreservesExistingOnboardedBy()
|
||||
{
|
||||
|
||||
@@ -75,6 +75,24 @@ public class AuthFunctionTests
|
||||
Assert.Equal(agentSeen, (DateTime)body.whatsNewSeenAgentUtc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void HappyPath_CarriesThePendingTermsAndDpaAcceptance(bool pending)
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DpaAcceptancePending = pending;
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("platform", "platform")]
|
||||
[InlineData("Platform", "platform")]
|
||||
|
||||
@@ -372,6 +372,9 @@ public class CascadeVerificationServiceTests
|
||||
public Task<TableEntity?> GetSessionRowAsync(string tenantId, string sessionId, CancellationToken ct = default)
|
||||
=> Task.FromResult<TableEntity?>(null);
|
||||
|
||||
public Task<TableEntity?> GetSessionRowAsync(string tenantId, string sessionId, IEnumerable<string> select, CancellationToken ct = default)
|
||||
=> Task.FromResult<TableEntity?>(null);
|
||||
|
||||
public Task<TableEntity?> GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken ct = default)
|
||||
=> Task.FromResult<TableEntity?>(null);
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ public class ConfigBackupHookTests
|
||||
config.DomainName = "contoso.com";
|
||||
config.UpdatedBy = "admin@contoso.com";
|
||||
config.DataRetentionDays = 30;
|
||||
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
|
||||
config.WebhookUrl = "https://hooks.example.com/secret-hook";
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -127,9 +127,9 @@ public class ConfigBackupHookTests
|
||||
// The snapshot holds the STORED row (retention 30), not the incoming one.
|
||||
Assert.Contains("\"DataRetentionDays\":30", snapshot.EntityJson);
|
||||
// Raw secrets ARE in EntityJson (full-fidelity restore source)…
|
||||
Assert.Contains("contoso.webhook.office.com", snapshot.EntityJson);
|
||||
Assert.Contains("hooks.example.com/secret-hook", snapshot.EntityJson);
|
||||
// …but the advisory diff masks them by property-name heuristics (ConfigDiffHelper).
|
||||
Assert.DoesNotContain("contoso.webhook.office.com", snapshot.DiffJson ?? string.Empty);
|
||||
Assert.DoesNotContain("hooks.example.com/secret-hook", snapshot.DiffJson ?? string.Empty);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync(TenantId, Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
|
||||
@@ -93,7 +93,6 @@ public class ConfigRedactionTests
|
||||
var existing = new TenantConfiguration
|
||||
{
|
||||
DiagnosticsBlobSasUrl = "https://acct.blob.core.windows.net/c?sig=real",
|
||||
TeamsWebhookUrl = "https://teams.example/real",
|
||||
WebhookUrl = "https://hooks.example/real",
|
||||
WebhookCustomHeadersJson = "{\"X-Api-Key\":\"real\"}",
|
||||
DomainName = "contoso.com",
|
||||
@@ -106,7 +105,6 @@ public class ConfigRedactionTests
|
||||
incoming.RestoreRedactedSecretsFrom(existing);
|
||||
|
||||
Assert.Equal(existing.DiagnosticsBlobSasUrl, incoming.DiagnosticsBlobSasUrl);
|
||||
Assert.Equal(existing.TeamsWebhookUrl, incoming.TeamsWebhookUrl);
|
||||
Assert.Equal(existing.WebhookUrl, incoming.WebhookUrl);
|
||||
Assert.Equal(existing.WebhookCustomHeadersJson, incoming.WebhookCustomHeadersJson);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -795,4 +798,38 @@ public class DeviceJourneyAndFtrTests
|
||||
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-00");
|
||||
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-01");
|
||||
}
|
||||
|
||||
// ── sweep merge: a failed history read is never "no history" ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SweepMerge_HistoryReadFailure_SkipsTheDevice_AndKeepsOlderRefsOfTheOthers()
|
||||
{
|
||||
// A failed point read used to come back as null ("no history"): the sweep merged the
|
||||
// window into an empty chain and replaced the row, dropping every older ref.
|
||||
var repo = new Mock<IMetricsRepository>();
|
||||
repo.Setup(r => r.GetDeviceHistoryAsync(TenantA, "broken"))
|
||||
.ThrowsAsync(new RequestFailedException(503, "Server Busy"));
|
||||
repo.Setup(r => r.GetDeviceHistoryAsync(TenantA, "healthy"))
|
||||
.ReturnsAsync(History(TenantA, Ref("old-1", SessionStatus.Failed, T0.AddDays(-200))));
|
||||
var upserted = new List<DeviceHistory>();
|
||||
repo.Setup(r => r.UpsertDeviceHistoryAsync(It.IsAny<DeviceHistory>()))
|
||||
.Callback<DeviceHistory>(upserted.Add)
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var byDevice = new Dictionary<(string TenantId, string SerialKey), List<SessionSummary>>
|
||||
{
|
||||
[(TenantA, "broken")] = new() { Session("w-1") },
|
||||
[(TenantA, "healthy")] = new() { Session("w-2") },
|
||||
};
|
||||
|
||||
var (merged, readFailures, firstError) = await MaintenanceService.MergeWindowIntoDeviceHistoriesAsync(
|
||||
repo.Object, byDevice, new Dictionary<string, ISet<string>>());
|
||||
|
||||
Assert.Equal(1, readFailures);
|
||||
Assert.IsType<RequestFailedException>(firstError);
|
||||
var written = Assert.Single(upserted);
|
||||
Assert.Equal("healthy", written.SerialKey);
|
||||
Assert.Equal(new[] { "old-1", "w-2" }, written.Chain.Select(r => r.SessionId).ToArray());
|
||||
Assert.Same(written, Assert.Single(merged));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path,
|
||||
/// never the bot), recipient validation, the Global-Admin gate, the rendered message, and the
|
||||
/// send against a stubbed provider endpoint.
|
||||
/// </summary>
|
||||
public class EmailChannelProviderTests
|
||||
{
|
||||
private const int Email = (int)WebhookProviderType.Email;
|
||||
private static readonly NotificationScope Platform = NotificationScope.Platform;
|
||||
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
|
||||
|
||||
private static NotificationChannel Channel(string? url, string id = "mail", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = Email, Url = url, Enabled = enabled };
|
||||
|
||||
private static NotificationAlert Alert() => new()
|
||||
{
|
||||
EventType = "PushDeliveryFailed",
|
||||
Title = "Ops Alert: Platform/PushDeliveryFailed",
|
||||
Summary = "A push service refused the platform key",
|
||||
Severity = NotificationSeverity.Error,
|
||||
Facts = { new() { Name = "Category", Value = "Platform" }, new() { Name = "Tenant", Value = "11111111-1111-1111-1111-111111111111" } },
|
||||
Sections = { new() { Title = "What happened", Text = "status 401 <kid=abc>" } },
|
||||
Actions = { new() { Type = "openUrl", Title = "Open alerts", Url = "https://portal.example.invalid/admin/settings/alerts" } },
|
||||
};
|
||||
|
||||
// ── Dispatch routing ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Email_channel_reaches_the_email_sender_with_its_recipients_and_never_the_webhook_path()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var email = new Mock<IEmailChannelSender>();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var alert = Alert();
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid; two@example.invalid") }, alert, Platform);
|
||||
|
||||
email.Verify(e => e.SendOpsAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Email_channel_without_recipients_is_skipped_and_the_test_path_reports_the_sender_result()
|
||||
{
|
||||
var email = new Mock<IEmailChannelSender>();
|
||||
email.Setup(e => e.SendAlertWithResultAsync("ops@example.invalid", It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "Sent to 1 recipient(s)." });
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform);
|
||||
email.Verify(e => e.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
|
||||
var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("Sent to 1 recipient(s).", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatcher_without_an_email_transport_skips_the_channel_and_says_so_on_test()
|
||||
{
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid") }, Alert(), Platform); // no throw
|
||||
var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not registered", result.Message);
|
||||
}
|
||||
|
||||
// ── Validation and gate ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("ops@example.invalid")]
|
||||
[InlineData("ops@example.invalid; second@example.invalid")]
|
||||
[InlineData("ops@example.invalid, second@example.invalid ,third@sub.example.invalid")]
|
||||
[InlineData(" first.last+tag@example.invalid ")]
|
||||
public void ValidateEmailDestination_accepts_one_to_five_plain_addresses(string recipients)
|
||||
=> Assert.Null(TenantConfigValidation.ValidateEmailDestination(recipients));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(";,")]
|
||||
[InlineData("not-an-address")]
|
||||
[InlineData("Ops Team <ops@example.invalid>")]
|
||||
[InlineData("ops@localhost")]
|
||||
[InlineData("https://hooks.example.invalid/x")]
|
||||
[InlineData("a@x.invalid;b@x.invalid;c@x.invalid;d@x.invalid;e@x.invalid;f@x.invalid")]
|
||||
public void ValidateEmailDestination_rejects_blank_malformed_named_hostless_and_too_many(string? recipients)
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateEmailDestination(recipients));
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_uses_the_recipient_rule_not_the_ssrf_url_gate()
|
||||
{
|
||||
var ok = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(ok));
|
||||
|
||||
var bad = NotificationChannel.SerializeList(new[] { Channel("https://hooks.example.invalid/x") });
|
||||
Assert.Contains("not a valid e-mail address", TenantConfigValidation.ValidateNotificationChannels(bad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Email_provider_is_global_admin_only()
|
||||
{
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider(Email));
|
||||
var candidate = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: true));
|
||||
var refused = TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: false);
|
||||
Assert.Equal("E-mail channels can only be configured by a Global Administrator.", refused);
|
||||
|
||||
// A GA-created channel survives a tenant admin's unrelated save; retargeting it does not.
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, candidate, isGlobalAdmin: false));
|
||||
var retargeted = NotificationChannel.SerializeList(new[] { Channel("other@example.invalid") });
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, candidate, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Renderer_reads_like_a_message_not_a_log_line()
|
||||
{
|
||||
var alert = Alert();
|
||||
alert.DataJson = "{\"service\":\"apple\",\"statusCode\":401,\"note\":\"<script>\",\"nested\":{\"a\":1}}";
|
||||
|
||||
// No brackets, no severity shouting, the event name as words, the category in parentheses.
|
||||
Assert.Equal("Autopilot Monitor: Push delivery failed (Platform)", EmailAlertRenderer.Subject(alert));
|
||||
|
||||
var html = EmailAlertRenderer.Html(alert);
|
||||
Assert.Contains("Autopilot Monitor raised an error in the Platform category.", html); // one sentence first
|
||||
Assert.Contains("<h1 style=\"font-size:18px;margin:0 0 8px 0;\">Push delivery failed</h1>", html);
|
||||
Assert.DoesNotContain("Ops Alert: Platform/PushDeliveryFailed", html); // the machine title stays out
|
||||
Assert.Contains("A push service refused the platform key", html);
|
||||
Assert.Contains("<td style=\"padding:3px 0;\">Platform</td>", html); // facts as a table
|
||||
Assert.Contains("status 401 <kid=abc>", html); // section text escaped
|
||||
Assert.DoesNotContain("<kid=abc>", html);
|
||||
Assert.Contains("<td style=\"padding:3px 0;\"><script></td>", html); // payload as rows, escaped
|
||||
Assert.Contains("<td style=\"padding:3px 0;\">{"a":1}</td>", html); // nested values stay compact JSON
|
||||
Assert.DoesNotContain("<pre", html); // no raw JSON block for an object
|
||||
Assert.Contains("Open alerts: <a href=\"https://portal.example.invalid/admin/settings/alerts\"", html); // visible link
|
||||
Assert.Contains("sent automatically by Autopilot Monitor", html);
|
||||
|
||||
var text = EmailAlertRenderer.Text(alert);
|
||||
Assert.StartsWith("Autopilot Monitor raised an error in the Platform category.", text);
|
||||
Assert.Contains("Push delivery failed\n", text);
|
||||
Assert.Contains("Category: Platform\n", text);
|
||||
Assert.Contains("note: <script>\n", text); // text part is not HTML-escaped
|
||||
Assert.Contains("Open alerts: https://portal.example.invalid/admin/settings/alerts", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renderer_drops_non_http_links_caps_a_non_object_payload_and_survives_an_empty_alert()
|
||||
{
|
||||
var alert = new NotificationAlert { Title = "", Summary = "", DataJson = new string('x', EmailAlertRenderer.MaxPayloadChars + 100) };
|
||||
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "bad", Url = "javascript:alert(1)" });
|
||||
|
||||
Assert.Equal("Autopilot Monitor: Notification", EmailAlertRenderer.Subject(alert));
|
||||
var html = EmailAlertRenderer.Html(alert);
|
||||
Assert.DoesNotContain("javascript:", html);
|
||||
Assert.Contains("(truncated)", html);
|
||||
Assert.True(html.Length < EmailAlertRenderer.MaxPayloadChars + 3000);
|
||||
Assert.Equal("Autopilot Monitor: Hello world", EmailAlertRenderer.Subject(new NotificationAlert { Title = "Hello world", Summary = "" }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PushDeliveryFailed", "Push delivery failed")]
|
||||
[InlineData("SlaBreachNotification", "Sla breach notification")]
|
||||
[InlineData("SLAEvaluation", "SLA evaluation")]
|
||||
[InlineData("enrollment_failed", "Enrollment failed")]
|
||||
[InlineData("TenantSignup", "Tenant signup")]
|
||||
public void Humanize_splits_event_names_into_words(string eventName, string expected)
|
||||
=> Assert.Equal(expected, EmailAlertRenderer.Humanize(eventName));
|
||||
|
||||
// ── Sending through the provider path ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Sender_posts_one_message_per_recipient_and_reports_what_the_provider_accepted()
|
||||
{
|
||||
var handler = new StubHandler();
|
||||
var (service, _) = Sender(handler, apiKey: "key");
|
||||
|
||||
var result = await service.SendAlertWithResultAsync("ops@example.invalid; second@example.invalid", Alert());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("Sent to 2 recipient(s). Provider status: sent, id abc,abc.", result.Message);
|
||||
Assert.Equal(2, handler.Bodies.Count);
|
||||
Assert.Contains("\"to\":[{\"email\":\"ops@example.invalid\"", handler.Bodies[0]);
|
||||
Assert.Contains("\"subject\":\"Autopilot Monitor: Push delivery failed (Platform)\"", handler.Bodies[0]);
|
||||
Assert.Contains("\"tags\":[\"alert\"]", handler.Bodies[0]);
|
||||
Assert.Contains("\"track_opens\":false", handler.Bodies[0]);
|
||||
Assert.Contains("\"text\":\"Autopilot Monitor raised an error", handler.Bodies[0]); // own text part
|
||||
Assert.Contains("\"auto_text\":false", handler.Bodies[0]);
|
||||
Assert.Contains("\"headers\":{\"Auto-Submitted\":\"auto-generated\",\"X-Auto-Response-Suppress\":\"All\"}", handler.Bodies[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sender_reports_a_rejection_and_an_unconfigured_provider_without_throwing()
|
||||
{
|
||||
var handler = new StubHandler { Responder = _ => StubHandler.Json(HttpStatusCode.OK, "[{\"email\":\"ops@example.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\"}]") };
|
||||
var (service, _) = Sender(handler, apiKey: "key");
|
||||
var rejected = await service.SendAlertWithResultAsync("ops@example.invalid", Alert());
|
||||
Assert.False(rejected.Success);
|
||||
Assert.Contains("accepted 0 of 1", rejected.Message);
|
||||
Assert.Contains("Provider status: rejected", rejected.Message);
|
||||
|
||||
var (unconfigured, _) = Sender(new StubHandler(), apiKey: "");
|
||||
var skipped = await unconfigured.SendAlertWithResultAsync("ops@example.invalid", Alert());
|
||||
Assert.False(skipped.Success);
|
||||
Assert.Contains("not configured", skipped.Message);
|
||||
|
||||
await unconfigured.SendOpsAlertAsync("ops@example.invalid", Alert()); // never throws
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRecipients_trims_dedupes_and_keeps_order()
|
||||
{
|
||||
var list = EmailNotificationService.ParseRecipients(" a@x.invalid ; B@x.invalid, a@x.invalid ,, c@x.invalid ");
|
||||
Assert.Equal(new[] { "a@x.invalid", "B@x.invalid", "c@x.invalid" }, list);
|
||||
Assert.Empty(EmailNotificationService.ParseRecipients(null));
|
||||
}
|
||||
|
||||
private static (EmailNotificationService Service, EmailService Email) Sender(StubHandler handler, string apiKey)
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[EmailService.ApiKeyConfigKey] = apiKey,
|
||||
["Email:Endpoint"] = "https://mail.example.invalid/send",
|
||||
}).Build();
|
||||
var templates = new EmailTemplateService(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
|
||||
NullLogger<EmailTemplateService>.Instance);
|
||||
var email = new EmailService(new HttpClient(handler, disposeHandler: false), config, templates, NullLogger<EmailService>.Instance);
|
||||
return (new EmailNotificationService(email, NullLogger<EmailNotificationService>.Instance), email);
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public List<string> Bodies { get; } = new();
|
||||
public Func<HttpRequestMessage, HttpResponseMessage> Responder { get; set; } = _ =>
|
||||
Json(HttpStatusCode.OK, "[{\"email\":\"x@y.invalid\",\"status\":\"sent\",\"_id\":\"abc\"}]");
|
||||
|
||||
public static HttpResponseMessage Json(HttpStatusCode status, string body)
|
||||
=> new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Bodies.Add(request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken));
|
||||
return Responder(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,9 +111,14 @@ public sealed class EmailServiceTests
|
||||
Assert.Equal(EmailService.DefaultFromName, message.GetProperty("from_name").GetString());
|
||||
Assert.Equal("it@contoso.invalid", message.GetProperty("to")[0].GetProperty("email").GetString());
|
||||
Assert.Equal("to", message.GetProperty("to")[0].GetProperty("type").GetString());
|
||||
Assert.Equal(EmailTemplates.PreviewApprovedSubject, message.GetProperty("subject").GetString());
|
||||
Assert.Equal("Your Autopilot Monitor access for contoso.invalid is ready", message.GetProperty("subject").GetString());
|
||||
Assert.Contains("contoso.invalid", message.GetProperty("html").GetString());
|
||||
Assert.True(message.GetProperty("auto_text").GetBoolean());
|
||||
// A text part of our own (HtmlToText) instead of the provider's derived one, and the
|
||||
// RFC 3834 header on every automated mail.
|
||||
Assert.False(message.GetProperty("auto_text").GetBoolean());
|
||||
Assert.Contains("contoso.invalid", message.GetProperty("text").GetString());
|
||||
Assert.DoesNotContain("<", message.GetProperty("text").GetString());
|
||||
Assert.Equal("auto-generated", message.GetProperty("headers").GetProperty("Auto-Submitted").GetString());
|
||||
Assert.False(message.GetProperty("track_opens").GetBoolean());
|
||||
Assert.False(message.GetProperty("track_clicks").GetBoolean());
|
||||
Assert.Equal("welcome", message.GetProperty("tags")[0].GetString());
|
||||
@@ -253,7 +258,8 @@ public sealed class EmailServiceTests
|
||||
// The mail is sent from a noreply address after portal access is gone, so the
|
||||
// feedback pointers must be external channels the recipient can actually reach.
|
||||
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.Contains("linkedin.com", html);
|
||||
Assert.Contains("github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.DoesNotContain("linkedin.com", html); // one feedback link only (user go 2026-10-08)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+2
-3
@@ -137,7 +137,6 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
[InlineData("PUT", "/api/rules/gather/rule-1", "rules/gather/{ruleId}")]
|
||||
[InlineData("DELETE", "/api/tenants/tid-1/admins/user@contoso.com", "tenants/{tenantId}/admins/{adminUpn}")]
|
||||
[InlineData("PATCH", "/api/tenants/tid-1/admins/user@contoso.com/permissions", "tenants/{tenantId}/admins/{adminUpn}/permissions")]
|
||||
[InlineData("DELETE", "/api/devices/block/SN123456", "devices/block/{encodedSerialNumber}")]
|
||||
[InlineData("DELETE", "/api/versions/block/v1.0.*", "versions/block/{encodedPattern}")]
|
||||
[InlineData("PATCH", "/api/global/session-reports/report-1/note", "global/session-reports/{reportId}/note")]
|
||||
[InlineData("POST", "/api/rules/analyze/ANALYZE-ID-001/create-from-template", "rules/analyze/{ruleId}/create-from-template")]
|
||||
@@ -284,8 +283,8 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/global/apps/list", "global/apps/list")]
|
||||
[InlineData("GET", "/api/global/apps/Company%20Portal/analytics", "global/apps/{appName}/analytics")]
|
||||
[InlineData("GET", "/api/global/apps/Company%20Portal/sessions", "global/apps/{appName}/sessions")]
|
||||
[InlineData("GET", "/api/global/apps/analytics", "global/apps/analytics")]
|
||||
[InlineData("GET", "/api/global/apps/sessions", "global/apps/sessions")]
|
||||
public void GlobalAppsRoutes_AreGlobalReadOrAdmin(string method, string path, string expectedTemplate)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
|
||||
|
||||
@@ -355,7 +355,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
[Fact]
|
||||
public void Classify_device_provisioned_user_phase_pending_within_grace_is_AwaitingUser()
|
||||
{
|
||||
// The dominant crcins.com case: DeviceSetup 4/4, AccountSetup 0/5, silent, 6h in.
|
||||
// The dominant field case: DeviceSetup 4/4, AccountSetup 0/5, silent, 6h in.
|
||||
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup05) }, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, status);
|
||||
Assert.Contains("Device Setup completed", reason);
|
||||
@@ -382,7 +382,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
public void ExtractRollup_agent_timeout_enrollment_failed_is_not_explicit_failure()
|
||||
{
|
||||
// The max-lifetime watchdog's enrollment_failed(failureType=agent_timeout) is "the agent
|
||||
// gave up waiting", not a failure verdict — it must not poison rule 1 (tenant a53e67ec).
|
||||
// gave up waiting", not a failure verdict — it must not poison rule 1 (misclassification audit 2026-07-16).
|
||||
var timeout = Evt("enrollment_failed",
|
||||
data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasExplicitFailure);
|
||||
@@ -589,7 +589,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
}
|
||||
}
|
||||
|
||||
// -------- WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21) --------
|
||||
// -------- WhiteGlove Part-2 awaiting-user gate (field analysis 2026-08-21) --------
|
||||
|
||||
private static readonly DateTime Resumed = Start.AddMinutes(20);
|
||||
|
||||
@@ -603,7 +603,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
return (status, reason);
|
||||
}
|
||||
|
||||
/// <summary>The fairstone event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
|
||||
/// <summary>The field event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
|
||||
private static EnrollmentEvent[] WgParkedEvents() => new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
|
||||
@@ -660,7 +660,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_parked_within_grace_is_AwaitingUser()
|
||||
{
|
||||
// The fairstone shape: technician powers the device off at the logon screen minutes
|
||||
// The field shape: technician powers the device off at the logon screen minutes
|
||||
// after the reseal-reboot. Part-1 whiteglove_complete must NOT reconcile to Succeeded.
|
||||
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, status);
|
||||
@@ -734,7 +734,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
|
||||
// -------- Self-deploying profile gate (kiosk tenant aebdce78, audit 2026-08-23) --------
|
||||
// -------- Self-deploying profile gate (kiosk tenant, audit 2026-08-23) --------
|
||||
|
||||
private static (SessionStatus, string) ClassifySelfDeploying(
|
||||
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 3, int grace = 51)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Functions.Feedback;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The rating bubble asks a person at most twice: an X click counts, the second ask waits for the pause,
|
||||
/// a rating ends it for good and is never replaced. Only clicks count.
|
||||
/// </summary>
|
||||
public sealed class FeedbackPromptRulesTests
|
||||
{
|
||||
private const int Pause = 60;
|
||||
private static readonly DateTime Now = new(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static FeedbackEntry Row(bool submitted, int dismissals, int daysAgo) => new()
|
||||
{
|
||||
Upn = "alice@contoso.invalid",
|
||||
TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
DisplayName = "Alice",
|
||||
Submitted = submitted,
|
||||
Dismissed = !submitted,
|
||||
Rating = submitted ? 5 : null,
|
||||
Comment = submitted ? "Great" : null,
|
||||
DismissCount = dismissals,
|
||||
InteractedAt = Now.AddDays(-daysAgo),
|
||||
};
|
||||
|
||||
private static FeedbackAnswer Dismiss() =>
|
||||
new("alice@contoso.invalid", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "Alice", true, null, null);
|
||||
|
||||
private static FeedbackAnswer Rate(int stars, string? comment) =>
|
||||
new("alice@contoso.invalid", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "Alice", false, stars, comment);
|
||||
|
||||
// ── When the bubble may ask ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NeverAnswered_IsAsked()
|
||||
=> Assert.True(FeedbackPromptRules.IsEligible(null, Pause, Now));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(0, 400)]
|
||||
[InlineData(1, 400)]
|
||||
public void AfterARating_NeverAgain(int dismissals, int daysAgo)
|
||||
=> Assert.False(FeedbackPromptRules.IsEligible(Row(submitted: true, dismissals, daysAgo), Pause, Now));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, false)]
|
||||
[InlineData(59, false)]
|
||||
[InlineData(60, true)]
|
||||
[InlineData(400, true)]
|
||||
public void AfterTheFirstDismissal_OnceMore_AfterThePause(int daysAgo, bool expected)
|
||||
=> Assert.Equal(expected, FeedbackPromptRules.IsEligible(Row(submitted: false, 1, daysAgo), Pause, Now));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(400)]
|
||||
public void AfterTheSecondDismissal_NeverAgain(int daysAgo)
|
||||
=> Assert.False(FeedbackPromptRules.IsEligible(Row(submitted: false, 2, daysAgo), Pause, Now));
|
||||
|
||||
[Fact]
|
||||
public void PauseZero_MeansOneAskOnly()
|
||||
=> Assert.False(FeedbackPromptRules.IsEligible(Row(submitted: false, 1, 400), 0, Now));
|
||||
|
||||
// ── What one answer does ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FirstDismissal_CountsOne()
|
||||
{
|
||||
var row = FeedbackPromptRules.Apply(null, Dismiss(), Now)!;
|
||||
|
||||
Assert.True(row.Dismissed);
|
||||
Assert.False(row.Submitted);
|
||||
Assert.Equal(1, row.DismissCount);
|
||||
Assert.Equal(Now, row.InteractedAt);
|
||||
Assert.Null(row.Rating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondDismissal_CountsTwo_AndEndsTheAsking()
|
||||
{
|
||||
var row = FeedbackPromptRules.Apply(Row(submitted: false, 1, 61), Dismiss(), Now)!;
|
||||
|
||||
Assert.Equal(2, row.DismissCount);
|
||||
Assert.False(FeedbackPromptRules.IsEligible(row, Pause, Now.AddDays(400)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DismissalBeyondTheSecond_ChangesNothing()
|
||||
=> Assert.Null(FeedbackPromptRules.Apply(Row(submitted: false, 2, 1), Dismiss(), Now));
|
||||
|
||||
[Fact]
|
||||
public void RatingAfterADismissal_IsStored_AndKeepsTheCount()
|
||||
{
|
||||
var row = FeedbackPromptRules.Apply(Row(submitted: false, 1, 61), Rate(4, "Mostly good"), Now)!;
|
||||
|
||||
Assert.True(row.Submitted);
|
||||
Assert.False(row.Dismissed);
|
||||
Assert.Equal(4, row.Rating);
|
||||
Assert.Equal("Mostly good", row.Comment);
|
||||
Assert.Equal(1, row.DismissCount);
|
||||
Assert.Equal("alice@contoso.invalid", row.Upn);
|
||||
Assert.Equal("Alice", row.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARatedRow_IsFinal()
|
||||
{
|
||||
// A second tab's dismissal or second rating must neither replace the rating nor ping again.
|
||||
var rated = Row(submitted: true, 0, 1);
|
||||
|
||||
Assert.Null(FeedbackPromptRules.Apply(rated, Dismiss(), Now));
|
||||
Assert.Null(FeedbackPromptRules.Apply(rated, Rate(1, "changed my mind"), Now));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// App names and serial numbers are free text and may contain '/', '%' and '+'. They travel as query
|
||||
/// values: in a path segment the host decodes an escaped slash to a separator before routing (a 404
|
||||
/// before any function runs), and a catalog placeholder never matches a '/'. The encoded forms below
|
||||
/// are what the portal's URLSearchParams sends; req.Query decodes them exactly once.
|
||||
/// </summary>
|
||||
public class FreeTextNameQueryParamTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-0000-0000-0000000000a7";
|
||||
|
||||
public static TheoryData<string, string> Names => new()
|
||||
{
|
||||
{ "a/b", "a%2Fb" },
|
||||
{ "100%", "100%25" },
|
||||
{ "x+y", "x%2By" },
|
||||
{ "Company Portal", "Company+Portal" },
|
||||
// A literal escape sequence survives; the former second decode turned it into "a b".
|
||||
{ "a%20b", "a%2520b" },
|
||||
};
|
||||
|
||||
// ── Policy catalog ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/apps/analytics", "apps/analytics", EndpointPolicy.MemberRead, TenantScoping.None)]
|
||||
[InlineData("GET", "/api/apps/sessions", "apps/sessions", EndpointPolicy.MemberRead, TenantScoping.None)]
|
||||
[InlineData("GET", "/api/global/apps/analytics", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
|
||||
[InlineData("GET", "/api/global/apps/sessions", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
|
||||
[InlineData("DELETE", "/api/devices/block", "devices/block", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
public void Free_name_routes_are_literal_and_keep_their_policy(
|
||||
string method, string path, string template, EndpointPolicy policy, TenantScoping scoping)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(template, entry!.RouteTemplate);
|
||||
Assert.Equal(policy, entry.Policy);
|
||||
Assert.Equal(scoping, entry.TenantScoping);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_route_carries_an_app_name_or_serial_number_in_the_path()
|
||||
{
|
||||
var offenders = EndpointAccessPolicyCatalog.Entries
|
||||
.Where(e => e.RouteTemplate.Contains("{appName}", StringComparison.OrdinalIgnoreCase)
|
||||
|| e.RouteTemplate.Contains("serialNumber}", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
|
||||
.ToList();
|
||||
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
// ── Query path: the name reaches the handler unchanged ──────────────────
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task App_analytics_matches_the_exact_name(string name, string encoded)
|
||||
{
|
||||
var function = new GetAppAnalyticsFunction(
|
||||
NullLogger<GetAppAnalyticsFunction>.Instance, MetricsWith(name), Sessions(), RegressionTracker());
|
||||
var req = MemberRequest($"?appName={encoded}&days=30");
|
||||
|
||||
var res = await function.Run(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
var root = Json(res);
|
||||
Assert.Equal(name, root.GetProperty("appName").GetString());
|
||||
Assert.Equal(1, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task App_sessions_match_the_exact_name(string name, string encoded)
|
||||
{
|
||||
var function = new GetAppSessionsFunction(NullLogger<GetAppSessionsFunction>.Instance, MetricsWith(name), Sessions());
|
||||
var req = MemberRequest($"?appName={encoded}&days=30");
|
||||
|
||||
var res = await function.Run(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
Assert.Equal(1, Json(res).GetProperty("total").GetInt32());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task Global_app_analytics_and_sessions_match_the_exact_name(string name, string encoded)
|
||||
{
|
||||
var analytics = new GetGlobalAppAnalyticsFunction(
|
||||
NullLogger<GetGlobalAppAnalyticsFunction>.Instance, MetricsWith(name), Sessions(), RegressionTracker());
|
||||
var sessions = new GetGlobalAppSessionsFunction(NullLogger<GetGlobalAppSessionsFunction>.Instance, MetricsWith(name), Sessions());
|
||||
var query = $"?appName={encoded}&days=30&tenantId={TenantId}";
|
||||
|
||||
var analyticsRes = await analytics.Run(EndpointHarness.Request(TenantId, queryString: query).Req);
|
||||
var sessionsRes = await sessions.Run(EndpointHarness.Request(TenantId, queryString: query).Req);
|
||||
|
||||
Assert.Equal(1, Json(analyticsRes).GetProperty("summary").GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(1, Json(sessionsRes).GetProperty("total").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task App_routes_without_a_name_are_a_bad_request()
|
||||
{
|
||||
var analytics = new GetAppAnalyticsFunction(
|
||||
NullLogger<GetAppAnalyticsFunction>.Instance, MetricsWith("x"), Sessions(), RegressionTracker());
|
||||
var globalSessions = new GetGlobalAppSessionsFunction(NullLogger<GetGlobalAppSessionsFunction>.Instance, MetricsWith("x"), Sessions());
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await analytics.Run(MemberRequest("?days=30"))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await globalSessions.Run(EndpointHarness.Request(TenantId, queryString: "?appName=%20&days=30").Req)).StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task Unblock_receives_the_exact_serial_number(string serial, string encoded)
|
||||
{
|
||||
var repo = new Mock<IDeviceSecurityRepository>();
|
||||
repo.Setup(r => r.UnblockDeviceAsync(TenantId, serial)).ReturnsAsync(Array.Empty<string>());
|
||||
var function = UnblockFunction(repo.Object);
|
||||
|
||||
var res = await function.UnblockDevice(
|
||||
EndpointHarness.Request(TenantId, queryString: $"?serialNumber={encoded}&tenantId={TenantId}").Req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
repo.Verify(r => r.UnblockDeviceAsync(TenantId, serial), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unblock_without_a_serial_number_is_a_bad_request()
|
||||
{
|
||||
var repo = new Mock<IDeviceSecurityRepository>();
|
||||
var function = UnblockFunction(repo.Object);
|
||||
|
||||
var res = await function.UnblockDevice(EndpointHarness.Request(TenantId, queryString: $"?tenantId={TenantId}").Req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
repo.Verify(r => r.UnblockDeviceAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Rig ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>One finished install of <paramref name="name"/>, plus decoys named after its parts.</summary>
|
||||
private static IMetricsRepository MetricsWith(string name)
|
||||
{
|
||||
AppInstallSummary Install(string appName, string sessionId) => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = sessionId,
|
||||
AppName = appName,
|
||||
Status = "Succeeded",
|
||||
StartedAt = DateTime.UtcNow.AddHours(-2),
|
||||
DurationSeconds = 30,
|
||||
};
|
||||
|
||||
var summaries = new List<AppInstallSummary> { Install(name, "s-1"), Install("a", "s-2"), Install("b", "s-3"), Install("a b", "s-4") };
|
||||
var repo = new Mock<IMetricsRepository>();
|
||||
repo.Setup(r => r.GetAppsDashboardSummariesAsync(It.IsAny<DateTime>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(() => summaries.ToList());
|
||||
return repo.Object;
|
||||
}
|
||||
|
||||
private static ISessionRepository Sessions()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((SessionSummary?)null);
|
||||
return repo.Object;
|
||||
}
|
||||
|
||||
private static IHardwareRejectionNotificationTracker RegressionTracker()
|
||||
{
|
||||
var tracker = new Mock<IHardwareRejectionNotificationTracker>();
|
||||
tracker.Setup(t => t.GetAppVersionRegressionsAsync(It.IsAny<string>())).ReturnsAsync(new List<AppVersionRegressionAlert>());
|
||||
return tracker.Object;
|
||||
}
|
||||
|
||||
/// <summary>A member request: the JWT tenant comes from the principal the authentication middleware would set.</summary>
|
||||
private static HttpRequestData MemberRequest(string queryString)
|
||||
{
|
||||
var (req, context) = EndpointHarness.Request(TenantId, queryString: queryString);
|
||||
context.Items["ClaimsPrincipal"] = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", TenantId) }, "test"));
|
||||
return req;
|
||||
}
|
||||
|
||||
private static DeviceBlockFunction UnblockFunction(IDeviceSecurityRepository securityRepo)
|
||||
{
|
||||
var maintenance = new Mock<IMaintenanceRepository>();
|
||||
maintenance
|
||||
.Setup(m => m.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var opsEvents = new OpsEventService(
|
||||
Mock.Of<IOpsEventRepository>(), NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
|
||||
return new DeviceBlockFunction(
|
||||
NullLogger<DeviceBlockFunction>.Instance,
|
||||
new BlockedDeviceService(securityRepo, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance),
|
||||
maintenance.Object,
|
||||
opsEvents);
|
||||
}
|
||||
|
||||
private static JsonElement Json(HttpResponseData res)
|
||||
{
|
||||
res.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(res.Body);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -82,63 +82,59 @@ public class GenericWebhookTests
|
||||
Assert.Equal("hardware_rejected", alert.EventType);
|
||||
}
|
||||
|
||||
// ── TenantConfiguration.GetGenericWebhookHeaders ──────────────────────
|
||||
// ── NotificationChannel.GetCustomHeaders ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_ReturnsEmpty_ForNonGenericProvider()
|
||||
public void GetCustomHeaders_ReturnsEmpty_ForNonGenericProvider()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
var channel = new NotificationChannel
|
||||
{
|
||||
WebhookUrl = "https://hooks.slack.com/x",
|
||||
WebhookProviderType = (int)WebhookProviderType.Slack,
|
||||
WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer x\"}",
|
||||
ProviderType = (int)WebhookProviderType.Slack,
|
||||
CustomHeadersJson = "{\"Authorization\":\"Bearer x\"}",
|
||||
};
|
||||
|
||||
Assert.Empty(config.GetGenericWebhookHeaders());
|
||||
Assert.Empty(channel.GetCustomHeaders());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_ParsesHeaders_ForGenericProvider()
|
||||
public void GetCustomHeaders_ParsesHeaders_ForGenericProvider()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
var channel = new NotificationChannel
|
||||
{
|
||||
WebhookUrl = "https://tickets.example.com/in",
|
||||
WebhookProviderType = (int)WebhookProviderType.GenericJson,
|
||||
WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer abc\",\"X-Api-Key\":\"k1\"}",
|
||||
ProviderType = (int)WebhookProviderType.GenericJson,
|
||||
CustomHeadersJson = "{\"Authorization\":\"Bearer abc\",\"X-Api-Key\":\"k1\"}",
|
||||
};
|
||||
|
||||
var headers = config.GetGenericWebhookHeaders();
|
||||
var headers = channel.GetCustomHeaders();
|
||||
Assert.Equal("Bearer abc", headers["Authorization"]);
|
||||
Assert.Equal("k1", headers["X-Api-Key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_DropsRestrictedHeaders()
|
||||
public void GetCustomHeaders_DropsRestrictedHeaders()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
var channel = new NotificationChannel
|
||||
{
|
||||
WebhookUrl = "https://tickets.example.com/in",
|
||||
WebhookProviderType = (int)WebhookProviderType.GenericJson,
|
||||
WebhookCustomHeadersJson = "{\"Host\":\"evil.example.com\",\"Content-Length\":\"0\",\"X-Ok\":\"v\"}",
|
||||
ProviderType = (int)WebhookProviderType.GenericJson,
|
||||
CustomHeadersJson = "{\"Host\":\"evil.example.com\",\"Content-Length\":\"0\",\"X-Ok\":\"v\"}",
|
||||
};
|
||||
|
||||
var headers = config.GetGenericWebhookHeaders();
|
||||
var headers = channel.GetCustomHeaders();
|
||||
Assert.False(headers.ContainsKey("Host"));
|
||||
Assert.False(headers.ContainsKey("Content-Length"));
|
||||
Assert.True(headers.ContainsKey("X-Ok"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_ReturnsEmpty_ForMalformedJson()
|
||||
public void GetCustomHeaders_ReturnsEmpty_ForMalformedJson()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
var channel = new NotificationChannel
|
||||
{
|
||||
WebhookUrl = "https://tickets.example.com/in",
|
||||
WebhookProviderType = (int)WebhookProviderType.GenericJson,
|
||||
WebhookCustomHeadersJson = "{not valid",
|
||||
ProviderType = (int)WebhookProviderType.GenericJson,
|
||||
CustomHeadersJson = "{not valid",
|
||||
};
|
||||
|
||||
Assert.Empty(config.GetGenericWebhookHeaders());
|
||||
Assert.Empty(channel.GetCustomHeaders());
|
||||
}
|
||||
|
||||
// ── TenantConfigValidation.ValidateWebhookCustomHeaders ────
|
||||
|
||||
@@ -68,8 +68,8 @@ public class GetImeVersionHistoryRedactionTests
|
||||
{
|
||||
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
|
||||
|
||||
Assert.DoesNotContain("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("baae0453-6d19-47b5-aa58-8bed5a572fc8", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("aaaaaaaa-1111-4222-8333-444444444444", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("bbbbbbbb-1111-4222-8333-444444444444", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -109,8 +109,8 @@ public class GetImeVersionHistoryRedactionTests
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
|
||||
|
||||
var row = doc.RootElement[0];
|
||||
Assert.Equal("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", row.GetProperty("firstSeenTenantId").GetString());
|
||||
Assert.Equal("baae0453-6d19-47b5-aa58-8bed5a572fc8", row.GetProperty("firstSeenSessionId").GetString());
|
||||
Assert.Equal("aaaaaaaa-1111-4222-8333-444444444444", row.GetProperty("firstSeenTenantId").GetString());
|
||||
Assert.Equal("bbbbbbbb-1111-4222-8333-444444444444", row.GetProperty("firstSeenSessionId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -300,8 +300,8 @@ public class GetImeVersionHistoryRedactionTests
|
||||
{
|
||||
Version = version,
|
||||
FirstSeenAt = firstSeenAt ?? First,
|
||||
FirstSeenSessionId = "baae0453-6d19-47b5-aa58-8bed5a572fc8",
|
||||
FirstSeenTenantId = "57f34dd1-6a42-47f9-9bc0-b921fa6caa30",
|
||||
FirstSeenSessionId = "bbbbbbbb-1111-4222-8333-444444444444",
|
||||
FirstSeenTenantId = "aaaaaaaa-1111-4222-8333-444444444444",
|
||||
LastSeenAt = Last,
|
||||
SessionCount = 3109,
|
||||
MsiArchiveStatus = "Archived",
|
||||
|
||||
@@ -185,18 +185,18 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
DiagnosticsBlobSasUrl = "https://example.blob.core.windows.net/diagnostics?sv=secret-sas-token",
|
||||
TeamsWebhookUrl = "https://outlook.office.com/webhook/secret-team-hook",
|
||||
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
|
||||
NotificationChannelsJson = "[{\"id\":\"c1\",\"providerType\":2,\"url\":\"https://hooks.example.com/secret-channel-hook\"}]",
|
||||
};
|
||||
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-sas-token", json);
|
||||
Assert.DoesNotContain("secret-team-hook", json);
|
||||
Assert.DoesNotContain("secret-generic-hook", json);
|
||||
Assert.DoesNotContain("secret-channel-hook", json);
|
||||
Assert.DoesNotContain("diagnosticsBlobSasUrl", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("teamsWebhookUrl", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("webhookUrl", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("notificationChannelsJson", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ── diagnosticsUploadConfigured (drives the session-detail "Collect Logs" button) ──
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>The text/plain twin of the HTML mails (HtmlToText): what it keeps, what it drops, how it breaks lines.</summary>
|
||||
public class HtmlToTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Drops_head_style_script_and_comments_and_keeps_the_prose()
|
||||
{
|
||||
const string html = "<!DOCTYPE html><html><head><title>t</title><style>p{color:red}</style></head>" +
|
||||
"<body><!-- note --><script>alert(1)</script><p>Hello <strong>world</strong></p></body></html>";
|
||||
Assert.Equal("Hello world", HtmlToText.Convert(html));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Turns_blocks_lists_and_breaks_into_lines_and_decodes_entities()
|
||||
{
|
||||
const string html = "<h2>Welcome</h2><p>Great – access for <strong>contoso.invalid</strong> is ready.<br>Second line.</p>" +
|
||||
"<ul><li>One & two</li><li>Three</li></ul><table><tr><td>a</td><td>b</td></tr></table>";
|
||||
var text = HtmlToText.Convert(html);
|
||||
Assert.Equal("Welcome\nGreat – access for contoso.invalid is ready.\nSecond line.\n\n- One & two\n- Three\na b", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keeps_link_targets_and_collapses_whitespace()
|
||||
{
|
||||
const string html = "<p>You can now <a href=\"https://portal.example.invalid/\" target=\"_blank\" style=\"color:red\">sign in</a> and start.</p>" +
|
||||
"<p><a href=\"https://docs.example.invalid\">https://docs.example.invalid</a></p>" +
|
||||
"<p><a href=\"mailto:x@example.invalid\">write us</a> or <a href=\"https://x.invalid/a?b=1&c=2\">this</a></p>";
|
||||
var text = HtmlToText.Convert(html);
|
||||
Assert.Equal("You can now sign in (https://portal.example.invalid/) and start.\nhttps://docs.example.invalid\nwrite us or this (https://x.invalid/a?b=1&c=2)", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Survives_empty_and_broken_markup()
|
||||
{
|
||||
Assert.Equal(string.Empty, HtmlToText.Convert(null));
|
||||
Assert.Equal(string.Empty, HtmlToText.Convert(" "));
|
||||
Assert.Equal("unterminated bold", HtmlToText.Convert("<p>unterminated <b>bold"));
|
||||
Assert.Equal("x", HtmlToText.Convert("<a href='nolink'>x</a>"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_the_default_welcome_template_as_readable_text()
|
||||
{
|
||||
var text = HtmlToText.Convert(EmailTemplates.GetPreviewApprovedHtml("contoso.invalid"));
|
||||
Assert.Contains("Welcome to Autopilot Monitor", text);
|
||||
Assert.Contains("contoso.invalid", text);
|
||||
Assert.DoesNotContain("<", text);
|
||||
Assert.DoesNotContain("style=", text);
|
||||
Assert.Contains("https://", text); // the links survive as URLs
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@ public class InfrastructureWireParityTests
|
||||
unrestrictedModeEnabled = false,
|
||||
mcpClientRegistrationEnabled = true,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen
|
||||
whatsNewSeenAgentUtc = agentSeen,
|
||||
dpaAcceptancePending = true
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -72,7 +73,8 @@ public class InfrastructureWireParityTests
|
||||
UnrestrictedModeEnabled = false,
|
||||
McpClientRegistrationEnabled = true,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen
|
||||
WhatsNewSeenAgentUtc = agentSeen,
|
||||
DpaAcceptancePending = true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,7 +111,8 @@ public class InfrastructureWireParityTests
|
||||
unrestrictedModeEnabled = false,
|
||||
mcpClientRegistrationEnabled = false,
|
||||
whatsNewSeenPlatformUtc,
|
||||
whatsNewSeenAgentUtc
|
||||
whatsNewSeenAgentUtc,
|
||||
dpaAcceptancePending = false
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -130,7 +133,8 @@ public class InfrastructureWireParityTests
|
||||
UnrestrictedModeEnabled = false,
|
||||
McpClientRegistrationEnabled = false,
|
||||
WhatsNewSeenPlatformUtc = null,
|
||||
WhatsNewSeenAgentUtc = null
|
||||
WhatsNewSeenAgentUtc = null,
|
||||
DpaAcceptancePending = false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Data.Tables;
|
||||
using Xunit;
|
||||
@@ -26,14 +26,14 @@ public class IngestStatusPrefetchTests
|
||||
[InlineData("succeeded", SessionStatus.Succeeded)] // mapper parses case-insensitively — mirror it
|
||||
public void StringStatus_TheActualWireFormat_IsParsed(string stored, SessionStatus expected)
|
||||
{
|
||||
Assert.Equal(expected, IngestTelemetryFunction.TryReadSessionStatus(Row(stored)));
|
||||
Assert.Equal(expected, SessionRowProjections.TryReadStatus(Row(stored)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntStatus_LegacyDefensiveFallback_IsCast()
|
||||
{
|
||||
var defined = (int)SessionStatus.Stalled;
|
||||
Assert.Equal(SessionStatus.Stalled, IngestTelemetryFunction.TryReadSessionStatus(Row(defined)));
|
||||
Assert.Equal(SessionStatus.Stalled, SessionRowProjections.TryReadStatus(Row(defined)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -41,13 +41,13 @@ public class IngestStatusPrefetchTests
|
||||
[InlineData("")]
|
||||
public void UnparseableStatus_ReturnsNull_SoCallerFallsBackToOwnRead(string stored)
|
||||
{
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(Row(stored)));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(Row(stored)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingStatusOrRow_ReturnsNull()
|
||||
{
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(Row(status: null)));
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(null));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(Row(status: null)));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
@@ -37,7 +38,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <c>OrdinalIgnoreCase</c> equality Run performs (Run L169-180).</item>
|
||||
/// <item>session deletion locked → 410: the real <see cref="SessionDeletionGuard"/> throwing
|
||||
/// <see cref="SessionDeletionLockedException"/> (Run L205-213), plus the unique
|
||||
/// <see cref="IngestTelemetryFunction.TryReadSessionStatus"/> that consumes the guard's row.</item>
|
||||
/// <see cref="SessionRowProjections.TryReadStatus"/> that consumes the guard's row.</item>
|
||||
/// <item>kill-switch / device-blocked → 200-block: NOT duplicated here. The verdict Run() routes
|
||||
/// on (<c>IsBlocked</c>/<c>IsKill</c>/<c>UnblockAt</c>) is produced by
|
||||
/// <see cref="Functions.Services.KillSwitchEvaluator"/> and is already exhaustively covered at the
|
||||
@@ -138,7 +139,7 @@ public class IngestTelemetryAuthPathTests
|
||||
public async Task DeletionUnlocked_guard_returns_row_that_feeds_the_status_prefetch()
|
||||
{
|
||||
// Run L205-217: on an unlocked session the guard hands back the loaded row, and Run reads
|
||||
// Status off it via TryReadSessionStatus to seed the stall-heal prefetch (no second read).
|
||||
// Status off it via SessionRowProjections.TryReadStatus to seed the stall-heal prefetch (no second read).
|
||||
var guard = NewGuard(out var reader);
|
||||
var row = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
@@ -151,10 +152,10 @@ public class IngestTelemetryAuthPathTests
|
||||
var returned = await guard.EnsureWritableAndGetRowAsync(TenantId, SessionId, callerContext: "V2.IngestTelemetry");
|
||||
|
||||
Assert.Same(row, returned);
|
||||
Assert.Equal(SessionStatus.Succeeded, IngestTelemetryFunction.TryReadSessionStatus(returned));
|
||||
Assert.Equal(SessionStatus.Succeeded, SessionRowProjections.TryReadStatus(returned));
|
||||
}
|
||||
|
||||
// ============================================================ TryReadSessionStatus (unique helper)
|
||||
// ============================================================ TryReadStatus (unique helper)
|
||||
|
||||
[Theory]
|
||||
[InlineData("InProgress", SessionStatus.InProgress)]
|
||||
@@ -164,27 +165,27 @@ public class IngestTelemetryAuthPathTests
|
||||
public void TryReadSessionStatus_parses_string_status_case_insensitively(string stored, SessionStatus expected)
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId) { ["Status"] = stored };
|
||||
Assert.Equal(expected, IngestTelemetryFunction.TryReadSessionStatus(row));
|
||||
Assert.Equal(expected, SessionRowProjections.TryReadStatus(row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadSessionStatus_returns_null_for_null_row()
|
||||
{
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(null));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadSessionStatus_returns_null_when_status_column_absent()
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId); // no Status column
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(row));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadSessionStatus_returns_null_for_unparseable_status()
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId) { ["Status"] = "NotARealStatus" };
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(row));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(row));
|
||||
}
|
||||
|
||||
// ============================================================ Harness (mirrors SessionDeletionGuardTests)
|
||||
|
||||
@@ -35,6 +35,7 @@ public class NotificationChannelsTests
|
||||
ChannelJson("ok", providerType: 10),
|
||||
"{\"name\":\"no-id\",\"providerType\":20,\"url\":\"https://x.example\"}",
|
||||
ChannelJson("none-provider", providerType: 0),
|
||||
ChannelJson("retired-teams-connector", providerType: 1),
|
||||
ChannelJson("unknown-provider", providerType: 99)) + "]";
|
||||
|
||||
var parsed = NotificationChannel.ParseList(json);
|
||||
@@ -113,23 +114,6 @@ public class NotificationChannelsTests
|
||||
Assert.Equal("k", ch.GetCustomHeaders()["X-Api-Key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNotificationChannels_LegacyTeamsOnly_SynthesizesTeamsLegacyConnector()
|
||||
{
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
TeamsWebhookUrl = "https://teams.example/hook",
|
||||
TeamsNotifyOnSuccess = true,
|
||||
TeamsNotifyOnFailure = false,
|
||||
};
|
||||
|
||||
var ch = Assert.Single(cfg.GetNotificationChannels());
|
||||
Assert.Equal((int)WebhookProviderType.TeamsLegacyConnector, ch.ProviderType);
|
||||
Assert.Equal("https://teams.example/hook", ch.Url);
|
||||
Assert.True(ch.NotifyOnSuccess);
|
||||
Assert.False(ch.NotifyOnFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNotificationChannels_ChannelsJsonWinsOverLegacyFields()
|
||||
{
|
||||
@@ -233,6 +217,7 @@ public class NotificationChannelsTests
|
||||
[InlineData("not-json", "not valid JSON")]
|
||||
[InlineData("[{\"name\":\"no-id\",\"providerType\":20}]", "needs an id")]
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":99}]", "invalid provider type")]
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":1}]", "invalid provider type")] // retired Teams Office 365 Connector
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":20,\"url\":\"http://plain.example\"}]", "")] // non-https rejected by SsrfGuard format check
|
||||
public void ValidateNotificationChannels_RejectsInvalidEntries(string json, string expectedFragment)
|
||||
{
|
||||
@@ -251,6 +236,26 @@ public class NotificationChannelsTests
|
||||
Assert.Contains("duplicate", error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, false)] // retired Teams Office 365 Connector
|
||||
[InlineData(99, false)]
|
||||
[InlineData(0, true)]
|
||||
[InlineData(2, true)]
|
||||
public void ValidateModel_LegacyWebhookProviderType_MustBeSupportedOrNone(int providerType, bool valid)
|
||||
{
|
||||
var existing = TenantConfiguration.CreateDefault("tenant-1");
|
||||
var candidate = TenantConfiguration.CreateDefault("tenant-1");
|
||||
candidate.WebhookProviderType = providerType;
|
||||
candidate.WebhookUrl = "https://hooks.example/x";
|
||||
|
||||
var error = TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false);
|
||||
|
||||
if (valid)
|
||||
Assert.Null(error);
|
||||
else
|
||||
Assert.Equal("Invalid webhook provider type.", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsBadPerChannelHeaders()
|
||||
{
|
||||
|
||||
@@ -19,8 +19,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// The notification-dispatch consumer resolves channel ids against the tenant's CURRENT
|
||||
/// configuration — no URL or secret ever rides in the queue — and sends only to channels that
|
||||
/// are still enabled. Plus the wire contract: the envelope round-trips through Newtonsoft with
|
||||
/// the alert's facts, sections and severity intact.
|
||||
/// are still enabled, on the scope of the envelope's tenant (the Push transport trusts that
|
||||
/// scope, so it is pinned here). Plus the wire contract: the envelope round-trips through
|
||||
/// Newtonsoft with the alert's facts, sections and severity intact.
|
||||
/// </summary>
|
||||
public class NotificationDispatchHandlerTests
|
||||
{
|
||||
@@ -41,6 +42,21 @@ public class NotificationDispatchHandlerTests
|
||||
var sent = Assert.Single(h.Sent);
|
||||
Assert.Equal(new[] { "teams" }, sent.Channels.Select(c => c.Id));
|
||||
Assert.Equal("enrollment_succeeded", sent.Alert.EventType);
|
||||
Assert.Equal(NotificationScope.Tenant(Tenant), Assert.Single(h.Scopes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_runs_on_the_scope_of_the_envelopes_tenant()
|
||||
{
|
||||
var h = new Harness(new[] { Channel("teams", enabled: true) });
|
||||
|
||||
await h.Sut.HandleAsync(Envelope("teams"), CancellationToken.None);
|
||||
|
||||
// The scope comes from the envelope — trusted queue context — never from the alert's facts.
|
||||
var scope = Assert.Single(h.Scopes);
|
||||
Assert.Equal(NotificationScope.Tenant(Tenant), scope);
|
||||
Assert.Equal(Tenant, scope.Key);
|
||||
Assert.False(scope.IsPlatform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -112,6 +128,7 @@ public class NotificationDispatchHandlerTests
|
||||
private sealed class Harness
|
||||
{
|
||||
public List<(IReadOnlyList<NotificationChannel> Channels, NotificationAlert Alert)> Sent { get; } = new();
|
||||
public List<NotificationScope> Scopes { get; } = new();
|
||||
public NotificationDispatchHandler Sut { get; }
|
||||
|
||||
public Harness(IEnumerable<NotificationChannel>? channels)
|
||||
@@ -138,9 +155,9 @@ public class NotificationDispatchHandlerTests
|
||||
.ReturnsAsync((config, true));
|
||||
}
|
||||
|
||||
var dispatcher = new Mock<NotificationChannelDispatcher>(null!, null!);
|
||||
dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>()))
|
||||
.Callback<IEnumerable<NotificationChannel>, NotificationAlert>((c, a) => Sent.Add((c.ToList(), a)))
|
||||
var dispatcher = new Mock<NotificationChannelDispatcher>(null!, null!, null!);
|
||||
dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>(), It.IsAny<NotificationScope>()))
|
||||
.Callback<IEnumerable<NotificationChannel>, NotificationAlert, NotificationScope>((c, a, s) => { Sent.Add((c.ToList(), a)); Scopes.Add(s); })
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
Sut = new NotificationDispatchHandler(
|
||||
|
||||
@@ -108,7 +108,8 @@ public class OffboardingSessionEnumeratorTests
|
||||
public Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null, AuditLogQueryFilters? filters = null) => throw new NotImplementedException();
|
||||
public Task<RawPage<AuditLogEntry>> GetAuditLogsPageAsync(string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false, AuditLogQueryFilters? filters = null) => throw new NotImplementedException();
|
||||
public Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false, AuditLogQueryFilters? filters = null) => throw new NotImplementedException();
|
||||
public Task<int> DeleteAuditLogsOlderThanAsync(DateTime cutoffUtc) => throw new NotImplementedException();
|
||||
public Task<int> DeleteAuditLogsOlderThanAsync(DateTime cutoffUtc, DateTime legacyCutoffUtc) => throw new NotImplementedException();
|
||||
public Task<int> DeleteTenantAuditLogsOlderThanAsync(string tenantId, DateTime cutoffUtc) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate, int maxResults = int.MaxValue, bool excludeInFlightDeletions = false) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException();
|
||||
public Task<List<SessionSummary>> GetUsageWindowSessionsAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException();
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The OOBE quality update page (oobe_update_page, D-310) — pins the built-in analyze rules for
|
||||
/// the update that did not happen:
|
||||
/// - ANALYZE-DEV-013: the page reported a failed download or install and no install success
|
||||
/// (field shapes e7551b82, cb1992b8). Only the explicit failure names count: an update that
|
||||
/// installed logs downloadInstallFailureHelper and a start timeout too (e4ecd6f8).
|
||||
/// - ANALYZE-DEV-014: nothing offered to an out-of-date device whose page counts an earlier
|
||||
/// failure, without a failure in this session (b2d9f12c).
|
||||
/// - ANALYZE-DEV-015: the scan started and the page ended without a scan result (c5b20dfa).
|
||||
/// Every absence condition is vetoed by the agent's names_capped marker. Sequences follow the
|
||||
/// field sessions of 2026-10-03..05 (synthetic times, no customer data); a session never fires
|
||||
/// two of the three.
|
||||
/// </summary>
|
||||
public class OobeUpdateRuleTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
private static readonly DateTime T0 = new(2026, 10, 3, 16, 56, 59, DateTimeKind.Utc);
|
||||
|
||||
private const string DownloadFailed = "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError";
|
||||
private const string InstallFailed = "SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError";
|
||||
private const string CommitFailed = "ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure";
|
||||
|
||||
private static readonly string[] RuleIds = { "ANALYZE-DEV-013", "ANALYZE-DEV-014", "ANALYZE-DEV-015" };
|
||||
|
||||
// ── the rules ───────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("ANALYZE-DEV-013")]
|
||||
[InlineData("ANALYZE-DEV-014")]
|
||||
[InlineData("ANALYZE-DEV-015")]
|
||||
public void Rules_are_enabled_warnings_that_never_fail_the_session(string ruleId)
|
||||
{
|
||||
var rule = Rule(ruleId);
|
||||
Assert.True(rule.Enabled);
|
||||
Assert.Equal("warning", rule.Severity);
|
||||
Assert.Equal("device", rule.Category);
|
||||
Assert.False(rule.MarkSessionAsFailedDefault); // the enrollment continues without the update
|
||||
}
|
||||
|
||||
// ── ANALYZE-DEV-013: failed ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DEV_013_fires_on_a_failed_download_and_names_the_failure()
|
||||
{
|
||||
var outcome = await RunAsync(FailedDownload());
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-DEV-013", result.RuleId);
|
||||
Assert.Equal(DownloadFailed, Value(result, "update_failure")); // {{update_failure}}
|
||||
Assert.Equal("fail", Value(result, "update_page_failed"));
|
||||
Assert.Equal(85, result.ConfidenceScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DEV_013_fires_when_the_update_is_skipped_after_the_failure()
|
||||
{
|
||||
var outcome = await RunAsync(FailureThenSkip());
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-DEV-013", result.RuleId);
|
||||
Assert.Equal(DownloadFailed, Value(result, "update_failure"));
|
||||
Assert.Equal("CloudNDUPSkipDownloadInstallButtonClicked", Value(result, "update_skipped"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstallFailed)]
|
||||
[InlineData(CommitFailed)]
|
||||
public async Task DEV_013_fires_on_every_explicit_failure_name(string failureName)
|
||||
{
|
||||
var events = Visit(days: 120, previousFailures: 0);
|
||||
events.AddRange(new[] { Name("ExpeditedUpdate_getUpdateResultsSucceeded"), Name(failureName), PageStop("OobeNDUP", "fail") });
|
||||
|
||||
var result = Assert.Single((await RunAsync(events)).Results);
|
||||
Assert.Equal(failureName, Value(result, "update_failure"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task DEV_013_stays_silent_on_an_update_that_installed_despite_the_failure_helper(bool buildChangeObserved)
|
||||
{
|
||||
// Field shape e4ecd6f8: the failure helper and a start timeout after "restart required",
|
||||
// then the update installed — with or without the build change on record.
|
||||
var events = InstalledDespiteFailureHelper();
|
||||
if (!buildChangeObserved) events.RemoveAll(e => e.EventType == "os_build_changed");
|
||||
|
||||
Assert.Empty((await RunAsync(events)).Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DEV_013_stays_silent_when_a_retry_installed_the_update()
|
||||
{
|
||||
var events = FailedDownload();
|
||||
events.AddRange(new[]
|
||||
{
|
||||
PageStart("OobeNDUP"),
|
||||
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
|
||||
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired_lcu"),
|
||||
});
|
||||
|
||||
Assert.Empty((await RunAsync(events)).Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DEV_013_is_vetoed_by_an_os_build_change()
|
||||
{
|
||||
var events = FailedDownload();
|
||||
events.Add(OsBuildChanged());
|
||||
|
||||
Assert.Empty((await RunAsync(events)).Results);
|
||||
}
|
||||
|
||||
// ── ANALYZE-DEV-014: not offered after an earlier failure ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DEV_014_fires_when_nothing_is_offered_after_an_earlier_failure()
|
||||
{
|
||||
var outcome = await RunAsync(NothingOfferedAfterFailure());
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-DEV-014", result.RuleId);
|
||||
Assert.Equal("137", Value(result, "days_out_of_date")); // {{days_out_of_date}}
|
||||
Assert.Equal("1", Value(result, "previous_failures")); // {{previous_failures}}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)] // the device is current: nothing to offer
|
||||
[InlineData(137, 0)] // no earlier failure on record
|
||||
public async Task DEV_014_stays_silent_without_both_the_backlog_and_the_earlier_failure(int days, int previousFailures)
|
||||
{
|
||||
var events = Visit(days, previousFailures);
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
|
||||
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
|
||||
PageStop("OobeNDUP", "cancel"),
|
||||
});
|
||||
|
||||
Assert.Empty((await RunAsync(events)).Results);
|
||||
}
|
||||
|
||||
// ── ANALYZE-DEV-015: scan without a result ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DEV_015_fires_when_the_page_ends_without_a_scan_result()
|
||||
{
|
||||
var outcome = await RunAsync(ScanWithoutResult());
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-DEV-015", result.RuleId);
|
||||
Assert.Equal("438", Value(result, "days_out_of_date"));
|
||||
Assert.Equal("page_stopped", Value(result, "update_page_ended"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DEV_015_stays_silent_while_the_page_still_runs()
|
||||
{
|
||||
// The session ended during the scan: no page end, so no claim that the page gave up.
|
||||
var events = ScanWithoutResult();
|
||||
events.RemoveAll(e => e.EventType == "oobe_update_page" && (string)e.Data["cxhEvent"] == "page_stopped");
|
||||
|
||||
Assert.Empty((await RunAsync(events)).Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DEV_015_stays_silent_when_the_agent_missed_the_scan_start()
|
||||
{
|
||||
// A late agent sees the page end but not the scan: the missing result proves nothing.
|
||||
var events = ScanWithoutResult();
|
||||
events.RemoveAll(e => e.EventType == "oobe_update_page" && (e.Data.TryGetValue("name", out var n) ? (string)n : "").StartsWith("ExpeditedUpdate_startWUScan"));
|
||||
|
||||
Assert.Empty((await RunAsync(events)).Results);
|
||||
}
|
||||
|
||||
// ── shared vetoes and the sessions that fire nothing ────────────────────
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(FiringShapes))]
|
||||
public async Task The_names_capped_marker_vetoes_every_rule(string shape)
|
||||
{
|
||||
// Once the agent capped the page's names, a missing success or scan result proves nothing.
|
||||
var events = Shapes[shape]();
|
||||
events.Add(NamesCapped());
|
||||
|
||||
Assert.Empty((await RunAsync(events)).Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_page_the_policy_switched_off_fires_nothing()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
PageStart("OobeNDUP"),
|
||||
Name("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "false"),
|
||||
PageStop("OobeNDUP", "cancel"),
|
||||
PageStart("RebootNDUP"),
|
||||
PageStop("RebootNDUP", "success"),
|
||||
};
|
||||
|
||||
Assert.Empty((await RunAsync(events)).Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_installed_update_fires_nothing()
|
||||
{
|
||||
Assert.Empty((await RunAsync(Installed())).Results);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllShapes))]
|
||||
public async Task No_session_fires_more_than_one_of_the_three(string shape)
|
||||
{
|
||||
var outcome = await RunAsync(Shapes[shape]());
|
||||
|
||||
Assert.True(outcome.Results.Count <= 1, $"{shape}: {string.Join(", ", outcome.Results.Select(r => r.RuleId))}");
|
||||
}
|
||||
|
||||
// ── session shapes (field sessions, synthetic times) ────────────────────
|
||||
|
||||
private static readonly Dictionary<string, Func<List<EnrollmentEvent>>> Shapes = new()
|
||||
{
|
||||
["failed_download"] = FailedDownload,
|
||||
["failure_then_skip"] = FailureThenSkip,
|
||||
["installed_despite_failure_helper"] = InstalledDespiteFailureHelper,
|
||||
["nothing_offered_after_failure"] = NothingOfferedAfterFailure,
|
||||
["scan_without_result"] = ScanWithoutResult,
|
||||
["installed"] = Installed,
|
||||
};
|
||||
|
||||
public static IEnumerable<object[]> AllShapes() => Shapes.Keys.Select(k => new object[] { k });
|
||||
|
||||
public static IEnumerable<object[]> FiringShapes() =>
|
||||
new[] { "failed_download", "failure_then_skip", "nothing_offered_after_failure", "scan_without_result" }
|
||||
.Select(k => new object[] { k });
|
||||
|
||||
/// <summary>The names every visit logs before its scan.</summary>
|
||||
private static List<EnrollmentEvent> Visit(int days, int previousFailures) => new()
|
||||
{
|
||||
PageStart("OobeNDUP"),
|
||||
Name("ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", "true"),
|
||||
Name("ExpeditedUpdate_getDaysOutOfDateSucceeded", days.ToString()),
|
||||
Name("SdxWebAppCloudNDUP_initialize_NDUPInstallCanceledInOptOut", "false"),
|
||||
Name("SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount", previousFailures.ToString()),
|
||||
Name("ExpeditedUpdate_startWUScanStarted"),
|
||||
Name("ExpeditedUpdate_startWUScanStartedSuccedded"),
|
||||
};
|
||||
|
||||
/// <summary>e7551b82: only the servicing stack staged, the download failed after 25 min; the second visit found nothing to offer.</summary>
|
||||
private static List<EnrollmentEvent> FailedDownload()
|
||||
{
|
||||
var events = Visit(days: 136, previousFailures: 0);
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Name("ExpeditedUpdate_getUpdateResultsStarted"),
|
||||
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
|
||||
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI"), // a list: no safe value
|
||||
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
|
||||
Name(DownloadFailed),
|
||||
Name(CommitFailed),
|
||||
Name("SdxWebAppCloudNDUP_downloadInstallFailureHelper"),
|
||||
PageStop("OobeNDUP", "fail"),
|
||||
PageStart("RebootNDUP"),
|
||||
});
|
||||
events.AddRange(Visit(days: 137, previousFailures: 1));
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
|
||||
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
|
||||
PageStop("OobeNDUP", "cancel"),
|
||||
PageStart("RebootNDUP"),
|
||||
PageStop("RebootNDUP", "success"),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <summary>cb1992b8: visit 1 failed; visit 2 downloaded again and someone selected Skip before the install.</summary>
|
||||
private static List<EnrollmentEvent> FailureThenSkip()
|
||||
{
|
||||
var events = Visit(days: 438, previousFailures: 0);
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
|
||||
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
|
||||
Name(DownloadFailed),
|
||||
Name(CommitFailed),
|
||||
Name(InstallFailed),
|
||||
PageStop("OobeNDUP", "fail"),
|
||||
PageStart("RebootNDUP"),
|
||||
});
|
||||
events.AddRange(Visit(days: 438, previousFailures: 1));
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
|
||||
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI"),
|
||||
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
|
||||
Name("CloudNDUPOptoutLinkClicked", "0"),
|
||||
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
|
||||
Name("SdxWebAppCloudNDUP_updateUSOProgressBar_DownloadPhase_progress100"),
|
||||
Name("SdxWebAppCloudNDUP_manageProgressBars_waitingForInstallStartError"),
|
||||
Name("CloudNDUPSkipDownloadInstallButtonClicked", "0"),
|
||||
PageStop("OobeNDUP", "cancel"),
|
||||
PageStart("RebootNDUP"),
|
||||
PageStop("RebootNDUP", "success"),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <summary>e4ecd6f8: the failure helper and a start timeout after "restart required" — the update installed after three restarts.</summary>
|
||||
private static List<EnrollmentEvent> InstalledDespiteFailureHelper()
|
||||
{
|
||||
var events = Visit(days: 99, previousFailures: 0);
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
|
||||
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
|
||||
Name("SdxWebAppCloudNDUP_updateUSOProgressBar_installPhase_progress100"),
|
||||
Name("SdxWebAppCloudNDUP_downloadInstallFailureHelper"),
|
||||
Name("SdxWebAppCloudNDUP_startEventArrivalTimeout_TimerExpired"),
|
||||
PageStart("RebootNDUP"),
|
||||
OsBuildChanged(),
|
||||
PageStart("OobeNDUP"),
|
||||
PageStop("OobeNDUP", "success"),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <summary>b2d9f12c: the same device the next day — out of date, an earlier failure on record, nothing offered.</summary>
|
||||
private static List<EnrollmentEvent> NothingOfferedAfterFailure()
|
||||
{
|
||||
var events = Visit(days: 137, previousFailures: 1);
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Name("ExpeditedUpdate_getUpdateResultsStarted"),
|
||||
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
|
||||
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI", "false"),
|
||||
Name("SdxWebAppCloudNDUP_exit_appResult"),
|
||||
PageStop("OobeNDUP", "cancel"),
|
||||
PageStart("RebootNDUP"),
|
||||
PageStop("RebootNDUP", "success"),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <summary>c5b20dfa: 438 days behind; the page ended two minutes after the scan started, without a result.</summary>
|
||||
private static List<EnrollmentEvent> ScanWithoutResult()
|
||||
{
|
||||
var events = Visit(days: 438, previousFailures: 0);
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededNoReboot"), // the language check, every visit
|
||||
Name("SdxWebAppCloudNDUP_exit_appResult"),
|
||||
PageStop("OobeNDUP", "cancel"),
|
||||
PageStart("RebootNDUP"),
|
||||
PageStop("RebootNDUP", "success"),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <summary>6a7d241d: downloaded, installed, restarted.</summary>
|
||||
private static List<EnrollmentEvent> Installed()
|
||||
{
|
||||
var events = Visit(days: 120, previousFailures: 0);
|
||||
events.AddRange(new[]
|
||||
{
|
||||
Name("ExpeditedUpdate_getUpdateResultsSucceeded"),
|
||||
Name("SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI"),
|
||||
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncStarted"),
|
||||
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadSucceeded"),
|
||||
Name("SdxWebAppCloudNDUP_processStatusChangeFromHandler_installSucceededRebootRequired"),
|
||||
Name("ExpeditedUpdate_commitExpeditionDownloadInstallAsyncSucceeded"),
|
||||
Name("SdxWebAppCloudNDUP_rebootCountdown_starting"),
|
||||
PageStart("RebootNDUP"),
|
||||
OsBuildChanged(),
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
// ── event builders — mirror the agent's OobeUpdateTelemetry payload ─────
|
||||
|
||||
private static long _seq;
|
||||
|
||||
private static EnrollmentEvent Evt(string eventType, Dictionary<string, object> data)
|
||||
{
|
||||
var seq = Interlocked.Increment(ref _seq);
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = eventType,
|
||||
Timestamp = T0.AddSeconds(seq),
|
||||
Sequence = seq,
|
||||
Data = data,
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> PageData(string cxhEvent, int windowsEventId) => new()
|
||||
{
|
||||
["cxhEvent"] = cxhEvent,
|
||||
["windowsEventId"] = windowsEventId,
|
||||
["backfill"] = false,
|
||||
["occurrence"] = 1,
|
||||
};
|
||||
|
||||
private static EnrollmentEvent PageStart(string page)
|
||||
{
|
||||
var data = PageData("page_started", 62404);
|
||||
data["page"] = page;
|
||||
return Evt("oobe_update_page", data);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent PageStop(string page, string result)
|
||||
{
|
||||
var data = PageData("page_stopped", 62405);
|
||||
data["page"] = page;
|
||||
data["result"] = result;
|
||||
return Evt("oobe_update_page", data);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent Name(string name, string? value = null)
|
||||
{
|
||||
var data = PageData("event_name", value == null ? 62406 : 62407);
|
||||
data["name"] = name;
|
||||
if (value != null) data["value"] = value;
|
||||
return Evt("oobe_update_page", data);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent NamesCapped()
|
||||
{
|
||||
var data = PageData("names_capped", 0);
|
||||
data.Remove("windowsEventId");
|
||||
data.Remove("occurrence");
|
||||
data["limit"] = 150;
|
||||
return Evt("oobe_update_page", data);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent OsBuildChanged() => Evt("os_build_changed", new Dictionary<string, object>
|
||||
{
|
||||
["previousBuild"] = "26200.8037",
|
||||
["currentBuild"] = "26200.8655",
|
||||
});
|
||||
|
||||
// ── harness (as WindowsUpdateRuleTests) ─────────────────────────────────
|
||||
|
||||
private static AnalyzeRule Rule(string ruleId) => BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == ruleId);
|
||||
|
||||
private static string Value(RuleResult result, string signal)
|
||||
{
|
||||
var evidence = Assert.IsType<Dictionary<string, object>>(result.MatchedConditions[signal]);
|
||||
return evidence["value"]?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<AnalysisOutcome> RunAsync(List<EnrollmentEvent> events)
|
||||
{
|
||||
var rules = RuleIds.Select(Rule).ToList();
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(rules);
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<OobeUpdateRuleTests>.Instance);
|
||||
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
@@ -7,7 +9,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ops-event payload delivery: the structured Details object reaches outbound channels, both as
|
||||
/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers).
|
||||
/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers),
|
||||
/// and only the channels whose rule opted in get it — on the platform scope, like every ops send.
|
||||
/// Before this, an alert carried only category/event/severity and a tenant GUID.
|
||||
/// </summary>
|
||||
public class OpsAlertPayloadTests
|
||||
@@ -132,4 +135,39 @@ public class OpsAlertPayloadTests
|
||||
Assert.Contains("Domain Name: contoso.example", text);
|
||||
Assert.Contains("Pro trial started", text);
|
||||
}
|
||||
|
||||
// ── Through DispatchAsync ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_PayloadReachesOnlyTheOptedInChannel_OnThePlatformScope()
|
||||
{
|
||||
const string subjectTenant = "11111111-1111-1111-1111-111111111111";
|
||||
const string details = """{"domainName":"contoso.invalid","selfService":true}""";
|
||||
var h = new OpsNotificationChannelsTests.DispatchHarness(
|
||||
new[]
|
||||
{
|
||||
new OpsAlertRule { EventType = "TenantTrialStarted", MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List<string> { "ops" } },
|
||||
new OpsAlertRule { EventType = "TenantTrialStarted", MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List<string> { "sales" }, IncludePayload = true },
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new NotificationChannel { Id = "ops", Name = "ops", ProviderType = 20, Url = "https://ops.example.invalid/hook", Enabled = true },
|
||||
new NotificationChannel { Id = "sales", Name = "sales", ProviderType = 20, Url = "https://sales.example.invalid/hook", Enabled = true },
|
||||
});
|
||||
|
||||
await h.Sut.DispatchAsync(OpsEventCategory.Tenant, "TenantTrialStarted", OpsEventSeverity.Info,
|
||||
"Pro trial started", subjectTenant, details);
|
||||
|
||||
Assert.Equal(2, h.Sends.Count);
|
||||
var plain = h.Sends.Single(s => s.Channels.Any(c => c.Id == "ops"));
|
||||
var withPayload = h.Sends.Single(s => s.Channels.Any(c => c.Id == "sales"));
|
||||
|
||||
Assert.Null(plain.Alert.DataJson);
|
||||
Assert.DoesNotContain(plain.Alert.Facts, f => f.Name == "Domain Name");
|
||||
|
||||
Assert.Equal(details, withPayload.Alert.DataJson);
|
||||
Assert.Contains(withPayload.Alert.Facts, f => f.Name == "Domain Name" && f.Value == "contoso.invalid");
|
||||
// The with-payload send is as platform-scoped as the plain one: the subject tenant owns no ops channel.
|
||||
Assert.Equal(NotificationScope.Platform, withPayload.Scope);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Platform (ops) notification channels: legacy-slot synthesis so the pre-channels dispatch
|
||||
/// behavior survives the migration untouched, per-rule channel routing, and redaction of the
|
||||
/// behavior survives the migration untouched, per-rule channel routing, the platform dispatch
|
||||
/// scope (an ops alert's tenant is its subject, never the channel owner), and redaction of the
|
||||
/// new secret-bearing field.
|
||||
/// </summary>
|
||||
public class OpsNotificationChannelsTests
|
||||
@@ -266,4 +272,58 @@ public class OpsNotificationChannelsTests
|
||||
|
||||
Assert.Equal(candidate, NotificationChannel.RestoreRedactedList(candidate, ""));
|
||||
}
|
||||
|
||||
// ── Dispatch scope ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_BothSends_UseThePlatformScope_NeverTheEventsTenant()
|
||||
{
|
||||
// The event is ABOUT a customer tenant; a Push channel among the ops channels must still
|
||||
// reach the operators' devices only. Plain and payload sends alike.
|
||||
const string subjectTenant = "11111111-1111-1111-1111-111111111111";
|
||||
var h = new DispatchHarness(
|
||||
new[] { Rule("TenantTrialStarted", "ops"), PayloadRule("TenantTrialStarted", "sales") },
|
||||
new[] { Channel("ops"), Channel("sales") });
|
||||
|
||||
await h.Sut.DispatchAsync(OpsEventCategory.Tenant, "TenantTrialStarted", OpsEventSeverity.Info,
|
||||
"Pro trial started", subjectTenant, """{"selfService":true}""");
|
||||
|
||||
Assert.Equal(2, h.Sends.Count);
|
||||
Assert.Contains(h.Sends, s => s.Alert.DataJson == null); // the plain send
|
||||
Assert.Contains(h.Sends, s => s.Alert.DataJson != null); // the with-payload send
|
||||
Assert.All(h.Sends, s => Assert.Equal(NotificationScope.Platform, s.Scope));
|
||||
Assert.DoesNotContain(h.Sends, s => s.Scope == NotificationScope.Tenant(subjectTenant));
|
||||
// The tenant still travels — as the alert's subject, in a fact.
|
||||
Assert.All(h.Sends, s => Assert.Contains(s.Alert.Facts, f => f.Name == "Tenant" && f.Value == subjectTenant));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="OpsAlertDispatchService.DispatchAsync"/> against a recorded dispatcher: the admin
|
||||
/// configuration carries the given rules and channels, every send is captured with its scope.
|
||||
/// </summary>
|
||||
internal sealed class DispatchHarness
|
||||
{
|
||||
public List<(IReadOnlyList<NotificationChannel> Channels, NotificationAlert Alert, NotificationScope Scope)> Sends { get; } = new();
|
||||
public OpsAlertDispatchService Sut { get; }
|
||||
|
||||
public DispatchHarness(IEnumerable<OpsAlertRule> rules, IEnumerable<NotificationChannel> channels)
|
||||
{
|
||||
var config = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "test",
|
||||
OpsAlertRulesJson = JsonSerializer.Serialize(rules),
|
||||
OpsNotificationChannelsJson = NotificationChannel.SerializeList(channels),
|
||||
};
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(config);
|
||||
|
||||
var dispatcher = new Mock<NotificationChannelDispatcher>(null!, null!, null!);
|
||||
dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>(), It.IsAny<NotificationScope>()))
|
||||
.Callback<IEnumerable<NotificationChannel>, NotificationAlert, NotificationScope>((c, a, s) => Sends.Add((c.ToList(), a, s)))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
Sut = new OpsAlertDispatchService(adminConfig.Object, dispatcher.Object, NullLogger<OpsAlertDispatchService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("user@contoso.com")]
|
||||
[InlineData("admin@tenant.onmicrosoft.com")]
|
||||
[InlineData("first.last@example.de")]
|
||||
[InlineData("cadm_user@xx3t8.onmicrosoft.com")]
|
||||
[InlineData("cadm_user@contoso.onmicrosoft.com")]
|
||||
public void IsRealUserUpn_AcceptsRealUpnShapes(string upn)
|
||||
{
|
||||
Assert.True(TenantApprovalService.IsRealUserUpn(upn));
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IPushDeviceRepository"/> with the one storage semantic the push flow
|
||||
/// depends on: the grant ETag. <see cref="TryUpdateGrantAsync"/> succeeds only with the ETag the
|
||||
/// grant was read with, so the one-shot redeem race is testable.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryPushDeviceRepository : IPushDeviceRepository
|
||||
{
|
||||
public Dictionary<(string Scope, string DeviceId), PushDevice> Devices { get; } = new();
|
||||
public Dictionary<string, (PushPairingGrant Grant, int Version)> Grants { get; } = new();
|
||||
public Dictionary<(string Tenant, string Oid), PushOwner> Owners { get; } = new();
|
||||
public Dictionary<(string Tenant, string Session, string Oid), PushSessionWatch> Watches { get; } = new();
|
||||
|
||||
public Task<PushDevice?> GetDeviceAsync(string scope, string deviceId)
|
||||
=> Task.FromResult(Devices.TryGetValue((scope, deviceId), out var d) ? Clone(d) : null);
|
||||
|
||||
public Task<List<PushDevice>> GetDevicesAsync(string scope)
|
||||
=> Task.FromResult(Devices.Values.Where(d => d.Scope == scope).Select(Clone).ToList());
|
||||
|
||||
public Task AddDeviceAsync(PushDevice device)
|
||||
{
|
||||
if (Devices.ContainsKey((device.Scope, device.DeviceId)))
|
||||
throw new InvalidOperationException("duplicate device");
|
||||
Devices[(device.Scope, device.DeviceId)] = Clone(device);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> MutateDeviceAsync(string scope, string deviceId, Func<PushDevice, IReadOnlyDictionary<string, object?>?> patch)
|
||||
{
|
||||
if (!Devices.TryGetValue((scope, deviceId), out var current))
|
||||
return Task.FromResult(false);
|
||||
var changes = patch(Clone(current));
|
||||
if (changes == null || changes.Count == 0)
|
||||
return Task.FromResult(false);
|
||||
foreach (var (key, value) in changes)
|
||||
Apply(current, key, value);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task DeleteDeviceAsync(string scope, string deviceId)
|
||||
{
|
||||
Devices.Remove((scope, deviceId));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PushPairingGrant?> GetGrantAsync(string codeHash)
|
||||
{
|
||||
if (!Grants.TryGetValue(codeHash, out var entry))
|
||||
return Task.FromResult<PushPairingGrant?>(null);
|
||||
var copy = Clone(entry.Grant);
|
||||
copy.ETag = entry.Version.ToString();
|
||||
return Task.FromResult<PushPairingGrant?>(copy);
|
||||
}
|
||||
|
||||
public Task AddGrantAsync(PushPairingGrant grant)
|
||||
{
|
||||
Grants[grant.CodeHash] = (Clone(grant), 1);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Runs once before the next grant update — a concurrent writer between read and write.</summary>
|
||||
public Action? BeforeGrantUpdate { get; set; }
|
||||
|
||||
/// <summary>Bumps the stored version of a grant as a concurrent writer would.</summary>
|
||||
public void TouchGrant(string codeHash)
|
||||
{
|
||||
if (Grants.TryGetValue(codeHash, out var entry))
|
||||
Grants[codeHash] = (entry.Grant, entry.Version + 1);
|
||||
}
|
||||
|
||||
public Task<bool> TryUpdateGrantAsync(PushPairingGrant grant)
|
||||
{
|
||||
var hook = BeforeGrantUpdate;
|
||||
BeforeGrantUpdate = null;
|
||||
hook?.Invoke();
|
||||
|
||||
if (!Grants.TryGetValue(grant.CodeHash, out var entry) || entry.Version.ToString() != grant.ETag)
|
||||
return Task.FromResult(false);
|
||||
Grants[grant.CodeHash] = (Clone(grant), entry.Version + 1);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task DeleteGrantAsync(string codeHash)
|
||||
{
|
||||
Grants.Remove(codeHash);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<List<PushPairingGrant>> GetExpiredGrantsAsync(DateTime cutoffUtc)
|
||||
=> Task.FromResult(Grants.Values.Where(g => g.Grant.ExpiresUtc < cutoffUtc).Select(g => Clone(g.Grant)).ToList());
|
||||
|
||||
public Task<PushOwner?> GetOwnerAsync(string homeTenantId, string objectId)
|
||||
=> Task.FromResult(Owners.TryGetValue((homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant()), out var o) ? o : null);
|
||||
|
||||
public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc)
|
||||
{
|
||||
Owners[(homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant())] = new PushOwner
|
||||
{
|
||||
HomeTenantId = homeTenantId.ToLowerInvariant(), ObjectId = objectId.ToLowerInvariant(), Upn = upn, LastSignInUtc = signInUtc,
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpsertWatchAsync(PushSessionWatch watch)
|
||||
{
|
||||
Watches[(watch.TenantId, watch.SessionId, watch.OwnerObjectId)] = watch;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteWatchAsync(string tenantId, string sessionId, string objectId)
|
||||
{
|
||||
Watches.Remove((tenantId, sessionId, objectId));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<PushSessionWatch?> GetWatchAsync(string tenantId, string sessionId, string objectId)
|
||||
=> Task.FromResult(Watches.TryGetValue((tenantId, sessionId, objectId), out var w) ? w : null);
|
||||
|
||||
public Task<List<PushSessionWatch>> GetWatchesForSessionAsync(string tenantId, string sessionId)
|
||||
=> Task.FromResult(Watches.Values.Where(w => w.TenantId == tenantId && w.SessionId == sessionId).ToList());
|
||||
|
||||
public Task<List<PushDevice>> GetAllDevicesAsync()
|
||||
=> Task.FromResult(Devices.Values.Select(Clone).ToList());
|
||||
|
||||
public Task<List<PushSessionWatch>> GetExpiredWatchesAsync(DateTime cutoffUtc)
|
||||
=> Task.FromResult(Watches.Values.Where(w => w.ExpiresUtc < cutoffUtc).ToList());
|
||||
|
||||
private static void Apply(PushDevice d, string key, object? value)
|
||||
{
|
||||
// Mirrors Table Storage Merge semantics: a null never clears a column; the production
|
||||
// repository writes "" for a cleared StatusReason, which maps back to null.
|
||||
if (value == null)
|
||||
{
|
||||
if (key == "StatusReason")
|
||||
d.StatusReason = null;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "Status": d.Status = (string)value!; break;
|
||||
case "StatusReason": d.StatusReason = string.IsNullOrEmpty((string?)value) ? null : (string?)value; break;
|
||||
case "StatusChangedUtc": d.StatusChangedUtc = (DateTime?)value; break;
|
||||
case "ConfirmedUtc": d.ConfirmedUtc = (DateTime?)value; break;
|
||||
case "LastDeliveredUtc": d.LastDeliveredUtc = (DateTime?)value; break;
|
||||
case "LastOpenedUtc": d.LastOpenedUtc = (DateTime?)value; break;
|
||||
case "LastStatusCode": d.LastStatusCode = (int?)value; break;
|
||||
case "ConsecutiveFailures": d.ConsecutiveFailures = (int)value!; break;
|
||||
case "WindowStartUtc": d.WindowStartUtc = (DateTime?)value; break;
|
||||
case "WindowCount": d.WindowCount = (int)value!; break;
|
||||
case "SuppressedCount": d.SuppressedCount = (int)value!; break;
|
||||
case "Endpoint": d.Endpoint = (string)value!; break;
|
||||
case "P256dh": d.P256dh = (string)value!; break;
|
||||
case "Auth": d.Auth = (string)value!; break;
|
||||
case "VapidKid": d.VapidKid = (string)value!; break;
|
||||
default: throw new InvalidOperationException($"unexpected patch key {key}");
|
||||
}
|
||||
}
|
||||
|
||||
private static PushDevice Clone(PushDevice d) => (PushDevice)d.MemberwiseCloneShim();
|
||||
private static PushPairingGrant Clone(PushPairingGrant g) => (PushPairingGrant)g.MemberwiseCloneShim();
|
||||
}
|
||||
|
||||
internal static class ShallowClone
|
||||
{
|
||||
public static object MemberwiseCloneShim(this object source)
|
||||
{
|
||||
var type = source.GetType();
|
||||
var copy = Activator.CreateInstance(type)!;
|
||||
foreach (var p in type.GetProperties().Where(p => p.CanRead && p.CanWrite))
|
||||
p.SetValue(copy, p.GetValue(source));
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The lock-screen projection (K23): what a push may carry, and — pinned with sentinels — what
|
||||
/// it must never carry (Sections, DataJson, the Failure Reason fact, the ops message).
|
||||
/// </summary>
|
||||
public class PushAlertProjectorTests
|
||||
{
|
||||
private const string Sentinel = "SENTINEL-MUST-NOT-LEAK";
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant("11111111-1111-1111-1111-111111111111");
|
||||
private static readonly DateTime Now = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static NotificationAlert EnrollmentFailed() => new()
|
||||
{
|
||||
EventType = "enrollment_failed",
|
||||
Title = "🔴 Enrollment Failed",
|
||||
Summary = "Enrollment Failed: DESKTOP-4711",
|
||||
Severity = NotificationSeverity.Error,
|
||||
Facts =
|
||||
{
|
||||
new() { Name = "Device", Value = "DESKTOP-4711" },
|
||||
new() { Name = "Serial", Value = "PF3ABCDE9" },
|
||||
new() { Name = "Hardware", Value = "LENOVO ThinkPad T14" },
|
||||
new() { Name = "Duration", Value = "41m 12s" },
|
||||
new() { Name = "Failure Reason", Value = Sentinel + " from the agent event message" },
|
||||
},
|
||||
Sections = { new() { Title = "Explanation", Text = Sentinel + " interpolated log line" } },
|
||||
Actions = { new() { Type = "openUrl", Title = "Open session", Url = Constants.PortalSessionUrl("sess-1") } },
|
||||
DataJson = "{\"secret\":\"" + Sentinel + "\"}",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Tenant_projection_keeps_allow_listed_facts_and_masks_the_serial()
|
||||
{
|
||||
var payload = PushAlertProjector.Project(EnrollmentFailed(), Tenant, "e1", Now);
|
||||
|
||||
Assert.Equal("enrollment_failed", payload.Type);
|
||||
Assert.Equal("🔴 Enrollment Failed", payload.Title);
|
||||
Assert.Equal("error", payload.Severity);
|
||||
Assert.Equal(Constants.PortalSessionUrl("sess-1"), payload.PortalUrl);
|
||||
Assert.Contains(("Device", "DESKTOP-4711"), payload.Facts);
|
||||
Assert.Contains(("Serial", "…CDE9"), payload.Facts);
|
||||
Assert.DoesNotContain(payload.Facts, f => f.Name == "Failure Reason");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nothing_outside_the_allow_list_reaches_the_wire()
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(PushAlertProjector.ToJsonBytes(PushAlertProjector.Project(EnrollmentFailed(), Tenant, "e1", Now)));
|
||||
|
||||
Assert.DoesNotContain(Sentinel, json);
|
||||
Assert.DoesNotContain("PF3ABCDE9", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Consecutive_failures_alert_drops_the_last_failure_reason()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "consecutive_failures",
|
||||
Title = "🔴 5 Consecutive Enrollment Failures",
|
||||
Summary = "Alert: 5 enrollments failed in a row for tenant 11111111-1111-1111-1111-111111111111",
|
||||
Severity = NotificationSeverity.Error,
|
||||
Facts =
|
||||
{
|
||||
new() { Name = "Consecutive Failures", Value = "5" },
|
||||
new() { Name = "Last Device", Value = "DESKTOP-0042" },
|
||||
new() { Name = "Last Failure", Value = Sentinel + " script output line" },
|
||||
},
|
||||
};
|
||||
|
||||
var payload = PushAlertProjector.Project(alert, Tenant, "e5", Now);
|
||||
var json = Encoding.UTF8.GetString(PushAlertProjector.ToJsonBytes(payload));
|
||||
|
||||
Assert.Contains(("Consecutive Failures", "5"), payload.Facts);
|
||||
Assert.DoesNotContain(payload.Facts, f => f.Name == "Last Failure");
|
||||
Assert.DoesNotContain(Sentinel, json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Platform_projection_never_uses_the_ops_message()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "TenantAutoApproved",
|
||||
Title = "Ops Alert: Tenant/TenantAutoApproved",
|
||||
Summary = "signup by admin@contoso.invalid " + Sentinel,
|
||||
Severity = NotificationSeverity.Info,
|
||||
Facts =
|
||||
{
|
||||
new() { Name = "Category", Value = "Tenant" },
|
||||
new() { Name = "Event", Value = "TenantAutoApproved" },
|
||||
new() { Name = "Severity", Value = "Info" },
|
||||
new() { Name = "Contact Email", Value = "someone@contoso.invalid" },
|
||||
},
|
||||
DataJson = "{\"email\":\"someone@contoso.invalid\"}",
|
||||
};
|
||||
|
||||
var payload = PushAlertProjector.Project(alert, NotificationScope.Platform, "e2", Now);
|
||||
var json = Encoding.UTF8.GetString(PushAlertProjector.ToJsonBytes(payload));
|
||||
|
||||
Assert.Equal("Ops alert", payload.Title);
|
||||
Assert.Equal("Tenant/TenantAutoApproved · Info", payload.Body);
|
||||
Assert.DoesNotContain(Sentinel, json);
|
||||
Assert.DoesNotContain("contoso.invalid", json);
|
||||
Assert.Equal("platform", payload.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hardware_rejected_uses_the_fixed_template_not_the_distress_strings()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "hardware_rejected",
|
||||
Title = "🟡 Hardware Not Whitelisted",
|
||||
Summary = "Device rejected: " + Sentinel + " is not in your hardware whitelist",
|
||||
Severity = NotificationSeverity.Warning,
|
||||
Facts =
|
||||
{
|
||||
new() { Name = "Manufacturer", Value = Sentinel },
|
||||
new() { Name = "Model", Value = Sentinel },
|
||||
new() { Name = "Serial", Value = "ZZ998877" },
|
||||
},
|
||||
};
|
||||
|
||||
var payload = PushAlertProjector.Project(alert, Tenant, "e3", Now);
|
||||
var json = Encoding.UTF8.GetString(PushAlertProjector.ToJsonBytes(payload));
|
||||
|
||||
Assert.Equal("Hardware rejected", payload.Title);
|
||||
Assert.Equal("Serial …8877 is not in the hardware whitelist.", payload.Body);
|
||||
Assert.DoesNotContain(Sentinel, json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Json_is_declarative_web_push_with_the_history_deep_link()
|
||||
{
|
||||
var payload = PushAlertProjector.Project(EnrollmentFailed(), Tenant, "entry-9", Now);
|
||||
using var doc = JsonDocument.Parse(PushAlertProjector.ToJsonBytes(payload));
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal(8030, root.GetProperty("web_push").GetInt32());
|
||||
Assert.True(root.GetProperty("mutable").GetBoolean());
|
||||
var notification = root.GetProperty("notification");
|
||||
Assert.Equal(Constants.PortalPushHistoryUrl("entry-9"), notification.GetProperty("navigate").GetString());
|
||||
var data = notification.GetProperty("data");
|
||||
Assert.Equal(1, data.GetProperty("v").GetInt32());
|
||||
Assert.Equal("entry-9", data.GetProperty("id").GetString());
|
||||
Assert.Equal("enrollment_failed", data.GetProperty("type").GetString());
|
||||
Assert.Equal("tenant", data.GetProperty("scope").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Over_budget_payloads_drop_facts_until_they_fit()
|
||||
{
|
||||
var alert = EnrollmentFailed();
|
||||
alert.Facts.Clear();
|
||||
for (var i = 0; i < 60; i++)
|
||||
alert.Facts.Add(new NotificationFact { Name = "Device", Value = new string('x', 60) + i });
|
||||
|
||||
var bytes = PushAlertProjector.ToJsonBytes(PushAlertProjector.Project(alert, Tenant, "e4", Now));
|
||||
|
||||
Assert.True(bytes.Length <= PushAlertProjector.MaxPayloadBytes, $"payload is {bytes.Length} bytes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_strips_control_characters_and_caps_length()
|
||||
{
|
||||
Assert.Equal("a b", PushAlertProjector.Clean("a\u0000\u0007 \t b", 64));
|
||||
Assert.Equal(64, PushAlertProjector.Clean(new string('q', 200), 64).Length);
|
||||
Assert.Equal(string.Empty, PushAlertProjector.Clean(" ", 64));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Topic_is_exactly_32_base64url_characters_and_opaque()
|
||||
{
|
||||
var topic = PushAlertProjector.BuildTopic(Constants.PortalSessionUrl("sess-1"), Encoding.UTF8.GetBytes("key"));
|
||||
|
||||
Assert.NotNull(topic);
|
||||
Assert.Equal(32, topic!.Length);
|
||||
Assert.DoesNotContain("sess-1", topic);
|
||||
Assert.Null(PushAlertProjector.BuildTopic(Constants.PortalSessionUrl("sess-1"), null));
|
||||
Assert.Null(PushAlertProjector.BuildTopic(Constants.PortalBaseUrl, Encoding.UTF8.GetBytes("key")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void System_messages_never_share_a_tag_so_none_replaces_an_earlier_notification()
|
||||
{
|
||||
var scope = NotificationScope.Tenant("11111111-1111-1111-1111-111111111111");
|
||||
var now = new DateTime(2026, 10, 8, 18, 38, 40, DateTimeKind.Utc);
|
||||
var first = PushAlertProjector.SystemMessage("session_watch", "Enrollment finished", "A finished.", "success", scope, now, Constants.PortalSessionUrl("sess-1"));
|
||||
var second = PushAlertProjector.SystemMessage("session_watch", "Enrollment finished", "B finished.", "success", scope, now, Constants.PortalSessionUrl("sess-2"));
|
||||
|
||||
Assert.Equal(first.Id, first.Tag);
|
||||
Assert.Equal(second.Id, second.Tag);
|
||||
Assert.NotEqual(first.Tag, second.Tag);
|
||||
using var json = JsonDocument.Parse(PushAlertProjector.ToJsonBytes(first));
|
||||
Assert.Equal(first.Id, json.RootElement.GetProperty("notification").GetProperty("tag").GetString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Functions.Raw;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Push as a channel provider: the dispatcher's Push branch (scope passed through, never the
|
||||
/// webhook path), the validation rules and the GA gate, the raw-table deny-list and the
|
||||
/// dependency redaction.
|
||||
/// </summary>
|
||||
public class PushChannelRoutingTests
|
||||
{
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
|
||||
|
||||
private static NotificationChannel PushChannel(string id = "push") =>
|
||||
new() { Id = id, Name = id, ProviderType = (int)WebhookProviderType.Push, Url = null, Enabled = true };
|
||||
|
||||
[Fact]
|
||||
public async Task Push_channel_reaches_the_push_sender_with_the_callers_scope_and_never_the_webhook_path()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, push.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { PushChannel() }, alert, Tenant);
|
||||
|
||||
push.Verify(p => p.SendAsync(Tenant, alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Push_channel_without_url_is_not_skipped_as_destination_less()
|
||||
{
|
||||
var push = new Mock<IPushChannelSender>();
|
||||
push.Setup(p => p.SendWithResultAsync(It.IsAny<NotificationScope>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "ok" });
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, push.Object);
|
||||
|
||||
var result = await dispatcher.SendWithResultAsync(PushChannel(), new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform);
|
||||
|
||||
Assert.True(result.Success);
|
||||
push.Verify(p => p.SendWithResultAsync(NotificationScope.Platform, It.IsAny<NotificationAlert>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasDestination_is_the_one_rule()
|
||||
{
|
||||
Assert.True(PushChannel().HasDestination());
|
||||
Assert.False(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "" }.HasDestination());
|
||||
Assert.True(new NotificationChannel { Id = "s", ProviderType = (int)WebhookProviderType.Slack, Url = "https://hooks.slack.example/x" }.HasDestination());
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Push));
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Telegram));
|
||||
Assert.False(NotificationChannel.IsGlobalAdminOnlyProvider((int)WebhookProviderType.Slack));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_accepts_a_push_channel_without_url_and_refuses_one_with_a_url()
|
||||
{
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(NotificationChannel.SerializeList(new[] { PushChannel() })));
|
||||
|
||||
var withUrl = PushChannel();
|
||||
withUrl.Url = "https://attacker.invalid/hook";
|
||||
var error = TenantConfigValidation.ValidateNotificationChannels(NotificationChannel.SerializeList(new[] { withUrl }));
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("no destination", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_lets_only_a_global_admin_add_or_flip_a_push_channel()
|
||||
{
|
||||
var json = NotificationChannel.SerializeList(new[] { PushChannel() });
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: true));
|
||||
Assert.Contains("Push channels can only be configured by a Global Administrator",
|
||||
TenantConfigValidation.ValidateTelegramChannelGate(json, existingJson: null, isGlobalAdmin: false));
|
||||
|
||||
// Unchanged GA-created channel: a tenant admin may still save unrelated fields.
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
|
||||
|
||||
var disabled = PushChannel();
|
||||
disabled.Enabled = false;
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
NotificationChannel.SerializeList(new[] { disabled }), json, isGlobalAdmin: false));
|
||||
|
||||
// Renames and toggles stay with the tenant admin.
|
||||
var renamed = PushChannel();
|
||||
renamed.Name = "Phones";
|
||||
renamed.NotifyOnFailure = true;
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
NotificationChannel.SerializeList(new[] { renamed }), json, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Raw_table_surface_denies_every_credential_bearing_table()
|
||||
{
|
||||
foreach (var table in Constants.TableNames.CredentialBearing)
|
||||
{
|
||||
Assert.Contains(table, Constants.TableNames.All);
|
||||
Assert.Contains(table, TableQueryFunction._blacklistedTables);
|
||||
}
|
||||
Assert.Contains(Constants.TableNames.PushDevices, Constants.TableNames.CredentialBearing);
|
||||
Assert.Contains(Constants.TableNames.PushPairingGrants, Constants.TableNames.CredentialBearing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://web.push.apple.com/QF3abc/device-token-123", "https://web.push.apple.com")]
|
||||
[InlineData("https://fcm.googleapis.com/fcm/send/abc:APA91", "https://fcm.googleapis.com")]
|
||||
[InlineData("https://updates.push.services.mozilla.com/wpush/v2/gAAAA", "https://updates.push.services.mozilla.com")]
|
||||
[InlineData("https://wns2-db5p.notify.windows.com/w/?token=abc", "https://wns2-db5p.notify.windows.com")]
|
||||
public void Dependency_rows_for_push_services_keep_scheme_and_host_only(string endpoint, string expected)
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = endpoint, Name = "POST " + endpoint, Target = new Uri(endpoint).Host };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal(expected, dependency.Data);
|
||||
Assert.Equal("POST " + expected, dependency.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dependency_rows_for_other_hosts_are_untouched()
|
||||
{
|
||||
var dependency = new DependencyTelemetry { Data = "https://graph.microsoft.com/v1.0/devices/abc", Name = "GET /v1.0/devices/abc" };
|
||||
|
||||
PushDependencyRedactionProcessor.Redact(dependency);
|
||||
|
||||
Assert.Equal("https://graph.microsoft.com/v1.0/devices/abc", dependency.Data);
|
||||
Assert.Equal("GET /v1.0/devices/abc", dependency.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Fan-out semantics of the Push transport: only Active devices of the scope, owner role and
|
||||
/// freshness re-checked at send time, 404/410 ⇒ Stale (never deleted), the flood window with its
|
||||
/// single "muted" push, an unset scope refused, a kid outside the ring ⇒ Stale, and the
|
||||
/// role-loss revoke (wipe push, then the row is gone). Below that the maintenance sweep's pause /
|
||||
/// retention / resume rules (an owner without a sign-in row pauses, a Paused row is retained from
|
||||
/// its last sign-in or the pause itself, only an owner_inactive pause ever resumes), the
|
||||
/// PushPairing lifecycle telemetry and the per-owner system send behind the session watch.
|
||||
/// </summary>
|
||||
public class PushDeliveryServiceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantId);
|
||||
|
||||
[Fact]
|
||||
public async Task Sends_to_active_devices_only_and_records_delivery()
|
||||
{
|
||||
var h = new Harness();
|
||||
var active = h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
h.AddDevice("d2", Constants.Push.DeviceStatus.Pending);
|
||||
h.AddDevice("d3", Constants.Push.DeviceStatus.Paused);
|
||||
// Another org's Global Admin: a tenant alert never reaches a platform device unless this is the
|
||||
// owner's home tenant (D-334, pinned below).
|
||||
h.AddDevice("d4", Constants.Push.DeviceStatus.Active, scope: Constants.Push.PlatformScope)
|
||||
.OwnerHomeTenantId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
var stats = await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
Assert.Equal(1, stats.Targets);
|
||||
Assert.Equal(1, stats.Delivered);
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal(active.Endpoint, request.RequestUri!.ToString());
|
||||
Assert.True(request.Headers.Contains("TTL"));
|
||||
Assert.StartsWith("vapid t=", request.Headers.Authorization!.ToString());
|
||||
Assert.NotNull(h.Repo.Devices[(TenantId, "d1")].LastDeliveredUtc);
|
||||
Assert.Equal(1, h.Repo.Devices[(TenantId, "d1")].WindowCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Payload_on_the_wire_is_encrypted_not_plaintext()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
var body = h.Http.Bodies.Single();
|
||||
Assert.True(body.Length > 86 + 16);
|
||||
Assert.DoesNotContain("DESKTOP-4711", System.Text.Encoding.Latin1.GetString(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_without_role_is_skipped_and_stale_owner_is_paused()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active, upn: "gone@contoso.invalid");
|
||||
h.AddDevice("d2", Constants.Push.DeviceStatus.Active, upn: "inactive@contoso.invalid");
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(Tenant, "gone@contoso.invalid", It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
h.Eligibility.Setup(e => e.IsOwnerFreshAsync(TenantId, "oid-inactive@contoso.invalid", It.IsAny<DateTime>())).ReturnsAsync(false);
|
||||
|
||||
var stats = await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
Assert.Equal(0, stats.Targets);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Repo.Devices[(TenantId, "d1")].Status);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Repo.Devices[(TenantId, "d2")].Status);
|
||||
Assert.Equal("owner_inactive", h.Repo.Devices[(TenantId, "d2")].StatusReason);
|
||||
Assert.Single(h.Http.Requests); // the "paused" push to d2
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.NotFound)]
|
||||
[InlineData(HttpStatusCode.Gone)]
|
||||
public async Task Gone_endpoints_become_stale_rows_never_deleted(HttpStatusCode status)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
h.Http.Respond = _ => status;
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Stale, row.Status);
|
||||
Assert.Equal("gone", row.StatusReason);
|
||||
Assert.Equal((int)status, row.LastStatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flood_window_mutes_after_the_cap_with_one_summary_push()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
|
||||
for (var i = 0; i < PushDeliveryService.FloodWindowCap + 3; i++)
|
||||
await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(PushDeliveryService.FloodWindowCap, row.WindowCount);
|
||||
Assert.Equal(3, row.SuppressedCount);
|
||||
// cap alerts + exactly one "muted" push
|
||||
Assert.Equal(PushDeliveryService.FloodWindowCap + 1, h.Http.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unset_scope_sends_nothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
|
||||
var stats = await h.Sut.SendCoreAsync(default, Alert());
|
||||
|
||||
Assert.Equal(0, stats.Targets);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_with_unknown_kid_goes_stale_with_a_config_error()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active, kid: "ZZZZZZZZ");
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Stale, row.Status);
|
||||
Assert.Equal("vapid_key_mismatch", row.StatusReason);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
h.OpsEvents.Verify(o => o.RecordPushDeliveryFailedAsync(It.IsAny<string>(), null, "ZZZZZZZZ", "vapid_key_mismatch"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Config_error_ops_event_is_raised_once_per_hour()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
h.Http.Respond = _ => HttpStatusCode.Forbidden;
|
||||
|
||||
await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
h.OpsEvents.Verify(o => o.RecordPushDeliveryFailedAsync("apple", 403, It.IsAny<string>(), It.IsAny<string>()), Times.Once);
|
||||
Assert.Equal(2, h.Repo.Devices[(TenantId, "d1")].ConsecutiveFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_sends_the_wipe_push_then_deletes_the_owners_devices()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active, upn: "leaver@contoso.invalid");
|
||||
h.AddDevice("d2", Constants.Push.DeviceStatus.Active, upn: "stays@contoso.invalid");
|
||||
|
||||
await h.Sut.RevokeOwnerAsync(Tenant, "leaver@contoso.invalid", "member_removed");
|
||||
|
||||
Assert.Single(h.Http.Requests);
|
||||
Assert.False(h.Repo.Devices.ContainsKey((TenantId, "d1")));
|
||||
Assert.True(h.Repo.Devices.ContainsKey((TenantId, "d2")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_result_reports_delivery_counts()
|
||||
{
|
||||
var h = new Harness();
|
||||
var result = await h.Sut.SendWithResultAsync(Tenant, Alert());
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("No active paired device", result.Message);
|
||||
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
result = await h.Sut.SendWithResultAsync(Tenant, Alert());
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("1 of 1", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maintenance_sweeps_grants_pending_stale_and_inactive_devices()
|
||||
{
|
||||
var h = new Harness();
|
||||
var now = DateTime.UtcNow;
|
||||
h.AddDevice("pending-old", Constants.Push.DeviceStatus.Pending, pairedUtc: now.AddMinutes(-30));
|
||||
h.AddDevice("stale-old", Constants.Push.DeviceStatus.Stale, statusChangedUtc: now.AddDays(-20));
|
||||
h.AddDevice("stale-new", Constants.Push.DeviceStatus.Stale, statusChangedUtc: now.AddDays(-2));
|
||||
h.AddDevice("active-inactive-owner", Constants.Push.DeviceStatus.Active, upn: "quiet@contoso.invalid");
|
||||
h.AddDevice("paused-forgotten", Constants.Push.DeviceStatus.Paused, upn: "forgotten@contoso.invalid", statusChangedUtc: now.AddDays(-100));
|
||||
h.Repo.Owners[(TenantId, "oid-quiet@contoso.invalid")] = new PushOwner { HomeTenantId = TenantId, ObjectId = "oid-quiet@contoso.invalid", LastSignInUtc = now.AddDays(-45) };
|
||||
h.Repo.Owners[(TenantId, "oid-forgotten@contoso.invalid")] = new PushOwner { HomeTenantId = TenantId, ObjectId = "oid-forgotten@contoso.invalid", LastSignInUtc = now.AddDays(-120) };
|
||||
h.Repo.Grants["expired"] = (new PushPairingGrant { CodeHash = "expired", TenantId = TenantId, Status = Constants.Push.PairingStatus.Pending, ExpiresUtc = now.AddMinutes(-1) }, 1);
|
||||
h.Repo.Grants["fresh"] = (new PushPairingGrant { CodeHash = "fresh", TenantId = TenantId, Status = Constants.Push.PairingStatus.Pending, ExpiresUtc = now.AddMinutes(5) }, 1);
|
||||
|
||||
var maintenance = new PushMaintenanceService(h.Repo, h.Settings, h.Sut, NullLogger<PushMaintenanceService>.Instance);
|
||||
var result = await maintenance.RunAsync(now);
|
||||
|
||||
Assert.Equal(1, result.GrantsDeleted);
|
||||
Assert.True(h.Repo.Grants.ContainsKey("fresh"));
|
||||
Assert.False(h.Repo.Devices.ContainsKey((TenantId, "pending-old")));
|
||||
Assert.False(h.Repo.Devices.ContainsKey((TenantId, "stale-old")));
|
||||
Assert.True(h.Repo.Devices.ContainsKey((TenantId, "stale-new")));
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, h.Repo.Devices[(TenantId, "active-inactive-owner")].Status);
|
||||
Assert.False(h.Repo.Devices.ContainsKey((TenantId, "paused-forgotten")));
|
||||
}
|
||||
|
||||
// ── Maintenance: pause, retention, resume ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Maintenance_pauses_an_active_device_whose_owner_has_no_sign_in_row()
|
||||
{
|
||||
var h = new Harness();
|
||||
var now = DateTime.UtcNow;
|
||||
var device = h.AddDevice("d1", Constants.Push.DeviceStatus.Active, upn: "unknown@contoso.invalid");
|
||||
|
||||
var result = await h.Maintenance.RunAsync(now);
|
||||
|
||||
Assert.Equal(1, result.DevicesPaused);
|
||||
Assert.Equal(0, result.InactiveDevicesDeleted);
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, row.Status);
|
||||
Assert.Equal("owner_inactive", row.StatusReason);
|
||||
Assert.Equal<DateTime?>(now, row.StatusChangedUtc);
|
||||
var request = Assert.Single(h.Http.Requests); // the "paused" push, nothing else
|
||||
Assert.Equal(device.Endpoint, request.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paused_device_without_an_owner_row_is_deleted_once_the_pause_is_older_than_the_retention()
|
||||
{
|
||||
var h = new Harness();
|
||||
var now = DateTime.UtcNow;
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Paused, upn: "unknown@contoso.invalid", statusChangedUtc: now.AddDays(-100), statusReason: "owner_inactive");
|
||||
|
||||
var result = await h.Maintenance.RunAsync(now);
|
||||
|
||||
Assert.Equal(1, result.InactiveDevicesDeleted);
|
||||
Assert.False(h.Repo.Devices.ContainsKey((TenantId, "d1")));
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paused_device_without_an_owner_row_is_kept_inside_the_retention_window()
|
||||
{
|
||||
var h = new Harness();
|
||||
var now = DateTime.UtcNow;
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Paused, upn: "unknown@contoso.invalid", statusChangedUtc: now.AddDays(-10), statusReason: "owner_inactive");
|
||||
|
||||
var result = await h.Maintenance.RunAsync(now);
|
||||
|
||||
Assert.Equal(0, result.InactiveDevicesDeleted);
|
||||
Assert.Equal(0, result.DevicesResumed); // no sign-in row ⇒ nothing to resume from
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, row.Status);
|
||||
Assert.Equal("owner_inactive", row.StatusReason);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paused_device_without_a_status_change_anchors_its_retention_on_the_pairing()
|
||||
{
|
||||
var h = new Harness();
|
||||
var now = DateTime.UtcNow;
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Paused, upn: "unknown@contoso.invalid", pairedUtc: now.AddDays(-100));
|
||||
|
||||
var result = await h.Maintenance.RunAsync(now);
|
||||
|
||||
Assert.Equal(1, result.InactiveDevicesDeleted);
|
||||
Assert.False(h.Repo.Devices.ContainsKey((TenantId, "d1")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maintenance_resumes_an_owner_inactive_pause_silently_once_the_owner_signed_in_again()
|
||||
{
|
||||
var h = new Harness();
|
||||
var now = DateTime.UtcNow;
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Paused, upn: "back@contoso.invalid", statusChangedUtc: now.AddDays(-40), statusReason: "owner_inactive");
|
||||
h.SignIn("back@contoso.invalid", now.AddDays(-1));
|
||||
|
||||
var result = await h.Maintenance.RunAsync(now);
|
||||
|
||||
Assert.Equal(1, result.DevicesResumed);
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, row.Status);
|
||||
Assert.Null(row.StatusReason);
|
||||
Assert.Equal<DateTime?>(now, row.StatusChangedUtc);
|
||||
Assert.Empty(h.Http.Requests); // resuming sends nothing
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maintenance_never_re_arms_a_delivery_failures_pause()
|
||||
{
|
||||
var h = new Harness();
|
||||
var now = DateTime.UtcNow;
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Paused, upn: "fresh@contoso.invalid", statusChangedUtc: now.AddDays(-5), statusReason: "delivery_failures");
|
||||
h.SignIn("fresh@contoso.invalid", now.AddDays(-1));
|
||||
|
||||
var result = await h.Maintenance.RunAsync(now);
|
||||
|
||||
Assert.Equal(0, result.DevicesResumed);
|
||||
Assert.Equal(0, result.InactiveDevicesDeleted);
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, row.Status);
|
||||
Assert.Equal("delivery_failures", row.StatusReason);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
|
||||
// ── Pause / resume transitions ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PauseAsync_still_sends_the_push_but_leaves_a_stale_row_stale()
|
||||
{
|
||||
var h = new Harness();
|
||||
var now = DateTime.UtcNow;
|
||||
var stale = h.AddDevice("d1", Constants.Push.DeviceStatus.Stale, statusChangedUtc: now.AddDays(-1), statusReason: "gone");
|
||||
|
||||
await h.Sut.PauseAsync(stale, "owner_inactive", Tenant, now);
|
||||
|
||||
Assert.Single(h.Http.Requests); // the pause push attempt
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Stale, row.Status);
|
||||
Assert.Equal("gone", row.StatusReason);
|
||||
Assert.Equal<DateTime?>(now.AddDays(-1), row.StatusChangedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeAsync_on_an_active_row_changes_nothing()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
var h = new Harness(Telemetry(channel));
|
||||
var now = DateTime.UtcNow;
|
||||
var active = h.AddDevice("d1", Constants.Push.DeviceStatus.Active, statusChangedUtc: now.AddDays(-3));
|
||||
|
||||
await h.Sut.ResumeAsync(active, now);
|
||||
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, row.Status);
|
||||
Assert.Null(row.StatusReason);
|
||||
Assert.Equal<DateTime?>(now.AddDays(-3), row.StatusChangedUtc);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Empty(PairingEvents(channel)); // nothing landed ⇒ nothing tracked
|
||||
}
|
||||
|
||||
// ── Telemetry: the PushPairing lifecycle event ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PauseAsync_tracks_a_paused_pairing_event()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
var h = new Harness(Telemetry(channel));
|
||||
var device = h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
|
||||
await h.Sut.PauseAsync(device, "owner_inactive", Tenant, DateTime.UtcNow);
|
||||
|
||||
var e = Assert.Single(PairingEvents(channel));
|
||||
Assert.Equal("paused", e.Properties["action"]);
|
||||
Assert.Equal("owner_inactive", e.Properties["reason"]);
|
||||
Assert.Equal("tenant", e.Properties["scope"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumeAsync_tracks_a_resumed_pairing_event_naming_the_lifted_reason()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
var h = new Harness(Telemetry(channel));
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Paused, statusReason: "owner_inactive");
|
||||
var device = (await h.Repo.GetDeviceAsync(TenantId, "d1"))!; // the row as maintenance reads it
|
||||
|
||||
await h.Sut.ResumeAsync(device, DateTime.UtcNow);
|
||||
|
||||
var e = Assert.Single(PairingEvents(channel));
|
||||
Assert.Equal("resumed", e.Properties["action"]);
|
||||
Assert.Equal("owner_inactive", e.Properties["reason"]);
|
||||
Assert.Equal("tenant", e.Properties["scope"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeDeviceAsync_tracks_a_revoked_pairing_event_with_the_given_reason()
|
||||
{
|
||||
var channel = new CapturingChannel();
|
||||
var h = new Harness(Telemetry(channel));
|
||||
var device = h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
|
||||
await h.Sut.RevokeDeviceAsync(device, "member_removed");
|
||||
|
||||
var e = Assert.Single(PairingEvents(channel));
|
||||
Assert.Equal("revoked", e.Properties["action"]);
|
||||
Assert.Equal("member_removed", e.Properties["reason"]);
|
||||
Assert.Equal("tenant", e.Properties["scope"]);
|
||||
Assert.False(h.Repo.Devices.ContainsKey((TenantId, "d1")));
|
||||
}
|
||||
|
||||
// ── SendSystemToOwnerAsync: the session-watch fan-out ────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task System_push_to_an_owner_reaches_only_that_owners_active_devices_in_the_scope()
|
||||
{
|
||||
var h = new Harness();
|
||||
var d1 = h.AddDevice("d1", Constants.Push.DeviceStatus.Active, upn: "watcher@contoso.invalid");
|
||||
var d2 = h.AddDevice("d2", Constants.Push.DeviceStatus.Active, upn: "watcher@contoso.invalid");
|
||||
h.AddDevice("d3", Constants.Push.DeviceStatus.Active, upn: "other@contoso.invalid");
|
||||
h.AddDevice("d4", Constants.Push.DeviceStatus.Paused, upn: "watcher@contoso.invalid", statusReason: "owner_inactive");
|
||||
h.AddDevice("d5", Constants.Push.DeviceStatus.Active, upn: "watcher@contoso.invalid", scope: Constants.Push.PlatformScope);
|
||||
|
||||
var delivered = await h.Sut.SendSystemToOwnerAsync(Tenant, "oid-watcher@contoso.invalid", SessionFinished());
|
||||
|
||||
Assert.Equal(2, delivered);
|
||||
Assert.Equal(2, h.Http.Requests.Count);
|
||||
Assert.Contains(h.Http.Requests, r => r.RequestUri!.ToString() == d1.Endpoint);
|
||||
Assert.Contains(h.Http.Requests, r => r.RequestUri!.ToString() == d2.Endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task System_push_to_an_owner_refuses_an_unset_scope()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
|
||||
var delivered = await h.Sut.SendSystemToOwnerAsync(default, "oid-admin@contoso.invalid", SessionFinished());
|
||||
|
||||
Assert.Equal(0, delivered);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task System_push_to_an_owner_who_lost_the_role_sends_nothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active, upn: "gone@contoso.invalid");
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(Tenant, "gone@contoso.invalid", It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
|
||||
var delivered = await h.Sut.SendSystemToOwnerAsync(Tenant, "oid-gone@contoso.invalid", SessionFinished());
|
||||
|
||||
Assert.Equal(0, delivered);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Repo.Devices[(TenantId, "d1")].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task System_push_to_a_stale_owner_pauses_the_device_instead_of_delivering()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active, upn: "inactive@contoso.invalid");
|
||||
h.Eligibility.Setup(e => e.IsOwnerFreshAsync(TenantId, "oid-inactive@contoso.invalid", It.IsAny<DateTime>())).ReturnsAsync(false);
|
||||
|
||||
var delivered = await h.Sut.SendSystemToOwnerAsync(Tenant, "oid-inactive@contoso.invalid", SessionFinished());
|
||||
|
||||
Assert.Equal(0, delivered);
|
||||
Assert.Single(h.Http.Requests); // the "paused" push, never the session message
|
||||
var row = h.Repo.Devices[(TenantId, "d1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, row.Status);
|
||||
Assert.Equal("owner_inactive", row.StatusReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task System_push_to_an_owner_carries_the_requested_ttl()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
|
||||
await h.Sut.SendSystemToOwnerAsync(Tenant, "oid-admin@contoso.invalid", SessionFinished(), TimeSpan.FromMinutes(7));
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("420", Assert.Single(request.Headers.GetValues("TTL")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task System_push_to_an_owner_defaults_to_the_thirty_minute_ttl()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
|
||||
await h.Sut.SendSystemToOwnerAsync(Tenant, "oid-admin@contoso.invalid", SessionFinished());
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("1800", Assert.Single(request.Headers.GetValues("TTL")));
|
||||
}
|
||||
|
||||
private static PushPayload SessionFinished()
|
||||
=> PushAlertProjector.SystemMessage("session_watch", "Session finished", "DESKTOP-4711 finished enrollment.", "success", Tenant, DateTime.UtcNow);
|
||||
|
||||
private 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
|
||||
{
|
||||
public List<ITelemetry> Items { get; } = new();
|
||||
public bool? DeveloperMode { get; set; }
|
||||
public string EndpointAddress { get; set; } = "";
|
||||
public void Send(ITelemetry item) => Items.Add(item);
|
||||
public void Flush() { }
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_alert_also_reaches_platform_devices_whose_owners_home_tenant_it_is()
|
||||
{
|
||||
var h = new Harness();
|
||||
var own = h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var ga = h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope);
|
||||
h.AddDevice("ga-paused", Constants.Push.DeviceStatus.Paused, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope);
|
||||
// Another Global Admin whose home tenant is a different one: never reached by this tenant.
|
||||
h.AddDevice("other-ga", Constants.Push.DeviceStatus.Active, upn: "other@contoso.invalid", scope: Constants.Push.PlatformScope)
|
||||
.OwnerHomeTenantId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
var stats = await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
Assert.Equal(2, stats.Targets);
|
||||
Assert.Equal(2, stats.Delivered);
|
||||
Assert.Equal(
|
||||
new[] { own.Endpoint, ga.Endpoint }.OrderBy(x => x),
|
||||
h.Http.Requests.Select(r => r.RequestUri!.ToString()).OrderBy(x => x));
|
||||
// The tenant row passes the tenant rule, the platform row the platform rule.
|
||||
h.Eligibility.Verify(e => e.IsEligibleAsync(Tenant, "admin@contoso.invalid", It.IsAny<string>(), TenantId), Times.Once);
|
||||
h.Eligibility.Verify(e => e.IsEligibleAsync(NotificationScope.Platform, "ga@contoso.invalid", It.IsAny<string>(), TenantId), Times.Once);
|
||||
h.Eligibility.Verify(e => e.IsEligibleAsync(Tenant, "ga@contoso.invalid", It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Former_global_admins_platform_device_gets_nothing_from_its_home_tenant_and_a_platform_alert_never_pulls_tenant_rows()
|
||||
{
|
||||
var h = new Harness();
|
||||
var tenantRow = h.AddDevice("d1", Constants.Push.DeviceStatus.Active);
|
||||
var ga = h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(NotificationScope.Platform, "ga@contoso.invalid", It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
|
||||
var tenantStats = await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
Assert.Equal(1, tenantStats.Targets);
|
||||
Assert.Equal(tenantRow.Endpoint, Assert.Single(h.Http.Requests).RequestUri!.ToString());
|
||||
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(NotificationScope.Platform, "ga@contoso.invalid", It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
var platformStats = await h.Sut.SendCoreAsync(NotificationScope.Platform, Alert());
|
||||
Assert.Equal(1, platformStats.Targets);
|
||||
Assert.Equal(2, h.Http.Requests.Count);
|
||||
Assert.Equal(ga.Endpoint, h.Http.Requests[1].RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Same_endpoint_in_both_scopes_is_sent_to_once()
|
||||
{
|
||||
var h = new Harness();
|
||||
var tenantRow = h.AddDevice("d1", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid");
|
||||
h.AddDevice("ga", Constants.Push.DeviceStatus.Active, upn: "ga@contoso.invalid", scope: Constants.Push.PlatformScope)
|
||||
.Endpoint = tenantRow.Endpoint;
|
||||
|
||||
var stats = await h.Sut.SendCoreAsync(Tenant, Alert());
|
||||
|
||||
Assert.Equal(1, stats.Targets);
|
||||
Assert.Single(h.Http.Requests);
|
||||
}
|
||||
|
||||
private static NotificationAlert Alert() => new()
|
||||
{
|
||||
EventType = "enrollment_failed",
|
||||
Title = "Enrollment Failed",
|
||||
Summary = "Enrollment Failed: DESKTOP-4711",
|
||||
Severity = NotificationSeverity.Error,
|
||||
Facts = { new() { Name = "Device", Value = "DESKTOP-4711" } },
|
||||
};
|
||||
|
||||
internal sealed class Harness
|
||||
{
|
||||
public InMemoryPushDeviceRepository Repo { get; } = new();
|
||||
public VapidKeyRing Keys { get; } = new(VapidKey.Generate());
|
||||
public PushSettings Settings { get; }
|
||||
public Mock<PushEligibility> Eligibility { get; }
|
||||
public Mock<OpsEventService> OpsEvents { get; }
|
||||
public PushPairingServiceTests.RecordingHandler Http { get; } = new();
|
||||
public PushDeliveryService Sut { get; }
|
||||
public PushMaintenanceService Maintenance { get; }
|
||||
private readonly (string P256dh, string Auth) _keys;
|
||||
|
||||
/// <param name="telemetry">Optional: a client over a capturing channel to observe what the service tracks.</param>
|
||||
public Harness(TelemetryClient? telemetry = null)
|
||||
{
|
||||
Settings = PushSettings.ForKeys(Keys);
|
||||
Eligibility = new Mock<PushEligibility>(null!, null!, null!, null!, null!) { CallBase = false };
|
||||
Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
Eligibility.Setup(e => e.IsOwnerFreshAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>())).ReturnsAsync(true);
|
||||
OpsEvents = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
OpsEvents.Setup(o => o.RecordPushDeliveryFailedAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
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);
|
||||
|
||||
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
|
||||
var p = ua.ExportParameters(false);
|
||||
var point = new byte[65];
|
||||
point[0] = 0x04;
|
||||
p.Q.X!.CopyTo(point, 1 + (32 - p.Q.X!.Length));
|
||||
p.Q.Y!.CopyTo(point, 33 + (32 - p.Q.Y!.Length));
|
||||
_keys = (System.Buffers.Text.Base64Url.EncodeToString(point),
|
||||
System.Buffers.Text.Base64Url.EncodeToString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)));
|
||||
}
|
||||
|
||||
/// <summary>The owner's PushOwners stamp, keyed the way AddDevice derives the object id from the UPN.</summary>
|
||||
public void SignIn(string upn, DateTime lastSignInUtc)
|
||||
=> Repo.Owners[(TenantId, "oid-" + upn)] = new PushOwner { HomeTenantId = TenantId, ObjectId = "oid-" + upn, Upn = upn, LastSignInUtc = lastSignInUtc };
|
||||
|
||||
public PushDevice AddDevice(string id, string status, string upn = "admin@contoso.invalid", string scope = TenantId,
|
||||
string? kid = null, DateTime? pairedUtc = null, DateTime? statusChangedUtc = null, string? statusReason = null)
|
||||
{
|
||||
var device = new PushDevice
|
||||
{
|
||||
Scope = scope,
|
||||
DeviceId = id,
|
||||
OwnerUpn = upn,
|
||||
OwnerObjectId = "oid-" + upn,
|
||||
OwnerHomeTenantId = TenantId,
|
||||
Endpoint = $"https://web.push.apple.com/QF/{id}",
|
||||
P256dh = _keys.P256dh,
|
||||
Auth = _keys.Auth,
|
||||
DeviceSecretHash = "hash",
|
||||
VapidKid = kid ?? Keys.Active.Kid,
|
||||
Label = id,
|
||||
Platform = "ios-homescreen",
|
||||
Status = status,
|
||||
StatusReason = statusReason,
|
||||
PairedUtc = pairedUtc ?? DateTime.UtcNow,
|
||||
StatusChangedUtc = statusChangedUtc,
|
||||
};
|
||||
Repo.Devices[(scope, id)] = device;
|
||||
return device;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The real <see cref="PushEligibility"/> over mocked role and configuration reads: the channel
|
||||
/// switch per scope (K4), the table-backed role predicate for tenant members (Admin/Operator,
|
||||
/// never Viewer) and for Global Admins (GlobalAdmin with a matching identity binding, never a
|
||||
/// Global Reader), and the owner-freshness window (K3).
|
||||
/// </summary>
|
||||
public class PushEligibilityTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string HomeTenantId = "33333333-3333-3333-3333-333333333333";
|
||||
private const string OtherTenantId = "44444444-4444-4444-4444-444444444444";
|
||||
private const string Upn = "admin@contoso.invalid";
|
||||
private const string ObjectId = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private const string OtherObjectId = "aaaaaaaa-0000-0000-0000-000000000002";
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantId);
|
||||
private static readonly DateTime Now = new(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── HasPushChannelAsync ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Unset_scope_has_no_push_channel()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.OpsChannels(Channel(WebhookProviderType.Push));
|
||||
h.TenantChannels(exists: true, Channel(WebhookProviderType.Push));
|
||||
|
||||
Assert.False(await h.Sut.HasPushChannelAsync(default));
|
||||
h.AdminConfig.Verify(a => a.GetConfigurationAsync(), Times.Never);
|
||||
h.TenantConfig.Verify(t => t.TryGetConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_scope_has_a_push_channel_with_an_enabled_push_ops_channel()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.OpsChannels(Channel(WebhookProviderType.Slack), Channel(WebhookProviderType.Push));
|
||||
|
||||
Assert.True(await h.Sut.HasPushChannelAsync(NotificationScope.Platform));
|
||||
h.TenantConfig.Verify(t => t.TryGetConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_scope_ignores_a_disabled_push_ops_channel()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.OpsChannels(Channel(WebhookProviderType.Push, enabled: false));
|
||||
|
||||
Assert.False(await h.Sut.HasPushChannelAsync(NotificationScope.Platform));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_scope_with_only_webhook_ops_channels_has_no_push_channel()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.OpsChannels(Channel(WebhookProviderType.Slack));
|
||||
|
||||
Assert.False(await h.Sut.HasPushChannelAsync(NotificationScope.Platform));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_scope_without_any_ops_channel_has_no_push_channel()
|
||||
{
|
||||
var h = new Harness(); // default admin configuration: no channel list, no legacy slots
|
||||
|
||||
Assert.False(await h.Sut.HasPushChannelAsync(NotificationScope.Platform));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_scope_without_a_stored_configuration_has_no_push_channel()
|
||||
{
|
||||
var h = new Harness();
|
||||
// The default row handed back for a missing tenant never counts, whatever it carries.
|
||||
h.TenantChannels(exists: false, Channel(WebhookProviderType.Push));
|
||||
|
||||
Assert.False(await h.Sut.HasPushChannelAsync(Tenant));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_scope_has_a_push_channel_with_an_enabled_tenant_push_channel()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.TenantChannels(exists: true, Channel(WebhookProviderType.Slack), Channel(WebhookProviderType.Push));
|
||||
|
||||
Assert.True(await h.Sut.HasPushChannelAsync(Tenant));
|
||||
h.AdminConfig.Verify(a => a.GetConfigurationAsync(), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_scope_ignores_a_disabled_tenant_push_channel()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.TenantChannels(exists: true, Channel(WebhookProviderType.Push, enabled: false));
|
||||
|
||||
Assert.False(await h.Sut.HasPushChannelAsync(Tenant));
|
||||
}
|
||||
|
||||
// ── IsEligibleAsync — tenant scope ───────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Admin)]
|
||||
[InlineData(Constants.TenantRoles.Operator)]
|
||||
public async Task Enabled_tenant_member_with_an_alerting_role_is_eligible(string role)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.TenantMember(role);
|
||||
|
||||
Assert.True(await h.Sut.IsEligibleAsync(Tenant, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_viewer_is_not_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.TenantMember(Constants.TenantRoles.Viewer);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Tenant, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_member_without_a_role_counts_as_admin()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.TenantMember(role: string.Empty); // legacy row written before roles existed
|
||||
|
||||
Assert.True(await h.Sut.IsEligibleAsync(Tenant, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_tenant_member_is_not_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.TenantMember(Constants.TenantRoles.Admin, enabled: false);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Tenant, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_tenant_member_is_not_eligible()
|
||||
{
|
||||
var h = new Harness(); // no TenantAdmins row for the upn
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Tenant, Upn, ObjectId, HomeTenantId));
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(TenantId, Upn), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_lookup_lower_cases_the_upn()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.TenantMember(Constants.TenantRoles.Admin); // row keyed on the lower-case upn
|
||||
|
||||
Assert.True(await h.Sut.IsEligibleAsync(Tenant, "Admin@Contoso.INVALID", ObjectId, HomeTenantId));
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(TenantId, Upn), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Application_principal_is_never_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
var appKey = Constants.PrincipalKeys.ForApplication("22222222-2222-2222-2222-222222222222");
|
||||
h.TenantMember(Constants.TenantRoles.Admin, upn: appKey); // even with an Admin row
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Tenant, appKey, ObjectId, HomeTenantId));
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unset_scope_is_never_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.TenantMember(Constants.TenantRoles.Admin);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(default, Upn, ObjectId, HomeTenantId));
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Blank_upn_is_never_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(Tenant, " ", ObjectId, HomeTenantId));
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── IsEligibleAsync — platform scope ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_with_matching_binding_is_eligible_for_platform_scope()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.GlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
h.Binding(HomeTenantId, ObjectId);
|
||||
|
||||
Assert.True(await h.Sut.IsEligibleAsync(NotificationScope.Platform, Upn, ObjectId, HomeTenantId));
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_with_unpinned_binding_is_eligible_for_platform_scope()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.GlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
h.Binding(HomeTenantId, objectId: string.Empty); // object id pinned on first sign-in
|
||||
|
||||
Assert.True(await h.Sut.IsEligibleAsync(NotificationScope.Platform, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_binding_with_another_object_id_is_not_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.GlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
h.Binding(HomeTenantId, OtherObjectId);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(NotificationScope.Platform, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_binding_homed_in_another_tenant_is_not_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.GlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
h.Binding(OtherTenantId, ObjectId);
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(NotificationScope.Platform, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_without_binding_is_not_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.GlobalRole(Constants.GlobalRoles.GlobalAdmin); // no AdminIdentityBindings row
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(NotificationScope.Platform, Upn, ObjectId, HomeTenantId));
|
||||
h.AdminRepo.Verify(r => r.GetIdentityBindingAsync(Upn), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_reader_is_not_eligible_for_platform_scope()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.GlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
h.Binding(HomeTenantId, ObjectId); // a perfect binding does not help
|
||||
|
||||
Assert.False(await h.Sut.IsEligibleAsync(NotificationScope.Platform, Upn, ObjectId, HomeTenantId));
|
||||
h.AdminRepo.Verify(r => r.GetIdentityBindingAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_lookup_lower_cases_the_upn()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.GlobalRole(Constants.GlobalRoles.GlobalAdmin); // rows keyed on the lower-case upn
|
||||
h.Binding(HomeTenantId, ObjectId);
|
||||
|
||||
Assert.True(await h.Sut.IsEligibleAsync(NotificationScope.Platform, "Admin@Contoso.INVALID", ObjectId, HomeTenantId));
|
||||
h.AdminRepo.Verify(r => r.GetGlobalRoleAsync(Upn), Times.Once);
|
||||
h.AdminRepo.Verify(r => r.GetIdentityBindingAsync(Upn), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_binding_comparison_ignores_case()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.GlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
h.Binding(HomeTenantId.ToUpperInvariant(), ObjectId.ToUpperInvariant());
|
||||
|
||||
Assert.True(await h.Sut.IsEligibleAsync(NotificationScope.Platform, Upn, ObjectId, HomeTenantId));
|
||||
}
|
||||
|
||||
// ── IsOwnerFreshAsync ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_without_a_sign_in_stamp_is_not_fresh()
|
||||
{
|
||||
var h = new Harness(); // no PushOwners row: "never signed in"
|
||||
|
||||
Assert.False(await h.Sut.IsOwnerFreshAsync(HomeTenantId, ObjectId, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_who_signed_in_a_day_ago_is_fresh()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now.AddDays(-1));
|
||||
|
||||
Assert.True(await h.Sut.IsOwnerFreshAsync(HomeTenantId, ObjectId, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_beyond_the_inactivity_window_is_not_fresh()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now.AddDays(-(h.Settings.OwnerInactivityDays + 1)));
|
||||
|
||||
Assert.False(await h.Sut.IsOwnerFreshAsync(HomeTenantId, ObjectId, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_exactly_at_the_inactivity_boundary_is_fresh()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now.AddDays(-h.Settings.OwnerInactivityDays)); // inclusive window (>=)
|
||||
|
||||
Assert.True(await h.Sut.IsOwnerFreshAsync(HomeTenantId, ObjectId, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_one_tick_past_the_inactivity_boundary_is_not_fresh()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now.AddDays(-h.Settings.OwnerInactivityDays).AddTicks(-1));
|
||||
|
||||
Assert.False(await h.Sut.IsOwnerFreshAsync(HomeTenantId, ObjectId, Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", ObjectId)]
|
||||
[InlineData(HomeTenantId, "")]
|
||||
public async Task Blank_owner_identity_is_never_fresh(string homeTenantId, string objectId)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Owner(Now); // a fresh stamp exists for the full identity
|
||||
|
||||
Assert.False(await h.Sut.IsOwnerFreshAsync(homeTenantId, objectId, Now));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static NotificationChannel Channel(WebhookProviderType provider, bool enabled = true) => new()
|
||||
{
|
||||
Id = provider.ToString().ToLowerInvariant() + "-channel",
|
||||
Name = provider.ToString(),
|
||||
ProviderType = (int)provider,
|
||||
// A Push channel has no destination of its own; every webhook provider needs one to parse as configured.
|
||||
Url = provider == WebhookProviderType.Push ? null : "https://hooks.example.invalid/endpoint",
|
||||
Enabled = enabled,
|
||||
};
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<IAdminRepository> AdminRepo { get; } = new();
|
||||
public InMemoryPushDeviceRepository Repo { get; } = new();
|
||||
public Mock<TenantConfigurationService> TenantConfig { get; }
|
||||
public Mock<AdminConfigurationService> AdminConfig { get; }
|
||||
public PushSettings Settings { get; }
|
||||
public PushEligibility Sut { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Settings = PushSettings.ForKeys(new VapidKeyRing(VapidKey.Generate()));
|
||||
|
||||
TenantConfig = new Mock<TenantConfigurationService>(null!, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()), null!) { CallBase = false };
|
||||
|
||||
AdminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
// A platform without any ops channel until a test says otherwise.
|
||||
AdminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
|
||||
|
||||
Sut = new PushEligibility(AdminRepo.Object, Repo, TenantConfig.Object, AdminConfig.Object, Settings);
|
||||
}
|
||||
|
||||
public void OpsChannels(params NotificationChannel[] channels)
|
||||
=> AdminConfig.Setup(a => a.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { OpsNotificationChannelsJson = NotificationChannel.SerializeList(channels) });
|
||||
|
||||
public void TenantChannels(bool exists, params NotificationChannel[] channels)
|
||||
=> TenantConfig.Setup(t => t.TryGetConfigurationAsync(TenantId))
|
||||
.ReturnsAsync((new TenantConfiguration { TenantId = TenantId, NotificationChannelsJson = NotificationChannel.SerializeList(channels) }, exists));
|
||||
|
||||
public void TenantMember(string role, bool enabled = true, string upn = Upn)
|
||||
=> AdminRepo.Setup(r => r.GetTenantMemberAsync(TenantId, upn))
|
||||
.ReturnsAsync(new TenantMember { TenantId = TenantId, Upn = upn, Role = role, IsEnabled = enabled });
|
||||
|
||||
public void GlobalRole(string role)
|
||||
=> AdminRepo.Setup(r => r.GetGlobalRoleAsync(Upn)).ReturnsAsync(role);
|
||||
|
||||
public void Binding(string tenantId, string objectId)
|
||||
=> AdminRepo.Setup(r => r.GetIdentityBindingAsync(Upn))
|
||||
.ReturnsAsync(new AdminIdentityBinding { Upn = Upn, TenantId = tenantId, ObjectId = objectId });
|
||||
|
||||
public void Owner(DateTime lastSignInUtc)
|
||||
=> Repo.Owners[(HomeTenantId, ObjectId)] = new PushOwner
|
||||
{
|
||||
HomeTenantId = HomeTenantId, ObjectId = ObjectId, Upn = Upn, LastSignInUtc = lastSignInUtc,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The pairing flow end to end against the in-memory repository and a fake push service:
|
||||
/// create → begin → redeem (Pending) → confirm (Active, confirmation push), the one-shot redeem,
|
||||
/// the five-failure burn, expiry, the device token, re-subscribe re-arming a Stale row, the
|
||||
/// role/channel gates, grant ownership (a grant answers only to its creator) and the
|
||||
/// one-per-host-and-hour ops event for a refused subscription endpoint.
|
||||
/// </summary>
|
||||
public class PushPairingServiceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantId);
|
||||
private static readonly PushCaller Admin = new("admin@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000001", TenantId, IsScopeAdmin: true);
|
||||
private static readonly PushCaller Operator = new("ops@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000002", TenantId, IsScopeAdmin: false);
|
||||
/// <summary>Same scope, same admin role, different object id — not the grant's creator.</summary>
|
||||
private static readonly PushCaller OtherAdmin = new("other@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000003", TenantId, IsScopeAdmin: true);
|
||||
|
||||
[Fact]
|
||||
public async Task Full_flow_creates_a_pending_device_and_confirmation_activates_it_with_a_push()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
var created = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
Assert.True(created.Ok);
|
||||
Assert.Equal(Constants.Push.CodeLength, created.Value!.Code.Length);
|
||||
Assert.Equal(Constants.PortalPushPairUrl(created.Value.Code), created.Value.Url);
|
||||
Assert.Single(h.Repo.Owners); // pairing stamps the owner's sign-in
|
||||
|
||||
var begin = await h.Sut.BeginAsync(created.Value.Code.ToLowerInvariant().Replace("0", "O"));
|
||||
Assert.True(begin.Ok);
|
||||
Assert.Equal(h.Keys.Active.Kid, begin.Value!.Kid);
|
||||
Assert.Equal(h.Keys.Active.PublicKeyBase64Url, begin.Value.VapidPublicKey);
|
||||
|
||||
var redeemed = await h.Sut.RedeemAsync(h.RedeemRequest(created.Value.Code, label: " My iPhone \u0007 "));
|
||||
Assert.True(redeemed.Ok);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Pending, redeemed.Value!.Status);
|
||||
Assert.Equal(3, redeemed.Value.DeviceToken.Split('.').Length);
|
||||
|
||||
var status = await h.Sut.GetStatusAsync(Tenant, created.Value.PairingId, Admin);
|
||||
Assert.Equal(Constants.Push.PairingStatus.Redeemed, status.Value!.Status);
|
||||
Assert.Equal("My iPhone", status.Value.Device!.Label);
|
||||
Assert.Equal("ios-homescreen", status.Value.Device.Platform);
|
||||
|
||||
// Pending devices are invisible to the list and receive nothing.
|
||||
Assert.Empty((await h.Sut.ListAsync(Tenant, Admin)).Devices);
|
||||
|
||||
var confirmed = await h.Sut.ConfirmAsync(Tenant, created.Value.PairingId, Admin);
|
||||
Assert.True(confirmed.Ok);
|
||||
var device = h.Repo.Devices.Values.Single();
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, device.Status);
|
||||
Assert.NotNull(device.ConfirmedUtc);
|
||||
Assert.Equal(h.Keys.Active.Kid, device.VapidKid);
|
||||
Assert.Single(h.Http.Requests); // the "paired" push
|
||||
Assert.Equal(Constants.Push.PairingStatus.Confirmed, (await h.Sut.GetStatusAsync(Tenant, created.Value.PairingId, Admin)).Value!.Status);
|
||||
|
||||
var listed = Assert.Single((await h.Sut.ListAsync(Tenant, Admin)).Devices);
|
||||
Assert.True(listed.IsOwn);
|
||||
Assert.Equal("My iPhone", listed.Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_code_redeems_exactly_once()
|
||||
{
|
||||
var h = new Harness();
|
||||
var created = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
|
||||
Assert.True((await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code))).Ok);
|
||||
var second = await h.Sut.RedeemAsync(h.RedeemRequest(created.Value.Code));
|
||||
|
||||
Assert.Equal(PushOpError.CodeInvalid, second.Error);
|
||||
Assert.Single(h.Repo.Devices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_redeem_loser_gets_CodeUsed_and_leaves_no_device()
|
||||
{
|
||||
var h = new Harness();
|
||||
var created = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
var codeHash = PushPairingService.HashCode(created.Value!.Code);
|
||||
|
||||
// The grant row moves on between this redeem's read and its one-shot write (K9).
|
||||
h.Repo.BeforeGrantUpdate = () => h.Repo.TouchGrant(codeHash);
|
||||
|
||||
var result = await h.Sut.RedeemAsync(h.RedeemRequest(created.Value.Code));
|
||||
|
||||
Assert.Equal(PushOpError.CodeUsed, result.Error);
|
||||
Assert.Empty(h.Repo.Devices); // the device created before the CAS is rolled back
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_expired_and_burnt_codes_share_one_answer()
|
||||
{
|
||||
var h = new Harness();
|
||||
Assert.Equal(PushOpError.CodeInvalid, (await h.Sut.BeginAsync("ZZZZZZZZZZZ")).Error);
|
||||
Assert.Equal(PushOpError.CodeInvalid, (await h.Sut.BeginAsync("not a code at all")).Error);
|
||||
|
||||
var created = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
var codeHash = PushPairingService.HashCode(created.Value!.Code);
|
||||
var entry = h.Repo.Grants[codeHash];
|
||||
entry.Grant.ExpiresUtc = DateTime.UtcNow.AddMinutes(-1);
|
||||
h.Repo.Grants[codeHash] = entry;
|
||||
|
||||
Assert.Equal(PushOpError.CodeInvalid, (await h.Sut.BeginAsync(created.Value.Code)).Error);
|
||||
Assert.Equal(Constants.Push.PairingStatus.Expired, (await h.Sut.GetStatusAsync(Tenant, created.Value.PairingId, Admin)).Value!.Status);
|
||||
|
||||
// Five wrong-state hits burn the grant row.
|
||||
for (var i = 0; i < Constants.Push.MaxRedeemFailures; i++)
|
||||
await h.Sut.BeginAsync(created.Value.Code);
|
||||
Assert.False(h.Repo.Grants.ContainsKey(codeHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_token_resolves_only_with_the_right_secret()
|
||||
{
|
||||
var h = new Harness();
|
||||
var created = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
var redeemed = await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code));
|
||||
var token = redeemed.Value!.DeviceToken;
|
||||
|
||||
Assert.NotNull(await h.Sut.ResolveDeviceAsync(token));
|
||||
Assert.Null(await h.Sut.ResolveDeviceAsync(token[..^2] + "AA"));
|
||||
Assert.Null(await h.Sut.ResolveDeviceAsync("platform." + token.Split('.')[1] + "." + token.Split('.')[2]));
|
||||
Assert.Null(await h.Sut.ResolveDeviceAsync("garbage"));
|
||||
Assert.Null(await h.Sut.ResolveDeviceAsync(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resubscribe_re_arms_a_stale_device_and_refuses_unknown_endpoints()
|
||||
{
|
||||
var h = new Harness();
|
||||
var created = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
var redeemed = await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code));
|
||||
await h.Sut.ConfirmAsync(Tenant, created.Value.PairingId, Admin);
|
||||
var device = (await h.Sut.ResolveDeviceAsync(redeemed.Value!.DeviceToken))!;
|
||||
await h.Repo.MutateDeviceAsync(device.Scope, device.DeviceId, _ => new Dictionary<string, object?> { ["Status"] = Constants.Push.DeviceStatus.Stale, ["StatusReason"] = "gone" });
|
||||
|
||||
var refused = await h.Sut.ResubscribeAsync(device, new ResubscribeRequest
|
||||
{
|
||||
Endpoint = "https://attacker.invalid/push", P256dh = h.Subscription.P256dh, Auth = h.Subscription.Auth, Kid = h.Keys.Active.Kid,
|
||||
});
|
||||
Assert.Equal(PushOpError.InvalidSubscription, refused.Error);
|
||||
h.OpsEvents.Verify(o => o.RecordPushEndpointRefusedAsync("attacker.invalid", "tenant"), Times.Once);
|
||||
|
||||
var ok = await h.Sut.ResubscribeAsync(device, new ResubscribeRequest
|
||||
{
|
||||
Endpoint = "https://web.push.apple.com/QF/new-token", P256dh = h.Subscription.P256dh, Auth = h.Subscription.Auth, Kid = h.Keys.Active.Kid,
|
||||
});
|
||||
Assert.True(ok.Ok);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, ok.Value!.Status);
|
||||
Assert.Equal("https://web.push.apple.com/QF/new-token", h.Repo.Devices.Values.Single().Endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rotated_key_is_a_re_registration_not_a_re_pair()
|
||||
{
|
||||
var h = new Harness();
|
||||
var created = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
var redeemed = await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code));
|
||||
await h.Sut.ConfirmAsync(Tenant, created.Value.PairingId, Admin);
|
||||
var token = redeemed.Value!.DeviceToken;
|
||||
var device = (await h.Sut.ResolveDeviceAsync(token))!;
|
||||
|
||||
// Before the rotation the status names the key the device subscribed with as the active one.
|
||||
var before = await h.Sut.GetDeviceStatusAsync(device);
|
||||
Assert.Equal(h.Keys.Active.Kid, before.Kid);
|
||||
Assert.Equal(h.Keys.Active.Kid, before.ActiveKid);
|
||||
Assert.Equal(h.Keys.Active.PublicKeyBase64Url, before.ActiveVapidPublicKey);
|
||||
|
||||
// Rotation: a new active key, the old one retired — the same device rows under the new ring.
|
||||
var rotated = new Harness(new VapidKeyRing(VapidKey.Generate(), new[] { h.Keys.Active }), h.Repo);
|
||||
var status = await rotated.Sut.GetDeviceStatusAsync(device);
|
||||
Assert.Equal(h.Keys.Active.Kid, status.Kid);
|
||||
Assert.Equal(rotated.Keys.Active.Kid, status.ActiveKid);
|
||||
Assert.Equal(rotated.Keys.Active.PublicKeyBase64Url, status.ActiveVapidPublicKey);
|
||||
Assert.NotEqual(status.Kid, status.ActiveKid);
|
||||
|
||||
// The receiver subscribes again with the new key and PUTs the new kid: the row moves over, no re-pair.
|
||||
var moved = await rotated.Sut.ResubscribeAsync(device, new ResubscribeRequest
|
||||
{
|
||||
Endpoint = "https://web.push.apple.com/QF/rotated-token", P256dh = h.Subscription.P256dh, Auth = h.Subscription.Auth, Kid = rotated.Keys.Active.Kid,
|
||||
});
|
||||
Assert.True(moved.Ok);
|
||||
Assert.Equal(rotated.Keys.Active.Kid, moved.Value!.Kid);
|
||||
Assert.Equal(rotated.Keys.Active.Kid, h.Repo.Devices.Values.Single().VapidKid);
|
||||
Assert.Equal(token.Split('.')[2], token.Split('.')[2]); // the device token is untouched by the migration
|
||||
|
||||
// Once the old key left the ring, a row still on it is Stale (vapid_key_mismatch): the same PUT re-arms it,
|
||||
// while a PUT that still names the retired kid is refused.
|
||||
var withoutOld = new Harness(new VapidKeyRing(rotated.Keys.Active), h.Repo);
|
||||
await h.Repo.MutateDeviceAsync(device.Scope, device.DeviceId, _ => new Dictionary<string, object?>
|
||||
{
|
||||
["VapidKid"] = h.Keys.Active.Kid, ["Status"] = Constants.Push.DeviceStatus.Stale, ["StatusReason"] = "vapid_key_mismatch",
|
||||
});
|
||||
var staleRow = (await withoutOld.Sut.ResolveDeviceAsync(token))!;
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Stale, staleRow.Status);
|
||||
|
||||
var refused = await withoutOld.Sut.ResubscribeAsync(staleRow, new ResubscribeRequest
|
||||
{
|
||||
Endpoint = "https://web.push.apple.com/QF/rotated-token", P256dh = h.Subscription.P256dh, Auth = h.Subscription.Auth, Kid = h.Keys.Active.Kid,
|
||||
});
|
||||
Assert.Equal(PushOpError.InvalidSubscription, refused.Error);
|
||||
|
||||
var reArmed = await withoutOld.Sut.ResubscribeAsync(staleRow, new ResubscribeRequest
|
||||
{
|
||||
Endpoint = "https://web.push.apple.com/QF/rotated-token-2", P256dh = h.Subscription.P256dh, Auth = h.Subscription.Auth, Kid = withoutOld.Keys.Active.Kid,
|
||||
});
|
||||
Assert.True(reArmed.Ok);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, reArmed.Value!.Status);
|
||||
Assert.Equal(withoutOld.Keys.Active.Kid, h.Repo.Devices.Values.Single().VapidKid);
|
||||
Assert.Null(h.Repo.Devices.Values.Single().StatusReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Operators_see_only_their_own_devices_and_cannot_remove_others()
|
||||
{
|
||||
var h = new Harness();
|
||||
var adminPairing = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(adminPairing.Value!.Code));
|
||||
await h.Sut.ConfirmAsync(Tenant, adminPairing.Value.PairingId, Admin);
|
||||
|
||||
var opsPairing = await h.Sut.CreateAsync(Tenant, Operator);
|
||||
await h.Sut.RedeemAsync(h.RedeemRequest(opsPairing.Value!.Code, label: "ops phone"));
|
||||
await h.Sut.ConfirmAsync(Tenant, opsPairing.Value.PairingId, Operator);
|
||||
|
||||
Assert.Equal(2, (await h.Sut.ListAsync(Tenant, Admin)).Devices.Count);
|
||||
var opsView = Assert.Single((await h.Sut.ListAsync(Tenant, Operator)).Devices);
|
||||
Assert.Equal("ops phone", opsView.Label);
|
||||
|
||||
var adminDeviceId = h.Repo.Devices.Values.Single(d => d.OwnerUpn == Admin.Upn).DeviceId;
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.DeleteAsync(Tenant, adminDeviceId, Operator));
|
||||
Assert.Equal(PushOpError.None, await h.Sut.DeleteAsync(Tenant, adminDeviceId, Admin));
|
||||
Assert.Single(h.Repo.Devices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_grant_answers_only_to_the_caller_who_created_it()
|
||||
{
|
||||
var h = new Harness();
|
||||
var created = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
Assert.True((await h.Sut.RedeemAsync(h.RedeemRequest(created.Value!.Code))).Ok);
|
||||
var pairingId = created.Value.PairingId;
|
||||
|
||||
// Same scope, even a scope admin — but another object id: for them the grant does not exist.
|
||||
Assert.Equal(PushOpError.NotFound, (await h.Sut.GetStatusAsync(Tenant, pairingId, OtherAdmin)).Error);
|
||||
Assert.Equal(PushOpError.NotFound, (await h.Sut.ConfirmAsync(Tenant, pairingId, OtherAdmin)).Error);
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.RejectAsync(Tenant, pairingId, OtherAdmin));
|
||||
|
||||
// Nothing moved: the grant still waits for its owner, the Pending device is still there, no push went out.
|
||||
Assert.Equal(Constants.Push.PairingStatus.Redeemed, h.Repo.Grants[pairingId].Grant.Status);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Pending, Assert.Single(h.Repo.Devices.Values).Status);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Equal(Constants.Push.PairingStatus.Redeemed, (await h.Sut.GetStatusAsync(Tenant, pairingId, Admin)).Value!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refused_endpoints_raise_one_ops_event_per_host_scope_and_hour()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
// Two fresh grants, two different paths on the same refused host: one ops event.
|
||||
var first = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
var second = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
Assert.Equal(PushOpError.InvalidSubscription,
|
||||
(await h.Sut.RedeemAsync(h.RedeemRequest(first.Value!.Code, endpoint: "https://push.example.invalid/sub/1"))).Error);
|
||||
Assert.Equal(PushOpError.InvalidSubscription,
|
||||
(await h.Sut.RedeemAsync(h.RedeemRequest(second.Value!.Code, endpoint: "https://push.example.invalid/sub/2"))).Error);
|
||||
h.OpsEvents.Verify(o => o.RecordPushEndpointRefusedAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
|
||||
|
||||
// A different refused host is a different cause: a second event.
|
||||
var third = await h.Sut.CreateAsync(Tenant, Admin);
|
||||
Assert.Equal(PushOpError.InvalidSubscription,
|
||||
(await h.Sut.RedeemAsync(h.RedeemRequest(third.Value!.Code, endpoint: "https://relay.example.invalid/sub/3"))).Error);
|
||||
h.OpsEvents.Verify(o => o.RecordPushEndpointRefusedAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Exactly(2));
|
||||
|
||||
// The hostname is the only endpoint-derived value that leaves the service (the endpoint is a credential).
|
||||
h.OpsEvents.Verify(o => o.RecordPushEndpointRefusedAsync("push.example.invalid", "tenant"), Times.Once);
|
||||
h.OpsEvents.Verify(o => o.RecordPushEndpointRefusedAsync("relay.example.invalid", "tenant"), Times.Once);
|
||||
Assert.Empty(h.Repo.Devices); // a refused subscription never becomes a device
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pairing_needs_a_push_channel_and_an_eligible_role()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(Tenant)).ReturnsAsync(false);
|
||||
Assert.Equal(PushOpError.ChannelRequired, (await h.Sut.CreateAsync(Tenant, Admin)).Error);
|
||||
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(Tenant)).ReturnsAsync(true);
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(Tenant, Admin.Upn, Admin.ObjectId, Admin.HomeTenantId)).ReturnsAsync(false);
|
||||
Assert.Equal(PushOpError.NotEligible, (await h.Sut.CreateAsync(Tenant, Admin)).Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Codes_normalise_typed_variants_and_refuse_everything_else()
|
||||
{
|
||||
Assert.Equal("0123456789A", PushPairingService.NormalizeCode("o123-4567 89a"));
|
||||
Assert.Equal("11111111111", PushPairingService.NormalizeCode("ilILiIlIL1l"));
|
||||
Assert.Null(PushPairingService.NormalizeCode("0123456789")); // too short
|
||||
Assert.Null(PushPairingService.NormalizeCode("0123456789AB")); // too long
|
||||
Assert.Null(PushPairingService.NormalizeCode("0123456789U")); // U is not in the alphabet
|
||||
Assert.Null(PushPairingService.NormalizeCode(null));
|
||||
|
||||
var code = PushPairingService.GenerateCode();
|
||||
Assert.Equal(Constants.Push.CodeLength, code.Length);
|
||||
Assert.All(code, ch => Assert.Contains(ch, Constants.Push.CodeAlphabet));
|
||||
Assert.DoesNotContain("error", code, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ── Harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed class Harness
|
||||
{
|
||||
public InMemoryPushDeviceRepository Repo { get; }
|
||||
public VapidKeyRing Keys { get; }
|
||||
public Mock<PushEligibility> Eligibility { get; }
|
||||
public Mock<OpsEventService> OpsEvents { get; }
|
||||
public RecordingHandler Http { get; } = new();
|
||||
public PushPairingService Sut { get; }
|
||||
public PushDeliveryService Delivery { get; }
|
||||
public (string Endpoint, string P256dh, string Auth) Subscription { get; }
|
||||
|
||||
/// <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)
|
||||
{
|
||||
Keys = keys ?? new VapidKeyRing(VapidKey.Generate());
|
||||
Repo = repo ?? new InMemoryPushDeviceRepository();
|
||||
var settings = PushSettings.ForKeys(Keys);
|
||||
Eligibility = new Mock<PushEligibility>(null!, null!, null!, null!, null!) { CallBase = false };
|
||||
Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(true);
|
||||
Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
Eligibility.Setup(e => e.IsOwnerFreshAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>())).ReturnsAsync(true);
|
||||
|
||||
OpsEvents = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
OpsEvents.Setup(o => o.RecordPushEndpointRefusedAsync(It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
OpsEvents.Setup(o => o.RecordPushDeliveryFailedAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
var sender = new WebPushSender(new HttpClient(Http) { BaseAddress = null });
|
||||
Delivery = new PushDeliveryService(Repo, Eligibility.Object, settings, sender,
|
||||
new Lazy<OpsEventService>(() => OpsEvents.Object), new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<PushDeliveryService>.Instance);
|
||||
|
||||
var tenantConfig = new Mock<TenantConfigurationService>(null!, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()), null!) { CallBase = false };
|
||||
tenantConfig.Setup(t => t.TryGetConfigurationAsync(It.IsAny<string>()))
|
||||
.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);
|
||||
|
||||
// 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);
|
||||
var p = ua.ExportParameters(false);
|
||||
var point = new byte[65];
|
||||
point[0] = 0x04;
|
||||
p.Q.X!.CopyTo(point, 1 + (32 - p.Q.X!.Length));
|
||||
p.Q.Y!.CopyTo(point, 33 + (32 - p.Q.Y!.Length));
|
||||
Subscription = (
|
||||
"https://web.push.apple.com/QF3abc/device-token",
|
||||
System.Buffers.Text.Base64Url.EncodeToString(point),
|
||||
System.Buffers.Text.Base64Url.EncodeToString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)));
|
||||
}
|
||||
|
||||
public RedeemPairRequest RedeemRequest(string code, string label = "", string platform = "ios-homescreen", string? endpoint = null) => new()
|
||||
{
|
||||
Code = code,
|
||||
Kid = Keys.Active.Kid,
|
||||
Endpoint = endpoint ?? Subscription.Endpoint,
|
||||
P256dh = Subscription.P256dh,
|
||||
Auth = Subscription.Auth,
|
||||
Label = label,
|
||||
Platform = platform,
|
||||
AppVersion = "web-1",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Records every request (and its body, read before the sender disposes it) and answers with a configurable status (201 by default).</summary>
|
||||
[Fact]
|
||||
public async Task Home_tenant_list_shows_a_global_admins_platform_device_which_the_tenant_routes_refuse()
|
||||
{
|
||||
var h = new Harness();
|
||||
var ga = new PushCaller("ga@contoso.invalid", "aaaaaaaa-0000-0000-0000-00000000000a", TenantId, IsScopeAdmin: true, IsGlobalAdmin: true);
|
||||
h.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")] = PlatformDevice("ga-phone", ga, TenantId, h.Keys.Active.Kid);
|
||||
h.Repo.Devices[(Constants.Push.PlatformScope, "other-phone")] = PlatformDevice("other-phone",
|
||||
new PushCaller("other@contoso.invalid", "aaaaaaaa-0000-0000-0000-00000000000b", "22222222-2222-2222-2222-222222222222", IsScopeAdmin: true, IsGlobalAdmin: true),
|
||||
"22222222-2222-2222-2222-222222222222", h.Keys.Active.Kid);
|
||||
|
||||
// Scope admins see it (as somebody else's device), the owner as their own, an operator not at all.
|
||||
var adminView = Assert.Single((await h.Sut.ListAsync(Tenant, Admin)).Devices);
|
||||
Assert.Equal("ga-phone", adminView.DeviceId);
|
||||
Assert.Equal("platform", adminView.Scope);
|
||||
Assert.False(adminView.IsOwn);
|
||||
Assert.True(Assert.Single((await h.Sut.ListAsync(Tenant, ga)).Devices).IsOwn);
|
||||
Assert.Empty((await h.Sut.ListAsync(Tenant, Operator)).Devices);
|
||||
// The platform list itself is untouched by the rule.
|
||||
Assert.All((await h.Sut.ListAsync(NotificationScope.Platform, ga)).Devices, d => Assert.Equal("platform", d.Scope));
|
||||
|
||||
// Managed under the platform's device list: the tenant routes do not know the row.
|
||||
Assert.Equal(PushOpError.NotFound, await h.Sut.DeleteAsync(Tenant, "ga-phone", Admin));
|
||||
Assert.False((await h.Sut.TestAsync(Tenant, "ga-phone", Admin)).Ok);
|
||||
Assert.Equal(2, h.Repo.Devices.Count);
|
||||
}
|
||||
|
||||
private static PushDevice PlatformDevice(string id, PushCaller owner, string homeTenantId, string kid) => new()
|
||||
{
|
||||
Scope = Constants.Push.PlatformScope,
|
||||
DeviceId = id,
|
||||
OwnerUpn = owner.Upn,
|
||||
OwnerObjectId = owner.ObjectId,
|
||||
OwnerHomeTenantId = homeTenantId,
|
||||
Endpoint = $"https://web.push.apple.com/QF/{id}",
|
||||
P256dh = "p",
|
||||
Auth = "a",
|
||||
DeviceSecretHash = "hash",
|
||||
VapidKid = kid,
|
||||
Label = id,
|
||||
Platform = "ios-homescreen",
|
||||
Status = Constants.Push.DeviceStatus.Active,
|
||||
PairedUtc = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
internal sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
public List<HttpRequestMessage> Requests { get; } = new();
|
||||
public List<byte[]> Bodies { get; } = new();
|
||||
public Func<HttpRequestMessage, HttpStatusCode> Respond { get; set; } = _ => HttpStatusCode.Created;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
Bodies.Add(request.Content == null ? Array.Empty<byte>() : await request.Content.ReadAsByteArrayAsync(cancellationToken));
|
||||
return new HttpResponseMessage(Respond(request)) { Content = new StringContent(string.Empty) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Push;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The receiver's own routes over HTTP. On <c>push/device</c> the token in
|
||||
/// <see cref="Constants.Push.DeviceTokenHeader"/> is the whole authority: 401 before anything else
|
||||
/// (the body included), then the per-device throttle, then the route. Re-subscribe re-arms a Stale
|
||||
/// row and never echoes the refused host; unpair deletes the row and the token dies with it. The
|
||||
/// anonymous <c>push/pair/begin</c> gives one 404 for an unusable code and is throttled per client IP
|
||||
/// (the trusted X-Forwarded-For hop, never a client-written prefix).
|
||||
/// </summary>
|
||||
public class PushReceiverFunctionsTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string PhoneIp = "198.51.100.7";
|
||||
private const string OtherPhoneIp = "198.51.100.8";
|
||||
private const string UnknownCode = "ZZZZZZZZZZZ";
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantId);
|
||||
private static readonly PushCaller Admin = new("admin@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000001", TenantId, IsScopeAdmin: true);
|
||||
|
||||
// ── GET push/device ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Get_without_the_device_token_header_is_401_InvalidDeviceToken()
|
||||
{
|
||||
var h = new Harness();
|
||||
await h.PairAsync();
|
||||
|
||||
var response = await h.Sut.Get(Harness.Request());
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
var envelope = await ReadAsync<ApiErrorResponse>(response);
|
||||
Assert.Equal(Constants.ApiErrorCodes.InvalidDeviceToken, envelope.Code);
|
||||
Assert.Equal("Unknown device token.", envelope.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("garbage")]
|
||||
[InlineData(TenantId + ".nosuchdevice.AAAA")]
|
||||
public async Task Get_with_a_token_that_names_no_device_is_401(string token)
|
||||
{
|
||||
var h = new Harness();
|
||||
await h.PairAsync();
|
||||
|
||||
var response = await h.Sut.Get(Harness.Request(token));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.InvalidDeviceToken, EndpointHarness.ErrorCode(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_with_the_right_device_but_a_wrong_secret_is_401()
|
||||
{
|
||||
var h = new Harness();
|
||||
var (token, _) = await h.PairAsync();
|
||||
var parts = token.Split('.');
|
||||
var forged = $"{parts[0]}.{parts[1]}.{new string('A', 43)}"; // same scope and device, a secret of 32 zero bytes
|
||||
|
||||
var response = await h.Sut.Get(Harness.Request(forged));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.InvalidDeviceToken, EndpointHarness.ErrorCode(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_with_the_device_token_returns_the_status_and_stamps_LastOpenedUtc()
|
||||
{
|
||||
var h = new Harness();
|
||||
var (token, row) = await h.PairAsync();
|
||||
Assert.Null(row.LastOpenedUtc);
|
||||
|
||||
var response = await h.Sut.Get(Harness.Request(token));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var status = await ReadAsync<PushDeviceStatusResponse>(response);
|
||||
Assert.Equal(row.DeviceId, status.DeviceId);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, status.Status);
|
||||
Assert.NotNull(row.LastOpenedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_status_body_never_carries_the_subscription()
|
||||
{
|
||||
var h = new Harness();
|
||||
var (token, row) = await h.PairAsync();
|
||||
|
||||
var response = await h.Sut.Get(Harness.Request(token));
|
||||
|
||||
var body = await BodyTextAsync(response);
|
||||
Assert.DoesNotContain(row.Endpoint, body);
|
||||
Assert.DoesNotContain(row.Auth, body);
|
||||
}
|
||||
|
||||
// ── Per-device throttle (after the token matched) ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Device_routes_allow_ten_calls_a_minute_then_429_with_Retry_After()
|
||||
{
|
||||
var h = new Harness();
|
||||
var (token, _) = await h.PairAsync();
|
||||
for (var i = 0; i < 10; i++)
|
||||
Assert.Equal(HttpStatusCode.OK, (await h.Sut.Get(Harness.Request(token))).StatusCode);
|
||||
|
||||
var throttled = await h.Sut.Get(Harness.Request(token));
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, throttled.StatusCode);
|
||||
var envelope = await ReadAsync<ApiErrorResponse>(throttled);
|
||||
Assert.Equal(Constants.ApiErrorCodes.RateLimited, envelope.Code);
|
||||
var retryAfter = int.Parse(Assert.Single(throttled.Headers.GetValues("Retry-After")));
|
||||
Assert.InRange(retryAfter, 1, 60);
|
||||
Assert.Equal(retryAfter, envelope.RetryAfterSeconds ?? -1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_throttle_is_keyed_per_device()
|
||||
{
|
||||
var h = new Harness();
|
||||
var (first, _) = await h.PairAsync();
|
||||
var (second, _) = await h.PairAsync();
|
||||
for (var i = 0; i <= 10; i++)
|
||||
await h.Sut.Get(Harness.Request(first)); // the 11th is the first device's 429
|
||||
|
||||
var response = await h.Sut.Get(Harness.Request(second));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_throttle_spans_the_three_device_routes_and_a_throttled_unpair_deletes_nothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
var (token, _) = await h.PairAsync();
|
||||
for (var i = 0; i < 10; i++)
|
||||
await h.Sut.Get(Harness.Request(token));
|
||||
|
||||
var response = await h.Sut.Unpair(Harness.Request(token));
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
|
||||
Assert.Single(h.Repo.Devices);
|
||||
}
|
||||
|
||||
// ── PUT push/device ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Put_re_arms_a_stale_device_with_the_new_subscription()
|
||||
{
|
||||
var h = new Harness();
|
||||
var (token, row) = await h.PairAsync();
|
||||
row.Status = Constants.Push.DeviceStatus.Stale;
|
||||
row.StatusReason = "gone";
|
||||
const string newEndpoint = "https://web.push.apple.com/QF/new-token";
|
||||
|
||||
var response = await h.Sut.Resubscribe(Harness.Request(token, h.ResubscribeBody(newEndpoint)));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var status = await ReadAsync<PushDeviceStatusResponse>(response);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, status.Status);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, row.Status);
|
||||
Assert.Null(row.StatusReason);
|
||||
Assert.Equal(newEndpoint, row.Endpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_with_an_unknown_push_host_is_400_InvalidSubscription_and_leaves_the_row_alone()
|
||||
{
|
||||
var h = new Harness();
|
||||
var (token, row) = await h.PairAsync();
|
||||
var endpointBefore = row.Endpoint;
|
||||
|
||||
var response = await h.Sut.Resubscribe(Harness.Request(token, h.ResubscribeBody("https://attacker.invalid/push")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var envelope = await ReadAsync<ApiErrorResponse>(response);
|
||||
Assert.Equal(Constants.ApiErrorCodes.InvalidSubscription, envelope.Code);
|
||||
Assert.DoesNotContain("attacker.invalid", envelope.Error); // no response names an endpoint
|
||||
Assert.Equal(endpointBefore, row.Endpoint);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, row.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_checks_the_token_before_it_reads_the_body()
|
||||
{
|
||||
var h = new Harness();
|
||||
await h.PairAsync();
|
||||
|
||||
var response = await h.Sut.Resubscribe(Harness.Request("garbage", "not json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.InvalidDeviceToken, EndpointHarness.ErrorCode(response));
|
||||
}
|
||||
|
||||
// ── DELETE push/device ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_unpairs_the_device_and_the_token_dies_with_the_row()
|
||||
{
|
||||
var h = new Harness();
|
||||
var (token, _) = await h.PairAsync();
|
||||
|
||||
var response = await h.Sut.Unpair(Harness.Request(token));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Empty(h.Repo.Devices);
|
||||
var again = await h.Sut.Unpair(Harness.Request(token));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, again.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.InvalidDeviceToken, EndpointHarness.ErrorCode(again));
|
||||
}
|
||||
|
||||
// ── POST push/pair/begin (anonymous, code-gated) ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Begin_with_an_unknown_code_is_404_PairingCodeInvalid()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
var response = await h.Sut.Begin(Harness.BeginRequest(UnknownCode, PhoneIp));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.PairingCodeInvalid, EndpointHarness.ErrorCode(response));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Begin_with_a_live_code_answers_the_active_server_key()
|
||||
{
|
||||
var h = new Harness();
|
||||
var created = await h.Pairing.Sut.CreateAsync(Tenant, Admin);
|
||||
|
||||
var response = await h.Sut.Begin(Harness.BeginRequest(created.Value!.Code, PhoneIp));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var begin = await ReadAsync<BeginPairResponse>(response);
|
||||
Assert.Equal(h.Pairing.Keys.Active.Kid, begin.Kid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Begin_allows_ten_attempts_per_client_ip_a_minute_then_429_with_Retry_After()
|
||||
{
|
||||
var h = new Harness();
|
||||
for (var i = 0; i < 10; i++)
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await h.Sut.Begin(Harness.BeginRequest(UnknownCode, PhoneIp))).StatusCode);
|
||||
|
||||
var throttled = await h.Sut.Begin(Harness.BeginRequest(UnknownCode, PhoneIp));
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, throttled.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.RateLimited, EndpointHarness.ErrorCode(throttled));
|
||||
Assert.InRange(int.Parse(Assert.Single(throttled.Headers.GetValues("Retry-After"))), 1, 60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Begin_throttle_is_keyed_per_client_ip()
|
||||
{
|
||||
var h = new Harness();
|
||||
for (var i = 0; i <= 10; i++)
|
||||
await h.Sut.Begin(Harness.BeginRequest(UnknownCode, PhoneIp)); // the 11th is this IP's 429
|
||||
|
||||
var response = await h.Sut.Begin(Harness.BeginRequest(UnknownCode, OtherPhoneIp));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Begin_throttle_keys_on_the_trusted_hop_not_a_client_written_prefix()
|
||||
{
|
||||
// App Service appends the real peer as the RIGHTMOST hop; anything to its left is the caller's.
|
||||
var h = new Harness();
|
||||
for (var i = 0; i < 10; i++)
|
||||
await h.Sut.Begin(Harness.BeginRequest(UnknownCode, $"203.0.113.{i}, {PhoneIp}"));
|
||||
|
||||
var response = await h.Sut.Begin(Harness.BeginRequest(UnknownCode, $"203.0.113.99, {PhoneIp}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<T> ReadAsync<T>(HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
return (await JsonSerializer.DeserializeAsync<T>(response.Body, ApiJsonOptions.Read))!;
|
||||
}
|
||||
|
||||
private static async Task<string> BodyTextAsync(HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
using var reader = new StreamReader(response.Body, leaveOpen: true);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The functions over the pairing-service harness (in-memory repository, fake push transport)
|
||||
/// and a real <see cref="RateLimitService"/> — one instance per test, so the throttle buckets
|
||||
/// are the test's own.
|
||||
/// </summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public PushPairingServiceTests.Harness Pairing { get; } = new();
|
||||
public PushReceiverFunctions Sut { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var rateLimit = new RateLimitService(new MemoryCache(new MemoryCacheOptions()), NullLogger<RateLimitService>.Instance);
|
||||
Sut = new PushReceiverFunctions(Pairing.Sut, rateLimit, NullLogger<PushReceiverFunctions>.Instance);
|
||||
}
|
||||
|
||||
public InMemoryPushDeviceRepository Repo => Pairing.Repo;
|
||||
|
||||
/// <summary>create → redeem → confirm: an Active device, the token its phone holds, and the stored row (the repository mutates it in place).</summary>
|
||||
public async Task<(string Token, PushDevice Row)> PairAsync()
|
||||
{
|
||||
var created = await Pairing.Sut.CreateAsync(Tenant, Admin);
|
||||
var redeemed = await Pairing.Sut.RedeemAsync(Pairing.RedeemRequest(created.Value!.Code));
|
||||
await Pairing.Sut.ConfirmAsync(Tenant, created.Value.PairingId, Admin);
|
||||
return (redeemed.Value!.DeviceToken, Repo.Devices[(Tenant.Key, redeemed.Value.DeviceId)]);
|
||||
}
|
||||
|
||||
public string ResubscribeBody(string endpoint) => TestWire.Serialize(new ResubscribeRequest
|
||||
{
|
||||
Endpoint = endpoint, P256dh = Pairing.Subscription.P256dh, Auth = Pairing.Subscription.Auth, Kid = Pairing.Keys.Active.Kid,
|
||||
});
|
||||
|
||||
/// <summary>A device-token route request: the token in its header (none by default), an optional JSON body.</summary>
|
||||
public static HttpRequestData Request(string? token = null, string? body = null)
|
||||
{
|
||||
var headers = new Dictionary<string, string>();
|
||||
if (token != null)
|
||||
headers[Constants.Push.DeviceTokenHeader] = token;
|
||||
return EndpointHarness.Request(TenantId, jsonBody: body, headers: headers).Req;
|
||||
}
|
||||
|
||||
/// <summary>An anonymous begin from one phone; X-Forwarded-For is the hop the IP throttle keys on.</summary>
|
||||
public static HttpRequestData BeginRequest(string code, string forwardedFor)
|
||||
=> EndpointHarness.Request(TenantId, jsonBody: TestWire.Serialize(new BeginPairRequest { Code = code }),
|
||||
headers: new Dictionary<string, string> { ["X-Forwarded-For"] = forwardedFor }).Req;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The role-loss hooks (K6): a tenant member who is removed, disabled or demoted below Operator
|
||||
/// loses the paired push devices of that tenant, a removed or disabled Global Admin loses the
|
||||
/// platform-scope devices, and no other mutation touches a device. The hook is optional — the
|
||||
/// original constructors without a revoker keep working — and it keys on the NEW role only.
|
||||
/// </summary>
|
||||
public class PushRoleLossHookTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Upn = "member@contoso.invalid";
|
||||
private static readonly NotificationScope Tenant = NotificationScope.Tenant(TenantId);
|
||||
|
||||
// ── TenantAdminsService ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Removing_a_member_revokes_their_tenant_devices_once_as_member_removed()
|
||||
{
|
||||
var h = new TenantHarness();
|
||||
|
||||
await h.Sut.RemoveTenantAdminAsync(TenantId, Upn);
|
||||
|
||||
AssertRevokedOnce(h.Revoker, Tenant, Upn, "member_removed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Remove_hands_the_revoker_the_lower_cased_scope_and_upn()
|
||||
{
|
||||
var h = new TenantHarness();
|
||||
|
||||
await h.Sut.RemoveTenantAdminAsync("22222222-2222-2222-2222-22222222ABCD", "Leaver@Contoso.INVALID");
|
||||
|
||||
AssertRevokedOnce(h.Revoker, NotificationScope.Tenant("22222222-2222-2222-2222-22222222abcd"), "leaver@contoso.invalid", "member_removed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Remove_deletes_the_member_row_before_the_push_hook_fires()
|
||||
{
|
||||
// The role loss is durable first; the device revoke is the best-effort follow-up.
|
||||
var h = new TenantHarness();
|
||||
var order = new List<string>();
|
||||
h.AdminRepo.Setup(r => r.RemoveTenantMemberAsync(TenantId, Upn)).Callback(() => order.Add("row")).ReturnsAsync(true);
|
||||
h.Revoker.Setup(r => r.RevokeOwnerAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Callback(() => order.Add("push")).Returns(Task.CompletedTask);
|
||||
|
||||
await h.Sut.RemoveTenantAdminAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(new[] { "row", "push" }, order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabling_a_member_revokes_their_tenant_devices_once_as_member_disabled()
|
||||
{
|
||||
var h = new TenantHarness();
|
||||
|
||||
await h.Sut.DisableTenantAdminAsync(TenantId, Upn);
|
||||
|
||||
AssertRevokedOnce(h.Revoker, Tenant, Upn, "member_disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabling_a_member_never_touches_push_devices()
|
||||
{
|
||||
var h = new TenantHarness();
|
||||
|
||||
await h.Sut.EnableTenantAdminAsync(TenantId, Upn);
|
||||
|
||||
AssertNeverRevoked(h.Revoker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Demotion_to_viewer_revokes_once_as_role_lost()
|
||||
{
|
||||
var h = new TenantHarness();
|
||||
h.GivenMember(Constants.TenantRoles.Admin);
|
||||
|
||||
var updated = await h.Sut.UpdateMemberPermissionsAsync(TenantId, Upn, Constants.TenantRoles.Viewer, false);
|
||||
|
||||
Assert.True(updated);
|
||||
AssertRevokedOnce(h.Revoker, Tenant, Upn, "role_lost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Demotion_from_admin_to_operator_keeps_the_devices()
|
||||
{
|
||||
var h = new TenantHarness();
|
||||
h.GivenMember(Constants.TenantRoles.Admin);
|
||||
|
||||
var updated = await h.Sut.UpdateMemberPermissionsAsync(TenantId, Upn, Constants.TenantRoles.Operator, true);
|
||||
|
||||
Assert.True(updated);
|
||||
AssertNeverRevoked(h.Revoker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Promotion_from_operator_to_admin_keeps_the_devices()
|
||||
{
|
||||
var h = new TenantHarness();
|
||||
h.GivenMember(Constants.TenantRoles.Operator);
|
||||
|
||||
var updated = await h.Sut.UpdateMemberPermissionsAsync(TenantId, Upn, Constants.TenantRoles.Admin, false);
|
||||
|
||||
Assert.True(updated);
|
||||
AssertNeverRevoked(h.Revoker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Viewer_to_viewer_update_revokes_too_because_the_hook_keys_on_the_new_role_only()
|
||||
{
|
||||
// The service never reads the previous role, so an update that lands on Viewer revokes
|
||||
// whether or not it was a demotion. Idempotent in practice: a Viewer cannot pair, so the
|
||||
// revoke finds no device — and it cleans up after an earlier hook that failed soft.
|
||||
var h = new TenantHarness();
|
||||
h.GivenMember(Constants.TenantRoles.Viewer);
|
||||
|
||||
var updated = await h.Sut.UpdateMemberPermissionsAsync(TenantId, Upn, Constants.TenantRoles.Viewer, false);
|
||||
|
||||
Assert.True(updated);
|
||||
AssertRevokedOnce(h.Revoker, Tenant, Upn, "role_lost");
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_that_changes_no_row_revokes_nothing()
|
||||
{
|
||||
var h = new TenantHarness();
|
||||
h.AdminRepo.Setup(r => r.UpdateMemberPermissionsAsync(TenantId, Upn, Constants.TenantRoles.Viewer, false)).ReturnsAsync(false);
|
||||
|
||||
var updated = await h.Sut.UpdateMemberPermissionsAsync(TenantId, Upn, Constants.TenantRoles.Viewer, false);
|
||||
|
||||
Assert.False(updated);
|
||||
AssertNeverRevoked(h.Revoker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_service_without_a_revoker_still_completes_every_hook_bearing_mutation()
|
||||
{
|
||||
var h = new TenantHarness(withRevoker: false);
|
||||
|
||||
await h.Sut.RemoveTenantAdminAsync(TenantId, Upn);
|
||||
await h.Sut.DisableTenantAdminAsync(TenantId, Upn);
|
||||
var updated = await h.Sut.UpdateMemberPermissionsAsync(TenantId, Upn, Constants.TenantRoles.Viewer, false);
|
||||
|
||||
Assert.True(updated);
|
||||
h.AdminRepo.Verify(r => r.RemoveTenantMemberAsync(TenantId, Upn), Times.Once);
|
||||
h.AdminRepo.Verify(r => r.SetTenantMemberEnabledAsync(TenantId, Upn, false), Times.Once);
|
||||
}
|
||||
|
||||
// ── GlobalAdminService ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Removing_a_global_admin_revokes_their_platform_devices_once_as_global_admin_removed()
|
||||
{
|
||||
var h = new GlobalHarness();
|
||||
|
||||
await h.Sut.RemoveGlobalAdminAsync(Upn);
|
||||
|
||||
AssertRevokedOnce(h.Revoker, NotificationScope.Platform, Upn, "global_admin_removed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabling_a_global_admin_revokes_their_platform_devices_once_as_global_admin_disabled()
|
||||
{
|
||||
var h = new GlobalHarness();
|
||||
|
||||
await h.Sut.DisableGlobalAdminAsync(Upn);
|
||||
|
||||
AssertRevokedOnce(h.Revoker, NotificationScope.Platform, Upn, "global_admin_disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_remove_hands_the_revoker_the_lower_cased_upn()
|
||||
{
|
||||
var h = new GlobalHarness();
|
||||
|
||||
await h.Sut.RemoveGlobalAdminAsync("Leaver@Contoso.INVALID");
|
||||
|
||||
AssertRevokedOnce(h.Revoker, NotificationScope.Platform, "leaver@contoso.invalid", "global_admin_removed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_service_without_a_revoker_still_completes_remove_and_disable()
|
||||
{
|
||||
var h = new GlobalHarness(withRevoker: false);
|
||||
|
||||
await h.Sut.RemoveGlobalAdminAsync(Upn);
|
||||
await h.Sut.DisableGlobalAdminAsync(Upn);
|
||||
|
||||
h.AdminRepo.Verify(r => r.RemoveGlobalAdminAsync(Upn), Times.Once);
|
||||
h.AdminRepo.Verify(r => r.DisableGlobalAdminAsync(Upn), Times.Once);
|
||||
}
|
||||
|
||||
// ── Assertions ───────────────────────────────────────────────────────────
|
||||
|
||||
private static void AssertRevokedOnce(Mock<IPushDeviceRevoker> revoker, NotificationScope scope, string upn, string reason)
|
||||
{
|
||||
revoker.Verify(r => r.RevokeOwnerAsync(It.Is<NotificationScope>(s => s == scope), upn, reason), Times.Once);
|
||||
revoker.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
private static void AssertNeverRevoked(Mock<IPushDeviceRevoker> revoker)
|
||||
=> revoker.Verify(r => r.RevokeOwnerAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
|
||||
// ── Harnesses ────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class TenantHarness
|
||||
{
|
||||
public Mock<IAdminRepository> AdminRepo { get; } = new(MockBehavior.Loose);
|
||||
public Mock<IPushDeviceRevoker> Revoker { get; } = new(MockBehavior.Loose);
|
||||
public TenantAdminsService Sut { get; }
|
||||
|
||||
public TenantHarness(bool withRevoker = true)
|
||||
{
|
||||
AdminRepo.Setup(r => r.RemoveTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
AdminRepo.Setup(r => r.SetTenantMemberEnabledAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>())).ReturnsAsync(true);
|
||||
// The hook fires only after a successful write, so the default answer is "row updated".
|
||||
AdminRepo.Setup(r => r.UpdateMemberPermissionsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>())).ReturnsAsync(true);
|
||||
Revoker.Setup(r => r.RevokeOwnerAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
Sut = withRevoker
|
||||
? new TenantAdminsService(AdminRepo.Object, cache, NullLogger<TenantAdminsService>.Instance, Revoker.Object)
|
||||
: new TenantAdminsService(AdminRepo.Object, cache, NullLogger<TenantAdminsService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>States the member's current row. The service does not read it today; the arrange stays honest if it ever does.</summary>
|
||||
public void GivenMember(string role)
|
||||
=> AdminRepo.Setup(r => r.GetTenantMemberAsync(TenantId, Upn))
|
||||
.ReturnsAsync(new TenantMember { TenantId = TenantId, Upn = Upn, Role = role, IsEnabled = true });
|
||||
}
|
||||
|
||||
private sealed class GlobalHarness
|
||||
{
|
||||
public Mock<IAdminRepository> AdminRepo { get; } = new(MockBehavior.Loose);
|
||||
public Mock<IPushDeviceRevoker> Revoker { get; } = new(MockBehavior.Loose);
|
||||
public GlobalAdminService Sut { get; }
|
||||
|
||||
public GlobalHarness(bool withRevoker = true)
|
||||
{
|
||||
AdminRepo.Setup(r => r.RemoveGlobalAdminAsync(It.IsAny<string>())).ReturnsAsync(true);
|
||||
AdminRepo.Setup(r => r.DisableGlobalAdminAsync(It.IsAny<string>())).ReturnsAsync(true);
|
||||
Revoker.Setup(r => r.RevokeOwnerAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var bindings = new AdminIdentityBindingService(AdminRepo.Object, cache, NullLogger<AdminIdentityBindingService>.Instance);
|
||||
Sut = withRevoker
|
||||
? new GlobalAdminService(AdminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance, Revoker.Object)
|
||||
: new GlobalAdminService(AdminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the Web Push surface of the route policy catalog: the tier and tenant scoping of every push
|
||||
/// route, that the surface is exactly these 22 routes (a 23rd cannot appear unnoticed), that only the
|
||||
/// five receiver routes skip JWT validation (the phone never signs in), that no push route carries a
|
||||
/// grant-widening opt-in, and that every push path is stamped <c>Cache-Control: no-store</c>.
|
||||
/// </summary>
|
||||
public class PushRoutePolicyTests
|
||||
{
|
||||
private const string SampleId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
/// <summary>The 22 push routes, as "METHOD template" — the one list the surface test compares against.</summary>
|
||||
private static readonly string[] PinnedRoutes =
|
||||
{
|
||||
// Receiver (the phone): one-shot pairing code, then the device token in X-Push-Device-Token.
|
||||
"POST push/pair/begin",
|
||||
"POST push/pair",
|
||||
"GET push/device",
|
||||
"PUT push/device",
|
||||
"DELETE push/device",
|
||||
// Tenant scope (portal): Admin/Operator, tenant from the JWT.
|
||||
"POST push/pairings",
|
||||
"GET push/pairings/{pairingId}",
|
||||
"POST push/pairings/{pairingId}/confirm",
|
||||
"POST push/pairings/{pairingId}/reject",
|
||||
"GET push/devices",
|
||||
"DELETE push/devices/{deviceId}",
|
||||
"POST push/devices/{deviceId}/test",
|
||||
// Session watches: ?tenantId= like the annotations route.
|
||||
"GET sessions/{sessionId}/watch",
|
||||
"PUT sessions/{sessionId}/watch",
|
||||
"DELETE sessions/{sessionId}/watch",
|
||||
// Platform-scope twin (operator devices, ops channels).
|
||||
"POST global/push/pairings",
|
||||
"GET global/push/pairings/{pairingId}",
|
||||
"POST global/push/pairings/{pairingId}/confirm",
|
||||
"POST global/push/pairings/{pairingId}/reject",
|
||||
"GET global/push/devices",
|
||||
"DELETE global/push/devices/{deviceId}",
|
||||
"POST global/push/devices/{deviceId}/test",
|
||||
};
|
||||
|
||||
// ── Catalog tiers ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
// Receiver: no JWT — the pairing code / device token is the whole authority.
|
||||
[InlineData("POST", "push/pair/begin", EndpointPolicy.PublicAnonymous, TenantScoping.None)]
|
||||
[InlineData("POST", "push/pair", EndpointPolicy.PublicAnonymous, TenantScoping.None)]
|
||||
[InlineData("GET", "push/device", EndpointPolicy.PublicAnonymous, TenantScoping.None)]
|
||||
[InlineData("PUT", "push/device", EndpointPolicy.PublicAnonymous, TenantScoping.None)]
|
||||
[InlineData("DELETE", "push/device", EndpointPolicy.PublicAnonymous, TenantScoping.None)]
|
||||
// Tenant scope: Admin/Operator (no Viewer), tenant from the JWT.
|
||||
[InlineData("POST", "push/pairings", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("GET", "push/pairings/{pairingId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/pairings/{pairingId}/confirm", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/pairings/{pairingId}/reject", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("GET", "push/devices", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("DELETE", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
[InlineData("POST", "push/devices/{deviceId}/test", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt)]
|
||||
// Session watches: ?tenantId= (optional, falls back to the JWT tenant; middleware cross-tenant check).
|
||||
[InlineData("GET", "sessions/{sessionId}/watch", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam)]
|
||||
[InlineData("PUT", "sessions/{sessionId}/watch", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam)]
|
||||
[InlineData("DELETE", "sessions/{sessionId}/watch", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam)]
|
||||
// Platform scope: Global Admin only, no tenant scoping.
|
||||
[InlineData("POST", "global/push/pairings", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("GET", "global/push/pairings/{pairingId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/confirm", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/pairings/{pairingId}/reject", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("GET", "global/push/devices", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("DELETE", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
[InlineData("POST", "global/push/devices/{deviceId}/test", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
public void Push_route_resolves_to_its_tier_and_tenant_scoping(string method, string template, EndpointPolicy policy, TenantScoping scoping)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, ConcretePath(template));
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(template, entry.RouteTemplate); // resolved to this entry, not to a sibling
|
||||
Assert.Equal(policy, entry.Policy);
|
||||
Assert.Equal(scoping, entry.TenantScoping);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_push_surface_is_exactly_the_22_pinned_routes()
|
||||
{
|
||||
var expected = PinnedRoutes.OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
var actual = PushSurface().Select(Key).OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_five_receiver_routes_skip_jwt_validation()
|
||||
{
|
||||
var expected = new[] { "DELETE push/device", "GET push/device", "POST push/pair", "POST push/pair/begin", "PUT push/device" };
|
||||
|
||||
var exempt = PushSurface()
|
||||
.Where(e => AuthenticationMiddleware.SkipsJwtValidation(e.HttpMethod, ConcretePath(e.RouteTemplate)))
|
||||
.Select(Key)
|
||||
.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(expected, exempt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_push_route_opts_into_application_principals_or_the_offboarding_bypass()
|
||||
{
|
||||
Assert.DoesNotContain(PushSurface(), e => e.ApplicationAllowed || e.AllowedDuringOffboarding);
|
||||
}
|
||||
|
||||
// ── no-store classification ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/push/device")]
|
||||
[InlineData("/api/push/pair")]
|
||||
[InlineData("/api/push/pair/begin")]
|
||||
[InlineData("/api/push/pairings")]
|
||||
[InlineData("/api/push/pairings/abc")]
|
||||
[InlineData("/api/push/devices")]
|
||||
[InlineData("/api/global/push/devices")]
|
||||
[InlineData("/api/global/push/pairings/abc")]
|
||||
[InlineData("/api/sessions/s1/watch")]
|
||||
[InlineData("/API/Push/Device")] // case-insensitive
|
||||
public void Push_paths_are_stamped_no_store(string path)
|
||||
{
|
||||
Assert.True(NoStoreCacheMiddleware.IsSensitive(path),
|
||||
$"Expected '{path}' to be classified as sensitive.");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// The prefixes end in a slash ("/api/push/", "/api/global/push/"): a sibling segment that merely
|
||||
// shares the letters must not be stamped, and neither is the bare segment without a sub-path.
|
||||
[InlineData("/api/pushes")]
|
||||
[InlineData("/api/push")]
|
||||
[InlineData("/api/global/pushes")]
|
||||
public void Paths_that_only_share_the_push_prefix_letters_are_not_stamped(string path)
|
||||
{
|
||||
Assert.False(NoStoreCacheMiddleware.IsSensitive(path),
|
||||
$"Expected '{path}' to NOT be classified as sensitive (would over-stamp no-store).");
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>A request path for the template: every {param} becomes a GUID-shaped sample, "/api/" in front.</summary>
|
||||
private static string ConcretePath(string template)
|
||||
=> "/api/" + Regex.Replace(template, @"\{[^}]+}", SampleId);
|
||||
|
||||
/// <summary>Every catalog entry on the push surface: push/*, global/push/* and the session watches.</summary>
|
||||
private static IEnumerable<EndpointPolicyEntry> PushSurface()
|
||||
=> EndpointAccessPolicyCatalog.Entries.Where(e =>
|
||||
e.RouteTemplate.StartsWith("push/", StringComparison.Ordinal)
|
||||
|| e.RouteTemplate.StartsWith("global/push/", StringComparison.Ordinal)
|
||||
|| e.RouteTemplate.EndsWith("/watch", StringComparison.Ordinal));
|
||||
|
||||
private static string Key(EndpointPolicyEntry e) => $"{e.HttpMethod} {e.RouteTemplate}";
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Session watches ("notify my devices when this session ends"): the watch row lives in the
|
||||
/// SESSION's tenant, the devices it fires on are the watcher's OWN scopes (home tenant, plus the
|
||||
/// platform partition for a Global Admin), gated by an enabled Push channel and the role check at
|
||||
/// watch time and again at notify time. 48-hour lifetime, one-hour push TTL, consumed at the
|
||||
/// terminal status, fail-soft towards the ingest path.
|
||||
/// </summary>
|
||||
public class PushSessionWatchServiceTests
|
||||
{
|
||||
private const string HomeTenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string CustomerTenantId = "22222222-2222-2222-2222-222222222222";
|
||||
private const string SessionId = "33333333-3333-3333-3333-333333333333";
|
||||
private static readonly NotificationScope Home = NotificationScope.Tenant(HomeTenantId);
|
||||
|
||||
private static readonly PushCaller Admin = new("admin@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000001", HomeTenantId, IsScopeAdmin: true);
|
||||
private static readonly PushCaller Operator = new("ops@contoso.invalid", "aaaaaaaa-0000-0000-0000-000000000002", HomeTenantId, IsScopeAdmin: false);
|
||||
private static readonly PushCaller GlobalAdmin = new("ga@contoso.invalid", "bbbbbbbb-0000-0000-0000-000000000001", HomeTenantId, IsScopeAdmin: true, IsGlobalAdmin: true);
|
||||
private static readonly PushCaller OtherGlobalAdmin = new("ga2@contoso.invalid", "bbbbbbbb-0000-0000-0000-000000000002", HomeTenantId, IsScopeAdmin: true, IsGlobalAdmin: true);
|
||||
private static readonly PushCaller CustomerAdmin = new("admin@fabrikam.invalid", "cccccccc-0000-0000-0000-000000000001", CustomerTenantId, IsScopeAdmin: true);
|
||||
|
||||
// ── WatchAsync ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Watch_without_a_push_channel_in_any_own_scope_is_refused()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.ChannelRequired, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Watch_with_a_channel_but_an_ineligible_caller_is_refused()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(Home, Admin.Upn, Admin.ObjectId, Admin.HomeTenantId)).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.NotEligible, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.Push.DeviceStatus.Pending)]
|
||||
[InlineData(Constants.Push.DeviceStatus.Paused)]
|
||||
[InlineData(Constants.Push.DeviceStatus.Stale)]
|
||||
public async Task Watch_without_an_active_device_of_the_caller_is_a_conflict(string status)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId, status);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.Conflict, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Another_owners_active_device_in_the_scope_does_not_count_for_the_caller()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Operator, HomeTenantId);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.Conflict, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_watch_is_stored_in_the_session_tenant_with_the_home_scope_and_a_48_hour_expiry()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.True(result.Value!.Watching);
|
||||
var row = h.Repo.Watches[(HomeTenantId, SessionId, Admin.ObjectId)];
|
||||
Assert.Equal(HomeTenantId, row.TenantId);
|
||||
Assert.Equal(SessionId, row.SessionId);
|
||||
Assert.Equal(Admin.Upn, row.OwnerUpn);
|
||||
Assert.Equal(HomeTenantId, row.OwnerScopes);
|
||||
Assert.InRange(row.ExpiresUtc, before.AddHours(48), after.AddHours(48));
|
||||
Assert.Equal(row.ExpiresUtc, result.Value.ExpiresUtc!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_with_only_a_platform_device_watches_a_customer_session_from_the_platform_scope()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
// The row lives in the SESSION's tenant partition, not in the platform or the home partition.
|
||||
var row = Assert.Single(h.Repo.Watches.Values);
|
||||
Assert.True(h.Repo.Watches.ContainsKey((CustomerTenantId, SessionId, GlobalAdmin.ObjectId)));
|
||||
Assert.Equal(CustomerTenantId, row.TenantId);
|
||||
Assert.Equal(Constants.Push.PlatformScope, row.OwnerScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_with_platform_and_home_devices_records_both_scopes_platform_first()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("h1", GlobalAdmin, HomeTenantId);
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal($"{Constants.Push.PlatformScope};{HomeTenantId}", h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scope_without_a_push_channel_is_left_out_of_the_watch_scopes()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("h1", GlobalAdmin, HomeTenantId);
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal(Constants.Push.PlatformScope, h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Own_scopes_are_the_platform_first_for_a_global_admin_and_the_home_tenant_for_everyone()
|
||||
{
|
||||
var globalAdminScopes = PushSessionWatchService.OwnScopes(GlobalAdmin);
|
||||
Assert.Equal(2, globalAdminScopes.Count);
|
||||
Assert.True(globalAdminScopes[0].IsPlatform);
|
||||
Assert.Equal(Home, globalAdminScopes[1]);
|
||||
|
||||
Assert.Equal(Home, Assert.Single(PushSessionWatchService.OwnScopes(Admin)));
|
||||
Assert.Empty(PushSessionWatchService.OwnScopes(new PushCaller("app@contoso.invalid", "dddddddd-0000-0000-0000-000000000001", string.Empty, IsScopeAdmin: false)));
|
||||
}
|
||||
|
||||
// ── GetAsync / UnwatchAsync ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Get_reports_an_active_watch_with_its_expiry()
|
||||
{
|
||||
var h = new Harness();
|
||||
var watch = h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
|
||||
var status = await h.Sut.GetAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.True(status.Watching);
|
||||
Assert.Equal(watch.ExpiresUtc, status.ExpiresUtc!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_reports_not_watching_without_a_row_of_the_caller()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddWatch(HomeTenantId, SessionId, Operator, HomeTenantId); // somebody else's watch on the same session
|
||||
|
||||
var status = await h.Sut.GetAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.False(status.Watching);
|
||||
Assert.Null(status.ExpiresUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_reports_an_expired_row_as_not_watching()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId, expiresUtc: DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
var status = await h.Sut.GetAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.False(status.Watching);
|
||||
Assert.Null(status.ExpiresUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_says_watching_is_available_when_an_own_scope_has_a_channel_and_the_caller_is_eligible()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
var status = await h.Sut.GetAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.False(status.Watching);
|
||||
Assert.True(status.Available);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_says_watching_is_unavailable_without_a_push_channel_or_without_eligibility()
|
||||
{
|
||||
var noChannel = new Harness();
|
||||
noChannel.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(false);
|
||||
Assert.False((await noChannel.Sut.GetAsync(HomeTenantId, SessionId, Admin)).Available);
|
||||
|
||||
var notEligible = new Harness();
|
||||
notEligible.Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
Assert.False((await notEligible.Sut.GetAsync(HomeTenantId, SessionId, Admin)).Available);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_keeps_an_active_watch_available_so_it_can_still_be_switched_off()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(false);
|
||||
|
||||
var status = await h.Sut.GetAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.True(status.Watching);
|
||||
Assert.True(status.Available);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unwatch_deletes_the_callers_row_only()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Operator, HomeTenantId);
|
||||
|
||||
var status = await h.Sut.UnwatchAsync(HomeTenantId, SessionId, Admin);
|
||||
|
||||
Assert.False(status.Watching);
|
||||
Assert.False(h.Repo.Watches.ContainsKey((HomeTenantId, SessionId, Admin.ObjectId)));
|
||||
Assert.True(h.Repo.Watches.ContainsKey((HomeTenantId, SessionId, Operator.ObjectId)));
|
||||
}
|
||||
|
||||
// ── NotifyAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_pushes_to_each_device_of_the_watcher_in_the_watch_scopes_and_consumes_the_watch()
|
||||
{
|
||||
var h = new Harness();
|
||||
var p1 = h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
var p2 = h.AddDevice("p2", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddDevice("p3", OtherGlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddDevice("c1", GlobalAdmin, CustomerTenantId); // the session's tenant is not a watch scope here
|
||||
h.AddDevice("c2", CustomerAdmin, CustomerTenantId);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Equal(Endpoints(p1, p2), SentTo(h));
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
Assert.NotNull(h.Repo.Devices[(Constants.Push.PlatformScope, "p1")].LastDeliveredUtc);
|
||||
Assert.NotNull(h.Repo.Devices[(Constants.Push.PlatformScope, "p2")].LastDeliveredUtc);
|
||||
Assert.Null(h.Repo.Devices[(Constants.Push.PlatformScope, "p3")].LastDeliveredUtc);
|
||||
Assert.Null(h.Repo.Devices[(CustomerTenantId, "c1")].LastDeliveredUtc);
|
||||
Assert.Null(h.Repo.Devices[(CustomerTenantId, "c2")].LastDeliveredUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_fires_every_watcher_of_the_session_and_consumes_each_watch()
|
||||
{
|
||||
var h = new Harness();
|
||||
var d1 = h.AddDevice("d1", Admin, HomeTenantId);
|
||||
var d2 = h.AddDevice("d2", Operator, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Operator, HomeTenantId);
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: false, "DESKTOP-4711", null);
|
||||
|
||||
Assert.Equal(Endpoints(d1, d2), SentTo(h));
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_with_empty_owner_scopes_falls_back_to_the_session_tenant()
|
||||
{
|
||||
var h = new Harness();
|
||||
var device = h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, ownerScopes: string.Empty); // legacy row
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: true, "DESKTOP-4711", null);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal(device.Endpoint, request.RequestUri!.ToString());
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_deletes_an_expired_watch_and_sends_nothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope, expiresUtc: DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: false, "DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_without_watches_neither_writes_nor_sends()
|
||||
{
|
||||
var repo = new Mock<IPushDeviceRepository>();
|
||||
repo.Setup(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId)).ReturnsAsync(new List<PushSessionWatch>());
|
||||
var h = new Harness(repo.Object);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", null);
|
||||
|
||||
repo.Verify(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId), Times.Once);
|
||||
repo.VerifyNoOtherCalls();
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Notify_push_carries_the_one_hour_ttl_and_high_urgency(bool success)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success, "DESKTOP-4711", "1234567890");
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("3600", Header(request, "TTL")); // WatchPushTtl: "Watch bis Session-Ende + 1 h"
|
||||
Assert.Equal("high", Header(request, "Urgency"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_skips_a_watcher_who_lost_eligibility_and_still_consumes_the_watch()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(NotificationScope.Platform, GlobalAdmin.Upn, GlobalAdmin.ObjectId, GlobalAdmin.HomeTenantId)).ReturnsAsync(false);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Repo.Devices[(Constants.Push.PlatformScope, "p1")].Status);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_pauses_the_devices_of_an_inactive_watcher_instead_of_pushing()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.IsOwnerFreshAsync(GlobalAdmin.HomeTenantId, GlobalAdmin.ObjectId, It.IsAny<DateTime>())).ReturnsAsync(false);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
|
||||
|
||||
var row = h.Repo.Devices[(Constants.Push.PlatformScope, "p1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, row.Status);
|
||||
Assert.Equal("owner_inactive", row.StatusReason);
|
||||
// The pause path sends its own "sign in to resume" push (system TTL 30 min); the watch push (TTL 1 h) never goes out.
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("1800", Header(request, "TTL"));
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_never_throws_when_the_repository_fails()
|
||||
{
|
||||
var repo = new Mock<IPushDeviceRepository>();
|
||||
repo.Setup(r => r.GetWatchesForSessionAsync(It.IsAny<string>(), It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
var h = new Harness(repo.Object);
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890"));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static string Header(HttpRequestMessage request, string name) => request.Headers.GetValues(name).Single();
|
||||
|
||||
private static string[] Endpoints(params PushDevice[] devices) => devices.Select(d => d.Endpoint).Order().ToArray();
|
||||
|
||||
private static string[] SentTo(Harness h) => h.Http.Requests.Select(r => r.RequestUri!.ToString()).Order().ToArray();
|
||||
|
||||
internal sealed class Harness
|
||||
{
|
||||
public InMemoryPushDeviceRepository Repo { get; } = new();
|
||||
public VapidKeyRing Keys { get; } = new(VapidKey.Generate());
|
||||
public Mock<PushEligibility> Eligibility { get; }
|
||||
public PushPairingServiceTests.RecordingHandler Http { get; } = new();
|
||||
public PushSessionWatchService Sut { get; }
|
||||
private readonly (string P256dh, string Auth) _subscription;
|
||||
|
||||
/// <param name="repo">The repository both services run on; defaults to <see cref="Repo"/>, a mock for the fail-soft cases.</param>
|
||||
public Harness(IPushDeviceRepository? repo = null)
|
||||
{
|
||||
repo ??= Repo;
|
||||
Eligibility = new Mock<PushEligibility>(null!, null!, null!, null!, null!) { CallBase = false };
|
||||
Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(true);
|
||||
Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
Eligibility.Setup(e => e.IsOwnerFreshAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DateTime>())).ReturnsAsync(true);
|
||||
|
||||
var opsEvents = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
opsEvents.Setup(o => o.RecordPushDeliveryFailedAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
var delivery = new PushDeliveryService(repo, Eligibility.Object, PushSettings.ForKeys(Keys), new WebPushSender(new HttpClient(Http)),
|
||||
new Lazy<OpsEventService>(() => opsEvents.Object), new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<PushDeliveryService>.Instance);
|
||||
Sut = new PushSessionWatchService(repo, Eligibility.Object, delivery, NullLogger<PushSessionWatchService>.Instance);
|
||||
|
||||
// A browser-shaped subscription: a fresh P-256 point + 16-byte auth secret, so the per-device encryption succeeds.
|
||||
using var ua = System.Security.Cryptography.ECDiffieHellman.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256);
|
||||
var p = ua.ExportParameters(false);
|
||||
var point = new byte[65];
|
||||
point[0] = 0x04;
|
||||
p.Q.X!.CopyTo(point, 1 + (32 - p.Q.X!.Length));
|
||||
p.Q.Y!.CopyTo(point, 33 + (32 - p.Q.Y!.Length));
|
||||
_subscription = (System.Buffers.Text.Base64Url.EncodeToString(point),
|
||||
System.Buffers.Text.Base64Url.EncodeToString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)));
|
||||
}
|
||||
|
||||
public PushDevice AddDevice(string id, PushCaller owner, string scope, string status = Constants.Push.DeviceStatus.Active)
|
||||
{
|
||||
var device = new PushDevice
|
||||
{
|
||||
Scope = scope,
|
||||
DeviceId = id,
|
||||
OwnerUpn = owner.Upn,
|
||||
OwnerObjectId = owner.ObjectId,
|
||||
OwnerHomeTenantId = owner.HomeTenantId,
|
||||
Kind = "webpush",
|
||||
Endpoint = $"https://web.push.apple.com/QF/{id}",
|
||||
P256dh = _subscription.P256dh,
|
||||
Auth = _subscription.Auth,
|
||||
DeviceSecretHash = "hash",
|
||||
VapidKid = Keys.Active.Kid,
|
||||
Label = id,
|
||||
Platform = "ios-homescreen",
|
||||
Status = status,
|
||||
PairedUtc = DateTime.UtcNow,
|
||||
};
|
||||
Repo.Devices[(scope, id)] = device;
|
||||
return device;
|
||||
}
|
||||
|
||||
public PushSessionWatch AddWatch(string tenantId, string sessionId, PushCaller owner, string ownerScopes, DateTime? expiresUtc = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var watch = new PushSessionWatch
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = sessionId,
|
||||
OwnerObjectId = owner.ObjectId,
|
||||
OwnerUpn = owner.Upn,
|
||||
OwnerScopes = ownerScopes,
|
||||
CreatedUtc = now,
|
||||
ExpiresUtc = expiresUtc ?? now + PushSessionWatchService.WatchLifetime,
|
||||
};
|
||||
Repo.Watches[(tenantId, sessionId, owner.ObjectId)] = watch;
|
||||
return watch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// Store → Map round trip of <see cref="TablePushRepository"/> for the three models it projects
|
||||
/// (<see cref="PushDevice"/>, <see cref="PushPairingGrant"/>, <see cref="PushSessionWatch"/>;
|
||||
/// <see cref="PushOwner"/> is written inline, not through a Store/Map pair). Every row is a
|
||||
/// credential or binds one, so a column dropped on either side is a silently unpaired phone or a
|
||||
/// reusable code. The comparisons run by reflection over the model's public properties: a property
|
||||
/// added later is covered without touching this file, and the fixture guard fails until the fixture
|
||||
/// carries a distinct non-default value for it.
|
||||
/// </summary>
|
||||
public class TablePushRepositoryMappingTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OwnerHomeTenantId = "22222222-2222-2222-2222-222222222222";
|
||||
private const string SessionId = "33333333-3333-3333-3333-333333333333";
|
||||
private const string DeviceId = "dddddddd-0000-0000-0000-000000000001";
|
||||
private const string OwnerObjectId = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private static readonly DateTime Anchor = new(2026, 10, 7, 8, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
// Documented key mappings: model property → entity key, where the value does not live in a column of its own name.
|
||||
private static readonly Dictionary<string, string> DeviceKeys = new() { ["Scope"] = "PartitionKey", ["DeviceId"] = "RowKey" };
|
||||
private static readonly Dictionary<string, string> GrantKeys = new() { ["CodeHash"] = "RowKey" };
|
||||
private static readonly Dictionary<string, string> WatchKeys = new() { ["TenantId"] = "PartitionKey" };
|
||||
|
||||
// ── Devices ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Device_round_trip_preserves_every_property()
|
||||
{
|
||||
var device = FullDevice();
|
||||
|
||||
var mapped = TablePushRepository.MapDevice(TablePushRepository.StoreDevice(device));
|
||||
|
||||
AssertSameProperties(device, mapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_fixture_sets_every_property_to_a_distinct_non_default_value()
|
||||
=> AssertFixtureIsDistinctAndNonDefault(FullDevice());
|
||||
|
||||
[Fact]
|
||||
public void Device_store_writes_every_property_as_a_column_or_key()
|
||||
{
|
||||
var device = FullDevice();
|
||||
|
||||
var entity = TablePushRepository.StoreDevice(device);
|
||||
|
||||
Assert.Equal(device.Scope, entity.PartitionKey);
|
||||
Assert.Equal(device.DeviceId, entity.RowKey);
|
||||
AssertEveryPropertyIsStored<PushDevice>(entity, DeviceKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_store_labels_every_date_utc_without_moving_it()
|
||||
{
|
||||
var input = WithUnspecifiedKinds(FullDevice());
|
||||
|
||||
var entity = TablePushRepository.StoreDevice(input);
|
||||
var mapped = TablePushRepository.MapDevice(entity);
|
||||
|
||||
AssertDatesStoredAsUtc(input, entity, mapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_status_reason_cleared_to_empty_string_maps_to_null()
|
||||
{
|
||||
// A Merge cannot clear a column, so MutateDeviceAsync writes "" for a cleared reason.
|
||||
var entity = TablePushRepository.StoreDevice(FullDevice());
|
||||
entity["StatusReason"] = string.Empty;
|
||||
|
||||
Assert.Null(TablePushRepository.MapDevice(entity).StatusReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_without_status_reason_column_maps_to_null()
|
||||
{
|
||||
var entity = new TableEntity(TenantId, DeviceId);
|
||||
|
||||
Assert.Null(TablePushRepository.MapDevice(entity).StatusReason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("gone")]
|
||||
[InlineData("vapid_key_mismatch")]
|
||||
[InlineData("invalid_subscription")]
|
||||
[InlineData("owner_inactive")]
|
||||
[InlineData("delivery_failures")]
|
||||
public void Device_set_status_reason_maps_verbatim(string reason)
|
||||
{
|
||||
var entity = new TableEntity(TenantId, DeviceId) { ["StatusReason"] = reason };
|
||||
|
||||
Assert.Equal(reason, TablePushRepository.MapDevice(entity).StatusReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_absent_nullable_columns_map_to_null()
|
||||
{
|
||||
var mapped = TablePushRepository.MapDevice(new TableEntity(TenantId, DeviceId));
|
||||
|
||||
var nullable = PublicProperties<PushDevice>().Where(p => Nullable.GetUnderlyingType(p.PropertyType) != null).ToList();
|
||||
Assert.NotEmpty(nullable);
|
||||
var populated = nullable.Where(p => p.GetValue(mapped) != null).Select(p => p.Name).ToList();
|
||||
Assert.Empty(populated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_without_optional_values_round_trips_the_nulls()
|
||||
{
|
||||
// Edm.DateTime cannot hold default(DateTime): a null stays null through Store (the Utc helper) and Map.
|
||||
var device = FullDevice();
|
||||
foreach (var p in PublicProperties<PushDevice>().Where(p => Nullable.GetUnderlyingType(p.PropertyType) != null))
|
||||
p.SetValue(device, null);
|
||||
device.StatusReason = null;
|
||||
|
||||
var mapped = TablePushRepository.MapDevice(TablePushRepository.StoreDevice(device));
|
||||
|
||||
AssertSameProperties(device, mapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_row_without_columns_maps_to_a_pending_device_with_model_defaults()
|
||||
{
|
||||
var mapped = TablePushRepository.MapDevice(new TableEntity(TenantId, DeviceId));
|
||||
|
||||
Assert.Equal(TenantId, mapped.Scope);
|
||||
Assert.Equal(DeviceId, mapped.DeviceId);
|
||||
// A row without a Status column is Pending — it must never become Active by omission.
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Pending, mapped.Status);
|
||||
Assert.Equal(new PushDevice().Kind, mapped.Kind);
|
||||
Assert.Equal(new PushDevice().Platform, mapped.Platform);
|
||||
Assert.Equal(string.Empty, mapped.OwnerUpn);
|
||||
Assert.Equal(DateTime.MinValue, mapped.PairedUtc);
|
||||
Assert.Equal(0, mapped.ConsecutiveFailures);
|
||||
Assert.Equal(0, mapped.WindowCount);
|
||||
Assert.Equal(0, mapped.SuppressedCount);
|
||||
}
|
||||
|
||||
// ── Grants ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Grant_round_trip_preserves_every_property_except_the_etag()
|
||||
{
|
||||
var grant = FullGrant();
|
||||
|
||||
var entity = TablePushRepository.StoreGrant(grant);
|
||||
var mapped = TablePushRepository.MapGrant(entity);
|
||||
|
||||
// ETag is the storage concurrency token: Map takes it from the entity, which a freshly built
|
||||
// TableEntity does not carry, so it cannot equal the input (see Grant_etag_comes_from_the_entity…).
|
||||
AssertSameProperties(grant, mapped, nameof(PushPairingGrant.ETag));
|
||||
Assert.Equal(entity.ETag.ToString(), mapped.ETag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grant_fixture_sets_every_property_to_a_distinct_non_default_value()
|
||||
=> AssertFixtureIsDistinctAndNonDefault(FullGrant());
|
||||
|
||||
[Fact]
|
||||
public void Grant_store_writes_every_property_as_a_column_or_key()
|
||||
{
|
||||
var grant = FullGrant();
|
||||
|
||||
var entity = TablePushRepository.StoreGrant(grant);
|
||||
|
||||
Assert.Equal("grant", entity.PartitionKey);
|
||||
Assert.Equal(grant.CodeHash, entity.RowKey);
|
||||
// ETag is never a column: TryUpdateGrantAsync sends it as the If-Match condition.
|
||||
AssertEveryPropertyIsStored<PushPairingGrant>(entity, GrantKeys, nameof(PushPairingGrant.ETag));
|
||||
Assert.False(entity.ContainsKey(nameof(PushPairingGrant.ETag)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grant_etag_comes_from_the_entity_not_from_the_stored_grant()
|
||||
{
|
||||
var grant = FullGrant(); // carries the ETag of an earlier read
|
||||
var entity = TablePushRepository.StoreGrant(grant);
|
||||
entity.ETag = new ETag("W/\"datetime'2026-10-07T08%3A31%3A00.0000000Z'\"");
|
||||
|
||||
var mapped = TablePushRepository.MapGrant(entity);
|
||||
|
||||
Assert.Equal(entity.ETag.ToString(), mapped.ETag);
|
||||
Assert.NotEqual(grant.ETag, mapped.ETag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grant_without_redeem_round_trips_null_redeemed_and_device_id()
|
||||
{
|
||||
var grant = FullGrant();
|
||||
grant.Status = Constants.Push.PairingStatus.Pending;
|
||||
grant.RedeemedUtc = null;
|
||||
grant.DeviceId = null;
|
||||
|
||||
var mapped = TablePushRepository.MapGrant(TablePushRepository.StoreGrant(grant));
|
||||
|
||||
Assert.Null(mapped.RedeemedUtc);
|
||||
Assert.Null(mapped.DeviceId);
|
||||
AssertSameProperties(grant, mapped, nameof(PushPairingGrant.ETag));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Grant_store_labels_every_date_utc_without_moving_it()
|
||||
{
|
||||
var input = WithUnspecifiedKinds(FullGrant());
|
||||
|
||||
var entity = TablePushRepository.StoreGrant(input);
|
||||
var mapped = TablePushRepository.MapGrant(entity);
|
||||
|
||||
AssertDatesStoredAsUtc(input, entity, mapped);
|
||||
}
|
||||
|
||||
// ── Watches ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Watch_round_trip_preserves_every_property()
|
||||
{
|
||||
// Lower-case inputs: TenantId, OwnerObjectId and OwnerUpn are lower-cased on store (dedicated test below).
|
||||
var watch = FullWatch();
|
||||
|
||||
var mapped = TablePushRepository.MapWatch(TablePushRepository.StoreWatch(watch));
|
||||
|
||||
AssertSameProperties(watch, mapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watch_fixture_sets_every_property_to_a_distinct_non_default_value()
|
||||
=> AssertFixtureIsDistinctAndNonDefault(FullWatch());
|
||||
|
||||
[Fact]
|
||||
public void Watch_store_writes_every_property_as_a_column_or_key()
|
||||
{
|
||||
var watch = FullWatch();
|
||||
|
||||
var entity = TablePushRepository.StoreWatch(watch);
|
||||
|
||||
Assert.Equal(watch.TenantId, entity.PartitionKey);
|
||||
// SessionId and OwnerObjectId compose the RowKey (point read and per-session range scan) and
|
||||
// are stored as columns as well, so Map never has to split the key.
|
||||
Assert.Equal($"{watch.SessionId}_{watch.OwnerObjectId}", entity.RowKey);
|
||||
AssertEveryPropertyIsStored<PushSessionWatch>(entity, WatchKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watch_store_lower_cases_tenant_owner_object_id_and_upn()
|
||||
{
|
||||
var watch = FullWatch();
|
||||
watch.TenantId = "11111111-AAAA-1111-1111-111111111111";
|
||||
watch.OwnerObjectId = "AAAAAAAA-0000-0000-0000-000000000001";
|
||||
watch.OwnerUpn = "Watcher@Contoso.invalid";
|
||||
|
||||
var entity = TablePushRepository.StoreWatch(watch);
|
||||
var mapped = TablePushRepository.MapWatch(entity);
|
||||
|
||||
Assert.Equal("11111111-aaaa-1111-1111-111111111111", entity.PartitionKey);
|
||||
Assert.Equal($"{SessionId}_aaaaaaaa-0000-0000-0000-000000000001", entity.RowKey);
|
||||
Assert.Equal("11111111-aaaa-1111-1111-111111111111", mapped.TenantId);
|
||||
Assert.Equal("aaaaaaaa-0000-0000-0000-000000000001", mapped.OwnerObjectId);
|
||||
Assert.Equal("watcher@contoso.invalid", mapped.OwnerUpn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watch_owner_scopes_list_survives_the_round_trip()
|
||||
{
|
||||
var watch = FullWatch();
|
||||
watch.OwnerScopes = $"{Constants.Push.PlatformScope};{TenantId}";
|
||||
|
||||
var mapped = TablePushRepository.MapWatch(TablePushRepository.StoreWatch(watch));
|
||||
|
||||
Assert.Equal("platform;11111111-1111-1111-1111-111111111111", mapped.OwnerScopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watch_store_labels_every_date_utc_without_moving_it()
|
||||
{
|
||||
var input = WithUnspecifiedKinds(FullWatch());
|
||||
|
||||
var entity = TablePushRepository.StoreWatch(input);
|
||||
var mapped = TablePushRepository.MapWatch(entity);
|
||||
|
||||
AssertDatesStoredAsUtc(input, entity, mapped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watch_row_without_columns_maps_to_empty_scopes_and_min_value_dates()
|
||||
{
|
||||
var mapped = TablePushRepository.MapWatch(new TableEntity(TenantId, $"{SessionId}_{OwnerObjectId}"));
|
||||
|
||||
Assert.Equal(TenantId, mapped.TenantId);
|
||||
// Legacy rows without OwnerScopes mean "the session tenant"; Map reads the columns, not the RowKey.
|
||||
Assert.Equal(string.Empty, mapped.OwnerScopes);
|
||||
Assert.Equal(string.Empty, mapped.SessionId);
|
||||
Assert.Equal(string.Empty, mapped.OwnerObjectId);
|
||||
Assert.Equal(string.Empty, mapped.OwnerUpn);
|
||||
Assert.Equal(DateTime.MinValue, mapped.CreatedUtc);
|
||||
Assert.Equal(DateTime.MinValue, mapped.ExpiresUtc);
|
||||
}
|
||||
|
||||
// ── Fixtures: a distinct non-default value for every public property ────
|
||||
|
||||
private static PushDevice FullDevice() => new()
|
||||
{
|
||||
Scope = TenantId,
|
||||
DeviceId = DeviceId,
|
||||
OwnerUpn = "owner@contoso.invalid",
|
||||
OwnerObjectId = OwnerObjectId,
|
||||
OwnerHomeTenantId = OwnerHomeTenantId,
|
||||
Kind = "apns",
|
||||
Endpoint = "https://push.contoso.invalid/send/endpoint-0001",
|
||||
P256dh = "p256dh-key-material-0001",
|
||||
Auth = "auth-secret-0001",
|
||||
DeviceSecretHash = "device-secret-hash-0001",
|
||||
VapidKid = "KID00001",
|
||||
Label = "My iPhone",
|
||||
Platform = "ios-homescreen",
|
||||
AppVersion = "1.2.3",
|
||||
Status = Constants.Push.DeviceStatus.Paused,
|
||||
StatusReason = "delivery_failures",
|
||||
PairedUtc = Anchor,
|
||||
ConfirmedUtc = Anchor.AddSeconds(1),
|
||||
LastDeliveredUtc = Anchor.AddSeconds(2),
|
||||
LastOpenedUtc = Anchor.AddSeconds(3),
|
||||
StatusChangedUtc = Anchor.AddSeconds(4),
|
||||
LastStatusCode = 410,
|
||||
ConsecutiveFailures = 3,
|
||||
WindowStartUtc = Anchor.AddSeconds(5),
|
||||
WindowCount = 7,
|
||||
SuppressedCount = 2,
|
||||
};
|
||||
|
||||
private static PushPairingGrant FullGrant() => new()
|
||||
{
|
||||
CodeHash = "code-hash-base64url-0001",
|
||||
TenantId = TenantId,
|
||||
OwnerUpn = "admin@contoso.invalid",
|
||||
OwnerObjectId = OwnerObjectId,
|
||||
OwnerHomeTenantId = OwnerHomeTenantId,
|
||||
Status = Constants.Push.PairingStatus.Redeemed,
|
||||
CreatedUtc = Anchor,
|
||||
ExpiresUtc = Anchor.AddMinutes(Constants.Push.PairingGrantMinutes),
|
||||
RedeemedUtc = Anchor.AddSeconds(42),
|
||||
DeviceId = DeviceId,
|
||||
FailedAttempts = 2,
|
||||
ETag = "W/\"datetime'2026-10-07T08%3A30%3A00.0000000Z'\"",
|
||||
};
|
||||
|
||||
private static PushSessionWatch FullWatch() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OwnerObjectId = OwnerObjectId,
|
||||
OwnerUpn = "watcher@contoso.invalid",
|
||||
OwnerScopes = $"{Constants.Push.PlatformScope};{OwnerHomeTenantId}",
|
||||
CreatedUtc = Anchor,
|
||||
ExpiresUtc = Anchor.AddHours(6),
|
||||
};
|
||||
|
||||
// ── Reflection helpers ──────────────────────────────────────────────────
|
||||
|
||||
private static PropertyInfo[] PublicProperties<T>()
|
||||
=> typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
private static IEnumerable<PropertyInfo> DateProperties<T>()
|
||||
=> PublicProperties<T>().Where(p => p.PropertyType == typeof(DateTime) || p.PropertyType == typeof(DateTime?));
|
||||
|
||||
/// <summary>Every public property of <paramref name="expected"/> equals the one on <paramref name="actual"/>, except the named ones.</summary>
|
||||
private static void AssertSameProperties<T>(T expected, T actual, params string[] except) where T : class
|
||||
{
|
||||
var mismatches = PublicProperties<T>()
|
||||
.Where(p => !except.Contains(p.Name))
|
||||
.Where(p => !Equals(p.GetValue(expected), p.GetValue(actual)))
|
||||
.Select(p => $"{typeof(T).Name}.{p.Name}: expected '{p.GetValue(expected)}', got '{p.GetValue(actual)}'")
|
||||
.ToList();
|
||||
Assert.Empty(mismatches);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The round trip is only as strong as its input: a property left at its default would survive a
|
||||
/// dropped column unnoticed, and two properties sharing a value would hide swapped columns.
|
||||
/// </summary>
|
||||
private static void AssertFixtureIsDistinctAndNonDefault<T>(T fixture) where T : class, new()
|
||||
{
|
||||
var blank = new T();
|
||||
var atDefault = PublicProperties<T>()
|
||||
.Where(p => Equals(p.GetValue(fixture), p.GetValue(blank)))
|
||||
.Select(p => p.Name)
|
||||
.ToList();
|
||||
Assert.Empty(atDefault);
|
||||
|
||||
var duplicates = PublicProperties<T>()
|
||||
.Select(p => p.GetValue(fixture))
|
||||
.OfType<object>()
|
||||
.GroupBy(v => v)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => $"{g.Key}")
|
||||
.ToList();
|
||||
Assert.Empty(duplicates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every public property of <typeparamref name="T"/> lands in the entity: as the column of the same
|
||||
/// name, or under its documented key mapping. A property added to the model without a Store line fails here.
|
||||
/// </summary>
|
||||
private static void AssertEveryPropertyIsStored<T>(TableEntity entity, IReadOnlyDictionary<string, string> keyMappings, params string[] notStored)
|
||||
{
|
||||
var missing = PublicProperties<T>()
|
||||
.Where(p => !notStored.Contains(p.Name))
|
||||
.Select(p => keyMappings.GetValueOrDefault(p.Name) ?? p.Name)
|
||||
.Where(column => !entity.ContainsKey(column))
|
||||
.ToList();
|
||||
Assert.Empty(missing);
|
||||
}
|
||||
|
||||
/// <summary>Relabels every date of the model as Unspecified without moving it — a value parsed from JSON without a zone designator.</summary>
|
||||
private static T WithUnspecifiedKinds<T>(T model) where T : class
|
||||
{
|
||||
foreach (var p in DateProperties<T>())
|
||||
{
|
||||
if (p.GetValue(model) is DateTime dt)
|
||||
p.SetValue(model, DateTime.SpecifyKind(dt, DateTimeKind.Unspecified));
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store labels every date Utc (Edm.DateTime is UTC-only; an Unspecified or Local value would be
|
||||
/// serialised with a shift or without a zone): every DateTime column of the entity and every date
|
||||
/// of the mapped model carries Kind Utc with the input's ticks untouched.
|
||||
/// </summary>
|
||||
private static void AssertDatesStoredAsUtc<T>(T input, TableEntity entity, T mapped) where T : class
|
||||
{
|
||||
var nonUtcColumns = entity.Keys
|
||||
.Where(key => entity[key] is DateTime dt && dt.Kind != DateTimeKind.Utc)
|
||||
.ToList();
|
||||
Assert.Empty(nonUtcColumns);
|
||||
|
||||
var mismatches = new List<string>();
|
||||
foreach (var p in DateProperties<T>())
|
||||
{
|
||||
if (p.GetValue(input) is not DateTime expected)
|
||||
continue;
|
||||
if (p.GetValue(mapped) is not DateTime actual || actual.Kind != DateTimeKind.Utc || actual.Ticks != expected.Ticks)
|
||||
mismatches.Add($"{p.Name}: expected ticks {expected.Ticks} as Utc, got '{p.GetValue(mapped)}'");
|
||||
}
|
||||
Assert.Empty(mismatches);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user