diff --git a/.claude/commands b/.claude/commands
index 21d29fdc0..3b7e7c9fc 160000
--- a/.claude/commands
+++ b/.claude/commands
@@ -1 +1 @@
-Subproject commit 21d29fdc0341868f7f5fa3aa424bfdca7188e86b
+Subproject commit 3b7e7c9fc08405cdbae849d627ad78764b3afdee
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index bd776a791..673f3c6ac 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 94774ce8a..977f2f487 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/combine-rules.yml b/.github/workflows/combine-rules.yml
index cffa7ff12..054999ec8 100644
--- a/.github/workflows/combine-rules.yml
+++ b/.github/workflows/combine-rules.yml
@@ -32,41 +32,18 @@ jobs:
# 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"
-
- - 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
+ run: npm ci --ignore-scripts
- # 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
diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln
index e5705e256..a52279b38 100644
--- a/AutopilotMonitor.sln
+++ b/AutopilotMonitor.sln
@@ -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
diff --git a/infra b/infra
index be0165a22..d8111089f 160000
--- a/infra
+++ b/infra
@@ -1 +1 @@
-Subproject commit be0165a223119dc276291bb0a9910f38a6fdd0a2
+Subproject commit d8111089f4c4e1cca54083299d466207cfe7483c
diff --git a/internal b/internal
index d951f3406..f0a39d58f 160000
--- a/internal
+++ b/internal
@@ -1 +1 @@
-Subproject commit d951f3406f0719fd76e714e731b8b84c7afe374a
+Subproject commit f0a39d58f763b149006f62dcabd723f0f7bf55ce
diff --git a/rules/scripts/.npmrc b/rules/scripts/.npmrc
deleted file mode 100644
index aae79898a..000000000
--- a/rules/scripts/.npmrc
+++ /dev/null
@@ -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
diff --git a/rules/scripts/package-lock.json b/rules/scripts/package-lock.json
index 4aecc97e8..ae352c5f7 100644
--- a/rules/scripts/package-lock.json
+++ b/rules/scripts/package-lock.json
@@ -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"
}
}
}
diff --git a/rules/scripts/package.json b/rules/scripts/package.json
index 45c1c6553..cfb8e2fb2 100644
--- a/rules/scripts/package.json
+++ b/rules/scripts/package.json
@@ -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"
}
}
diff --git a/rules/scripts/validate.js b/rules/scripts/validate.js
new file mode 100644
index 000000000..dd8f60342
--- /dev/null
+++ b/rules/scripts/validate.js
@@ -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);
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/NotificationDispatchHandlerTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/NotificationDispatchHandlerTests.cs
index 75846d2ef..c73c52527 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/NotificationDispatchHandlerTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/NotificationDispatchHandlerTests.cs
@@ -19,8 +19,9 @@ namespace AutopilotMonitor.Functions.Tests;
///
/// 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.
///
public class NotificationDispatchHandlerTests
{
@@ -41,6 +42,21 @@ public async Task Sends_to_enabled_channels_matching_the_envelope_ids_only()
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 void Envelope_round_trips_through_newtonsoft()
private sealed class Harness
{
public List<(IReadOnlyList Channels, NotificationAlert Alert)> Sent { get; } = new();
+ public List Scopes { get; } = new();
public NotificationDispatchHandler Sut { get; }
public Harness(IEnumerable? channels)
@@ -138,9 +155,9 @@ public Harness(IEnumerable? channels)
.ReturnsAsync((config, true));
}
- var dispatcher = new Mock(null!, null!);
- dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny>(), It.IsAny()))
- .Callback, NotificationAlert>((c, a) => Sent.Add((c.ToList(), a)))
+ var dispatcher = new Mock(null!, null!, null!);
+ dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny>(), It.IsAny(), It.IsAny()))
+ .Callback, NotificationAlert, NotificationScope>((c, a, s) => { Sent.Add((c.ToList(), a)); Scopes.Add(s); })
.Returns(Task.CompletedTask);
Sut = new NotificationDispatchHandler(
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OpsAlertPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OpsAlertPayloadTests.cs
index 498fe713a..278ab232b 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/OpsAlertPayloadTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/OpsAlertPayloadTests.cs
@@ -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;
///
/// 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.
///
public class OpsAlertPayloadTests
@@ -132,4 +135,39 @@ public void TelegramRendering_IncludesFlattenedFacts()
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 { "ops" } },
+ new OpsAlertRule { EventType = "TenantTrialStarted", MinSeverity = OpsEventSeverity.Info, NotifyChannelIds = new List { "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);
+ }
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OpsNotificationChannelsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OpsNotificationChannelsTests.cs
index 612417c63..ca261a4db 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/OpsNotificationChannelsTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/OpsNotificationChannelsTests.cs
@@ -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;
///
/// 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.
///
public class OpsNotificationChannelsTests
@@ -266,4 +272,58 @@ public void RestoreRedactedList_PassesThroughWhenNoSentinelPresent()
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));
+ }
+
+ ///
+ /// against a recorded dispatcher: the admin
+ /// configuration carries the given rules and channels, every send is captured with its scope.
+ ///
+ internal sealed class DispatchHarness
+ {
+ public List<(IReadOnlyList Channels, NotificationAlert Alert, NotificationScope Scope)> Sends { get; } = new();
+ public OpsAlertDispatchService Sut { get; }
+
+ public DispatchHarness(IEnumerable rules, IEnumerable channels)
+ {
+ var config = new AdminConfiguration
+ {
+ UpdatedBy = "test",
+ OpsAlertRulesJson = JsonSerializer.Serialize(rules),
+ OpsNotificationChannelsJson = NotificationChannel.SerializeList(channels),
+ };
+ var adminConfig = new Mock(
+ Mock.Of(), NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()));
+ adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(config);
+
+ var dispatcher = new Mock(null!, null!, null!);
+ dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny>(), It.IsAny(), It.IsAny()))
+ .Callback, NotificationAlert, NotificationScope>((c, a, s) => Sends.Add((c.ToList(), a, s)))
+ .Returns(Task.CompletedTask);
+
+ Sut = new OpsAlertDispatchService(adminConfig.Object, dispatcher.Object, NullLogger.Instance);
+ }
+ }
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/InMemoryPushDeviceRepository.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/InMemoryPushDeviceRepository.cs
new file mode 100644
index 000000000..3f5551369
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/InMemoryPushDeviceRepository.cs
@@ -0,0 +1,177 @@
+using AutopilotMonitor.Shared.DataAccess;
+
+namespace AutopilotMonitor.Functions.Tests.Push;
+
+///
+/// In-memory with the one storage semantic the push flow
+/// depends on: the grant ETag. succeeds only with the ETag the
+/// grant was read with, so the one-shot redeem race is testable.
+///
+internal sealed class InMemoryPushDeviceRepository : IPushDeviceRepository
+{
+ public Dictionary<(string Scope, string DeviceId), PushDevice> Devices { get; } = new();
+ public Dictionary 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 GetDeviceAsync(string scope, string deviceId)
+ => Task.FromResult(Devices.TryGetValue((scope, deviceId), out var d) ? Clone(d) : null);
+
+ public Task> 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 MutateDeviceAsync(string scope, string deviceId, Func?> 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 GetGrantAsync(string codeHash)
+ {
+ if (!Grants.TryGetValue(codeHash, out var entry))
+ return Task.FromResult(null);
+ var copy = Clone(entry.Grant);
+ copy.ETag = entry.Version.ToString();
+ return Task.FromResult(copy);
+ }
+
+ public Task AddGrantAsync(PushPairingGrant grant)
+ {
+ Grants[grant.CodeHash] = (Clone(grant), 1);
+ return Task.CompletedTask;
+ }
+
+ /// Runs once before the next grant update — a concurrent writer between read and write.
+ public Action? BeforeGrantUpdate { get; set; }
+
+ /// Bumps the stored version of a grant as a concurrent writer would.
+ public void TouchGrant(string codeHash)
+ {
+ if (Grants.TryGetValue(codeHash, out var entry))
+ Grants[codeHash] = (entry.Grant, entry.Version + 1);
+ }
+
+ public Task 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> GetExpiredGrantsAsync(DateTime cutoffUtc)
+ => Task.FromResult(Grants.Values.Where(g => g.Grant.ExpiresUtc < cutoffUtc).Select(g => Clone(g.Grant)).ToList());
+
+ public Task 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 GetWatchAsync(string tenantId, string sessionId, string objectId)
+ => Task.FromResult(Watches.TryGetValue((tenantId, sessionId, objectId), out var w) ? w : null);
+
+ public Task> GetWatchesForSessionAsync(string tenantId, string sessionId)
+ => Task.FromResult(Watches.Values.Where(w => w.TenantId == tenantId && w.SessionId == sessionId).ToList());
+
+ public Task> GetAllDevicesAsync()
+ => Task.FromResult(Devices.Values.Select(Clone).ToList());
+
+ public Task> 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;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs
new file mode 100644
index 000000000..1c99e1e47
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushAlertProjectorTests.cs
@@ -0,0 +1,191 @@
+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;
+
+///
+/// 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).
+///
+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")));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushChannelRoutingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushChannelRoutingTests.cs
new file mode 100644
index 000000000..059a3133e
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushChannelRoutingTests.cs
@@ -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;
+
+///
+/// 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.
+///
+public class PushChannelRoutingTests
+{
+ private static readonly NotificationScope Tenant = NotificationScope.Tenant("11111111-1111-1111-1111-111111111111");
+
+ private static Mock WebhookMock()
+ => new(new HttpClient(), NullLogger.Instance) { CallBase = false };
+
+ private static Mock TelegramMock()
+ => new(new HttpClient(), Mock.Of(), NullLogger.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();
+ 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(), It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny()), Times.Never);
+ telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Push_channel_without_url_is_not_skipped_as_destination_less()
+ {
+ var push = new Mock();
+ push.Setup(p => p.SendWithResultAsync(It.IsAny(), It.IsAny()))
+ .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()), 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);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushDeliveryServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushDeliveryServiceTests.cs
new file mode 100644
index 000000000..9fe50e60e
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushDeliveryServiceTests.cs
@@ -0,0 +1,584 @@
+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;
+
+///
+/// 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.
+///
+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);
+ h.AddDevice("d4", Constants.Push.DeviceStatus.Active, scope: Constants.Push.PlatformScope);
+
+ 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(), It.IsAny())).ReturnsAsync(false);
+ h.Eligibility.Setup(e => e.IsOwnerFreshAsync(TenantId, "oid-inactive@contoso.invalid", It.IsAny())).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(), 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(), It.IsAny()), 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.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(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(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(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(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(), It.IsAny())).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())).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" });
+
+ /// The PushPairing lifecycle events only — every send also tracks a PushDelivery event.
+ private static List PairingEvents(CapturingChannel channel)
+ => channel.Items.OfType().Where(e => e.Name == "PushPairing").ToList();
+
+ /// Captures what the service tracks (the startup-telemetry test keeps its own copy private).
+ private sealed class CapturingChannel : ITelemetryChannel
+ {
+ public List 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() { }
+ }
+
+ 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 Eligibility { get; }
+ public Mock OpsEvents { get; }
+ public PushPairingServiceTests.RecordingHandler Http { get; } = new();
+ public PushDeliveryService Sut { get; }
+ public PushMaintenanceService Maintenance { get; }
+ private readonly (string P256dh, string Auth) _keys;
+
+ /// Optional: a client over a capturing channel to observe what the service tracks.
+ public Harness(TelemetryClient? telemetry = null)
+ {
+ Settings = PushSettings.ForKeys(Keys);
+ Eligibility = new Mock(null!, null!, null!, null!, null!) { CallBase = false };
+ Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ Eligibility.Setup(e => e.IsOwnerFreshAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ OpsEvents = new Mock(null!, null!, null!) { CallBase = false };
+ OpsEvents.Setup(o => o.RecordPushDeliveryFailedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask);
+
+ Sut = new PushDeliveryService(Repo, Eligibility.Object, Settings, new WebPushSender(new HttpClient(Http)),
+ new Lazy(() => OpsEvents.Object), new MemoryCache(new MemoryCacheOptions()),
+ NullLogger.Instance, telemetry);
+ Maintenance = new PushMaintenanceService(Repo, Settings, Sut, NullLogger.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)));
+ }
+
+ /// The owner's PushOwners stamp, keyed the way AddDevice derives the object id from the UPN.
+ 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;
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushEligibilityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushEligibilityTests.cs
new file mode 100644
index 000000000..d68623632
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushEligibilityTests.cs
@@ -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;
+
+///
+/// The real 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).
+///
+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()), 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()), 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(), It.IsAny()), 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(), It.IsAny()), 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(), It.IsAny()), 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(), It.IsAny()), 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()), 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 AdminRepo { get; } = new();
+ public InMemoryPushDeviceRepository Repo { get; } = new();
+ public Mock TenantConfig { get; }
+ public Mock AdminConfig { get; }
+ public PushSettings Settings { get; }
+ public PushEligibility Sut { get; }
+
+ public Harness()
+ {
+ Settings = PushSettings.ForKeys(new VapidKeyRing(VapidKey.Generate()));
+
+ TenantConfig = new Mock(null!, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()), null!) { CallBase = false };
+
+ AdminConfig = new Mock(Mock.Of(), NullLogger.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,
+ };
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs
new file mode 100644
index 000000000..a1afd1064
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushPairingServiceTests.cs
@@ -0,0 +1,340 @@
+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;
+
+///
+/// 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.
+///
+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);
+ /// Same scope, same admin role, different object id — not the grant's creator.
+ 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 { ["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 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(), It.IsAny()), 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(), It.IsAny()), 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; } = new();
+ public VapidKeyRing Keys { get; } = new(VapidKey.Generate());
+ public Mock Eligibility { get; }
+ public Mock OpsEvents { get; }
+ public RecordingHandler Http { get; } = new();
+ public PushPairingService Sut { get; }
+ public PushDeliveryService Delivery { get; }
+ public (string Endpoint, string P256dh, string Auth) Subscription { get; }
+
+ public Harness()
+ {
+ var settings = PushSettings.ForKeys(Keys);
+ Eligibility = new Mock(null!, null!, null!, null!, null!) { CallBase = false };
+ Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny())).ReturnsAsync(true);
+ Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ Eligibility.Setup(e => e.IsOwnerFreshAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+
+ OpsEvents = new Mock(null!, null!, null!) { CallBase = false };
+ OpsEvents.Setup(o => o.RecordPushEndpointRefusedAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask);
+ OpsEvents.Setup(o => o.RecordPushDeliveryFailedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask);
+
+ var sender = new WebPushSender(new HttpClient(Http) { BaseAddress = null });
+ Delivery = new PushDeliveryService(Repo, Eligibility.Object, settings, sender,
+ new Lazy(() => OpsEvents.Object), new MemoryCache(new MemoryCacheOptions()),
+ NullLogger.Instance);
+
+ var tenantConfig = new Mock(null!, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()), null!) { CallBase = false };
+ tenantConfig.Setup(t => t.TryGetConfigurationAsync(It.IsAny()))
+ .ReturnsAsync((new AutopilotMonitor.Shared.Models.TenantConfiguration { TenantId = TenantId, CompanyName = "Contoso" }, true));
+
+ Sut = new PushPairingService(Repo, Eligibility.Object, settings, Delivery, OpsEvents.Object,
+ Mock.Of(), tenantConfig.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger.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",
+ };
+ }
+
+ /// Records every request (and its body, read before the sender disposes it) and answers with a configurable status (201 by default).
+ internal sealed class RecordingHandler : HttpMessageHandler
+ {
+ public List Requests { get; } = new();
+ public List Bodies { get; } = new();
+ public Func Respond { get; set; } = _ => HttpStatusCode.Created;
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Requests.Add(request);
+ Bodies.Add(request.Content == null ? Array.Empty() : await request.Content.ReadAsByteArrayAsync(cancellationToken));
+ return new HttpResponseMessage(Respond(request)) { Content = new StringContent(string.Empty) };
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushReceiverFunctionsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushReceiverFunctionsTests.cs
new file mode 100644
index 000000000..f82e26be4
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushReceiverFunctionsTests.cs
@@ -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;
+
+///
+/// The receiver's own routes over HTTP. On push/device the token in
+/// 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 push/pair/begin gives one 404 for an unusable code and is throttled per client IP
+/// (the trusted X-Forwarded-For hop, never a client-written prefix).
+///
+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(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(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(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(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(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(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 ReadAsync(HttpResponseData response)
+ {
+ response.Body.Position = 0;
+ return (await JsonSerializer.DeserializeAsync(response.Body, ApiJsonOptions.Read))!;
+ }
+
+ private static async Task BodyTextAsync(HttpResponseData response)
+ {
+ response.Body.Position = 0;
+ using var reader = new StreamReader(response.Body, leaveOpen: true);
+ return await reader.ReadToEndAsync();
+ }
+
+ ///
+ /// The functions over the pairing-service harness (in-memory repository, fake push transport)
+ /// and a real — one instance per test, so the throttle buckets
+ /// are the test's own.
+ ///
+ 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.Instance);
+ Sut = new PushReceiverFunctions(Pairing.Sut, rateLimit, NullLogger.Instance);
+ }
+
+ public InMemoryPushDeviceRepository Repo => Pairing.Repo;
+
+ /// create → redeem → confirm: an Active device, the token its phone holds, and the stored row (the repository mutates it in place).
+ 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,
+ });
+
+ /// A device-token route request: the token in its header (none by default), an optional JSON body.
+ public static HttpRequestData Request(string? token = null, string? body = null)
+ {
+ var headers = new Dictionary();
+ if (token != null)
+ headers[Constants.Push.DeviceTokenHeader] = token;
+ return EndpointHarness.Request(TenantId, jsonBody: body, headers: headers).Req;
+ }
+
+ /// An anonymous begin from one phone; X-Forwarded-For is the hop the IP throttle keys on.
+ public static HttpRequestData BeginRequest(string code, string forwardedFor)
+ => EndpointHarness.Request(TenantId, jsonBody: TestWire.Serialize(new BeginPairRequest { Code = code }),
+ headers: new Dictionary { ["X-Forwarded-For"] = forwardedFor }).Req;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushRoleLossHookTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushRoleLossHookTests.cs
new file mode 100644
index 000000000..eb6917d45
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushRoleLossHookTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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();
+ h.AdminRepo.Setup(r => r.RemoveTenantMemberAsync(TenantId, Upn)).Callback(() => order.Add("row")).ReturnsAsync(true);
+ h.Revoker.Setup(r => r.RevokeOwnerAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .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(), It.IsAny()), 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 revoker, NotificationScope scope, string upn, string reason)
+ {
+ revoker.Verify(r => r.RevokeOwnerAsync(It.Is(s => s == scope), upn, reason), Times.Once);
+ revoker.VerifyNoOtherCalls();
+ }
+
+ private static void AssertNeverRevoked(Mock revoker)
+ => revoker.Verify(r => r.RevokeOwnerAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+
+ // ── Harnesses ────────────────────────────────────────────────────────────
+
+ private sealed class TenantHarness
+ {
+ public Mock AdminRepo { get; } = new(MockBehavior.Loose);
+ public Mock Revoker { get; } = new(MockBehavior.Loose);
+ public TenantAdminsService Sut { get; }
+
+ public TenantHarness(bool withRevoker = true)
+ {
+ AdminRepo.Setup(r => r.RemoveTenantMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ AdminRepo.Setup(r => r.SetTenantMemberEnabledAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ // The hook fires only after a successful write, so the default answer is "row updated".
+ AdminRepo.Setup(r => r.UpdateMemberPermissionsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ Revoker.Setup(r => r.RevokeOwnerAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ Sut = withRevoker
+ ? new TenantAdminsService(AdminRepo.Object, cache, NullLogger.Instance, Revoker.Object)
+ : new TenantAdminsService(AdminRepo.Object, cache, NullLogger.Instance);
+ }
+
+ /// States the member's current row. The service does not read it today; the arrange stays honest if it ever does.
+ 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 AdminRepo { get; } = new(MockBehavior.Loose);
+ public Mock Revoker { get; } = new(MockBehavior.Loose);
+ public GlobalAdminService Sut { get; }
+
+ public GlobalHarness(bool withRevoker = true)
+ {
+ AdminRepo.Setup(r => r.RemoveGlobalAdminAsync(It.IsAny())).ReturnsAsync(true);
+ AdminRepo.Setup(r => r.DisableGlobalAdminAsync(It.IsAny())).ReturnsAsync(true);
+ Revoker.Setup(r => r.RevokeOwnerAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask);
+
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var bindings = new AdminIdentityBindingService(AdminRepo.Object, cache, NullLogger.Instance);
+ Sut = withRevoker
+ ? new GlobalAdminService(AdminRepo.Object, bindings, cache, NullLogger.Instance, Revoker.Object)
+ : new GlobalAdminService(AdminRepo.Object, bindings, cache, NullLogger.Instance);
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushRoutePolicyTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushRoutePolicyTests.cs
new file mode 100644
index 000000000..eb66c2f4c
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushRoutePolicyTests.cs
@@ -0,0 +1,161 @@
+using System.Text.RegularExpressions;
+using AutopilotMonitor.Functions.Middleware;
+using AutopilotMonitor.Functions.Security;
+
+namespace AutopilotMonitor.Functions.Tests.Push;
+
+///
+/// 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 Cache-Control: no-store.
+///
+public class PushRoutePolicyTests
+{
+ private const string SampleId = "22222222-2222-2222-2222-222222222222";
+
+ /// The 22 push routes, as "METHOD template" — the one list the surface test compares against.
+ 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 ──────────────────────────────────────────────────────────────────────────────
+
+ /// A request path for the template: every {param} becomes a GUID-shaped sample, "/api/" in front.
+ private static string ConcretePath(string template)
+ => "/api/" + Regex.Replace(template, @"\{[^}]+}", SampleId);
+
+ /// Every catalog entry on the push surface: push/*, global/push/* and the session watches.
+ private static IEnumerable 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}";
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs
new file mode 100644
index 000000000..08895b57c
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/PushSessionWatchServiceTests.cs
@@ -0,0 +1,446 @@
+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;
+
+///
+/// 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.
+///
+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())).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 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();
+ repo.Setup(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId)).ReturnsAsync(new List());
+ 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())).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();
+ repo.Setup(r => r.GetWatchesForSessionAsync(It.IsAny(), It.IsAny())).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 Eligibility { get; }
+ public PushPairingServiceTests.RecordingHandler Http { get; } = new();
+ public PushSessionWatchService Sut { get; }
+ private readonly (string P256dh, string Auth) _subscription;
+
+ /// The repository both services run on; defaults to , a mock for the fail-soft cases.
+ public Harness(IPushDeviceRepository? repo = null)
+ {
+ repo ??= Repo;
+ Eligibility = new Mock(null!, null!, null!, null!, null!) { CallBase = false };
+ Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny())).ReturnsAsync(true);
+ Eligibility.Setup(e => e.IsEligibleAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+ Eligibility.Setup(e => e.IsOwnerFreshAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true);
+
+ var opsEvents = new Mock(null!, null!, null!) { CallBase = false };
+ opsEvents.Setup(o => o.RecordPushDeliveryFailedAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask);
+
+ var delivery = new PushDeliveryService(repo, Eligibility.Object, PushSettings.ForKeys(Keys), new WebPushSender(new HttpClient(Http)),
+ new Lazy(() => opsEvents.Object), new MemoryCache(new MemoryCacheOptions()),
+ NullLogger.Instance);
+ Sut = new PushSessionWatchService(repo, Eligibility.Object, delivery, NullLogger.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;
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Push/TablePushRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Push/TablePushRepositoryMappingTests.cs
new file mode 100644
index 000000000..608506f9f
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Push/TablePushRepositoryMappingTests.cs
@@ -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;
+
+///
+/// Store → Map round trip of for the three models it projects
+/// (, , ;
+/// 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.
+///
+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 DeviceKeys = new() { ["Scope"] = "PartitionKey", ["DeviceId"] = "RowKey" };
+ private static readonly Dictionary GrantKeys = new() { ["CodeHash"] = "RowKey" };
+ private static readonly Dictionary 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(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().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().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(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(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()
+ => typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
+
+ private static IEnumerable DateProperties()
+ => PublicProperties().Where(p => p.PropertyType == typeof(DateTime) || p.PropertyType == typeof(DateTime?));
+
+ /// Every public property of equals the one on , except the named ones.
+ private static void AssertSameProperties(T expected, T actual, params string[] except) where T : class
+ {
+ var mismatches = PublicProperties()
+ .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);
+ }
+
+ ///
+ /// 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.
+ ///
+ private static void AssertFixtureIsDistinctAndNonDefault(T fixture) where T : class, new()
+ {
+ var blank = new T();
+ var atDefault = PublicProperties()
+ .Where(p => Equals(p.GetValue(fixture), p.GetValue(blank)))
+ .Select(p => p.Name)
+ .ToList();
+ Assert.Empty(atDefault);
+
+ var duplicates = PublicProperties()
+ .Select(p => p.GetValue(fixture))
+ .OfType