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() + .GroupBy(v => v) + .Where(g => g.Count() > 1) + .Select(g => $"{g.Key}") + .ToList(); + Assert.Empty(duplicates); + } + + /// + /// Every public property of lands in the entity: as the column of the same + /// name, or under its documented key mapping. A property added to the model without a Store line fails here. + /// + private static void AssertEveryPropertyIsStored(TableEntity entity, IReadOnlyDictionary keyMappings, params string[] notStored) + { + var missing = PublicProperties() + .Where(p => !notStored.Contains(p.Name)) + .Select(p => keyMappings.GetValueOrDefault(p.Name) ?? p.Name) + .Where(column => !entity.ContainsKey(column)) + .ToList(); + Assert.Empty(missing); + } + + /// Relabels every date of the model as Unspecified without moving it — a value parsed from JSON without a zone designator. + private static T WithUnspecifiedKinds(T model) where T : class + { + foreach (var p in DateProperties()) + { + if (p.GetValue(model) is DateTime dt) + p.SetValue(model, DateTime.SpecifyKind(dt, DateTimeKind.Unspecified)); + } + return model; + } + + /// + /// Store labels every date Utc (Edm.DateTime is UTC-only; an Unspecified or Local value would be + /// serialised with a shift or without a zone): every DateTime column of the entity and every date + /// of the mapped model carries Kind Utc with the input's ticks untouched. + /// + private static void AssertDatesStoredAsUtc(T input, TableEntity entity, T mapped) where T : class + { + var nonUtcColumns = entity.Keys + .Where(key => entity[key] is DateTime dt && dt.Kind != DateTimeKind.Utc) + .ToList(); + Assert.Empty(nonUtcColumns); + + var mismatches = new List(); + foreach (var p in DateProperties()) + { + if (p.GetValue(input) is not DateTime expected) + continue; + if (p.GetValue(mapped) is not DateTime actual || actual.Kind != DateTimeKind.Utc || actual.Ticks != expected.Ticks) + mismatches.Add($"{p.Name}: expected ticks {expected.Ticks} as Utc, got '{p.GetValue(mapped)}'"); + } + Assert.Empty(mismatches); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs index 8d43e06fd..9891f7153 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SlaTenantStatusLifecycleTests.cs @@ -7,6 +7,7 @@ using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Notifications; using AutopilotMonitor.Shared.Pagination; using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Extensibility; @@ -20,6 +21,8 @@ namespace AutopilotMonitor.Functions.Tests; /// Lifecycle tests for + . /// Locks the persistent-state semantics: one row per tenant; per-breach-type fields drive /// throttle + resolve detection; cooldown comes from AdminConfiguration.SlaNotificationCooldownHours. +/// Every channel send (breach, resolve, inline consecutive failures) runs on the evaluated +/// tenant's scope — the Push transport trusts it, so it is pinned here. /// public class SlaTenantStatusLifecycleTests { @@ -27,6 +30,28 @@ public class SlaTenantStatusLifecycleTests // ── Fakes / helpers ───────────────────────────────────────────────────── + private sealed record ChannelSend(IReadOnlyList Channels, NotificationAlert Alert, NotificationScope Scope); + + /// A dispatcher that records every send with its scope instead of reaching a transport. + private static NotificationChannelDispatcher RecordingDispatcher(List sink) + { + var dispatcher = new Mock(null!, null!, null!); + dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, NotificationAlert, NotificationScope>((c, a, s) => sink.Add(new ChannelSend(c.ToList(), a, s))) + .Returns(Task.CompletedTask); + return dispatcher.Object; + } + + /// Attaches one enabled SLA channel so the breach/resolve path reaches the dispatcher. + private static TenantConfiguration WithSlaChannel(TenantConfiguration config) + { + config.NotificationChannelsJson = NotificationChannel.SerializeList(new[] + { + new NotificationChannel { Id = "sla", Name = "sla", ProviderType = 20, Url = "https://hooks.example.invalid/sla", Enabled = true, NotifyOnSlaEvents = true }, + }); + return config; + } + private sealed class InMemorySlaStatusRepository : ISlaTenantStatusRepository { private readonly Dictionary _rows = new(StringComparer.OrdinalIgnoreCase); @@ -136,6 +161,8 @@ private sealed class Harness private readonly Mock _adminConfig; public List<(string Type, string Title, string Message, string? Href)> NotificationsSent { get; } = new(); + /// Every dispatcher send, with the scope the service passed. + public List ChannelSends { get; } = new(); public Harness(int cooldownHours = 24, DateTime? clock = null) { @@ -167,8 +194,7 @@ public Harness(int cooldownHours = 24, DateTime? clock = null) var opsRepo = new Mock(); opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())).Returns(Task.CompletedTask); - var webhook = new WebhookNotificationService(new HttpClient(), NullLogger.Instance); - var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance)); + var channelDispatcher = RecordingDispatcher(ChannelSends); var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfig.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); @@ -469,9 +495,11 @@ public async Task PartialResolve_OneTypeResolves_OtherStaysActive() // Builds an inline-path SlaBreachEvaluationService wired to the shared StatusRepo, a // TenantConfigurationService backed by `config`, and a session page of `recentPage`. - // Returns the service plus the captured in-app notification types for assertions. + // Returns the service plus the captured in-app notification types for assertions; channel + // sends (with their scope) land in `channelSends` when one is passed. private static (SlaBreachEvaluationService Service, List Captured) BuildInlineService( - InMemorySlaStatusRepository statusRepo, TenantConfiguration config, List recentPage) + InMemorySlaStatusRepository statusRepo, TenantConfiguration config, List recentPage, + List? channelSends = null) { var memCache = new MemoryCache(new MemoryCacheOptions()); var configRepoMock = new Mock(); @@ -493,8 +521,7 @@ private static (SlaBreachEvaluationService Service, List Captured) Build sessionRepo.Setup(r => r.GetSessionsPageAsync(TenantId, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new RawPage(recentPage, null)); - var webhook = new WebhookNotificationService(new HttpClient(), NullLogger.Instance); - var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance)); + var channelDispatcher = RecordingDispatcher(channelSends ?? new List()); var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminCfg.Object); var opsRepo = new Mock(); opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())).Returns(Task.CompletedTask); @@ -986,7 +1013,7 @@ public HarnessWithOpsCapture(List<(int Total, int Failed)> totalsCapture) .Returns(Task.CompletedTask); var webhook = new WebhookNotificationService(new HttpClient(), NullLogger.Instance); - var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance)); + var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance), Mock.Of()); var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object); var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); @@ -1062,4 +1089,53 @@ public async Task AppInstallBreach_BackAboveTarget_FiresResolved() Assert.NotNull(row.AppInstall_ResolvedAt); Assert.Single(h.NotificationsSent, n => n.Type == "sla_resolved"); } + + // ── Channel dispatch scope ────────────────────────────────────────────── + + [Fact] + public async Task BreachChannelSend_RunsOnTheEvaluatedTenantsScope() + { + var h = new Harness(); + h.SetTenants(WithSlaChannel(CreateConfig(successRate: true))); + h.SetTerminalSessions(TenantId, Sessions(succeeded: 8, failed: 4)); // 66.7% < 95% + + await h.Service.EvaluateAllTenantsAsync(); + + var send = Assert.Single(h.ChannelSends); + Assert.Equal("sla_breach", send.Alert.EventType); + Assert.Equal("sla", Assert.Single(send.Channels).Id); + Assert.Equal(NotificationScope.Tenant(TenantId), send.Scope); + } + + [Fact] + public async Task ResolvedChannelSend_RunsOnTheEvaluatedTenantsScope() + { + var h = new Harness(); + h.SetTenants(WithSlaChannel(CreateConfig(successRate: true))); + h.SetTerminalSessions(TenantId, Sessions(succeeded: 8, failed: 4)); + await h.Service.EvaluateAllTenantsAsync(); + h.ChannelSends.Clear(); + + h.SetTerminalSessions(TenantId, Sessions(succeeded: 12, failed: 0)); + await h.Service.EvaluateAllTenantsAsync(); + + var send = Assert.Single(h.ChannelSends); + Assert.Equal("sla_resolved", send.Alert.EventType); + Assert.Equal(NotificationScope.Tenant(TenantId), send.Scope); + } + + [Fact] + public async Task ConsecutiveFailures_InlinePath_ChannelSend_RunsOnTheTenantsScope() + { + var h = new Harness(); + var sends = new List(); + var (svc, _) = BuildInlineService(h.StatusRepo, WithSlaChannel(CreateConfig(consecutive: true, consecThreshold: 3)), FailedPage(3), sends); + var failedSession = new SessionSummary { TenantId = TenantId, SessionId = "s3", Status = SessionStatus.Failed, DeviceName = "DEV-01", FailureReason = "timeout" }; + + await svc.EvaluateSessionCompletionAsync(TenantId, failedSession); + + var send = Assert.Single(sends); + Assert.Equal("consecutive_failures", send.Alert.EventType); + Assert.Equal(NotificationScope.Tenant(TenantId), send.Scope); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TelegramChannelProviderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TelegramChannelProviderTests.cs index 42e17e7b0..dc42af37b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TelegramChannelProviderTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TelegramChannelProviderTests.cs @@ -37,11 +37,11 @@ public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook() { var webhook = WebhookMock(); var telegram = TelegramMock(); - var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of()); var alert = new NotificationAlert { Title = "t", Summary = "s" }; await dispatcher.SendToChannelsAsync( - new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert); + new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert, NotificationScope.Platform); telegram.Verify(t => t.SendOpsAlertAsync("-1003785642894", alert), Times.Once); webhook.Verify(w => w.SendNotificationAsync( @@ -54,11 +54,11 @@ public async Task SendToChannels_RoutesWebhookProvidersToWebhookService() { var webhook = WebhookMock(); var telegram = TelegramMock(); - var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of()); var alert = new NotificationAlert { Title = "t", Summary = "s" }; await dispatcher.SendToChannelsAsync( - new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert); + new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert, NotificationScope.Platform); webhook.Verify(w => w.SendNotificationAsync( "https://hooks.slack.example/x", WebhookProviderType.Slack, alert, @@ -71,14 +71,14 @@ public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport() { var webhook = WebhookMock(); var telegram = TelegramMock(); - var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of()); var alert = new NotificationAlert { Title = "t", Summary = "s" }; await dispatcher.SendToChannelsAsync(new[] { Channel((int)WebhookProviderType.Telegram, "-100123", "tg"), Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"), - }, alert); + }, alert, NotificationScope.Platform); telegram.Verify(t => t.SendOpsAlertAsync("-100123", alert), Times.Once); webhook.Verify(w => w.SendNotificationAsync( @@ -91,13 +91,13 @@ public async Task SendToChannels_SkipsChannelsWithoutDestination() { var webhook = WebhookMock(); var telegram = TelegramMock(); - var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of()); await dispatcher.SendToChannelsAsync(new[] { Channel((int)WebhookProviderType.Telegram, null, "tg"), Channel((int)WebhookProviderType.Slack, "", "slack"), - }, new NotificationAlert { Title = "t", Summary = "s" }); + }, new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform); telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny(), It.IsAny()), Times.Never); webhook.Verify(w => w.SendNotificationAsync( @@ -112,11 +112,11 @@ public async Task SendWithResult_RoutesTelegramToBot() var telegram = TelegramMock(); telegram.Setup(t => t.SendAlertWithResultAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new WebhookTestResult { Success = true, Message = "ok" }); - var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of()); var result = await dispatcher.SendWithResultAsync( Channel((int)WebhookProviderType.Telegram, "@salesdesk"), - new NotificationAlert { Title = "t", Summary = "s" }); + new NotificationAlert { Title = "t", Summary = "s" }, NotificationScope.Platform); Assert.True(result.Success); telegram.Verify(t => t.SendAlertWithResultAsync("@salesdesk", It.IsAny()), Times.Once); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestNotifications.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TestNotifications.cs index 7d61c1304..131dab8a8 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TestNotifications.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestNotifications.cs @@ -23,7 +23,8 @@ internal static NotificationChannelDispatcher InertDispatcher() => new( new WebhookNotificationService(new HttpClient(), NullLogger.Instance), new TelegramNotificationService(new HttpClient(), Mock.Of(), - NullLogger.Instance)); + NullLogger.Instance), + Mock.Of()); internal static OpsAlertDispatchService InertOpsAlertDispatch(AdminConfigurationService adminConfigService) => new(adminConfigService, InertDispatcher(), NullLogger.Instance); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/WhatsNewNotificationServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/WhatsNewNotificationServiceTests.cs index 0b519aae8..4bf90c096 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/WhatsNewNotificationServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/WhatsNewNotificationServiceTests.cs @@ -21,7 +21,8 @@ namespace AutopilotMonitor.Functions.Tests; /// /// What's new → channel digest: feed parsing, watermark semantics (baseline on first run, diff by -/// entry key, claim-then-send with ETag CAS), per-channel opt-in routing, and the digest shape. +/// entry key, claim-then-send with ETag CAS), per-channel opt-in routing, the per-tenant dispatch +/// scope (the Push transport trusts it), and the digest shape. /// public class WhatsNewNotificationServiceTests { @@ -79,6 +80,8 @@ private sealed class Harness public Mock ConfigRepo { get; } = new(); public Mock Dispatcher { get; } public List<(List Channels, NotificationAlert Alert)> Sent { get; } = new(); + /// The scope of every dispatch, in send order (parallel to ). + public List Scopes { get; } = new(); public WhatsNewNotificationService Service { get; } public DateTime Now { get; } = new(2026, 9, 8, 12, 0, 0, DateTimeKind.Utc); @@ -86,12 +89,13 @@ public Harness() { var webhook = new WebhookNotificationService(new HttpClient(), NullLogger.Instance); var telegram = new TelegramNotificationService(new HttpClient(), Mock.Of(), NullLogger.Instance); - Dispatcher = new Mock(webhook, telegram); + Dispatcher = new Mock(webhook, telegram, Mock.Of()); Dispatcher - .Setup(d => d.SendToChannelsAsync(It.IsAny>(), It.IsAny())) - .Returns, NotificationAlert>((channels, alert) => + .Setup(d => d.SendToChannelsAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns, NotificationAlert, NotificationScope>((channels, alert, scope) => { Sent.Add((channels.ToList(), alert)); + Scopes.Add(scope); return Task.CompletedTask; }); @@ -288,6 +292,7 @@ public async Task Run_NewEntries_SendsOneDigestPerTenantToOptedInChannelsOnly() var (channels, alert) = Assert.Single(h.Sent); Assert.Equal("yes", Assert.Single(channels).Id); Assert.Equal("whats_new", alert.EventType); + Assert.Equal(NotificationScope.Tenant("opted-in"), Assert.Single(h.Scopes)); Assert.Equal(2, alert.Sections.Count); Assert.Contains("2 new updates", alert.Summary); Assert.Contains("1 platform, 1 agent", alert.Summary); @@ -299,6 +304,23 @@ public async Task Run_NewEntries_SendsOneDigestPerTenantToOptedInChannelsOnly() Assert.Equal(2, state.LastNotifiedEntryCount); } + [Fact] + public async Task Run_NewEntries_DispatchesEachDigestOnTheTenantsOwnScope() + { + const string first = "11111111-1111-1111-1111-111111111111"; + const string second = "22222222-2222-2222-2222-222222222222"; + var h = new Harness(); + h.StateRepo.Seed("platform:a"); + h.SetFeed(Feed(Entry("platform", "a"), Entry("platform", "b"))); + h.SetTenants(Tenant(first, Channel("c1", whatsNew: true)), Tenant(second, Channel("c2", whatsNew: true))); + + var result = await h.Service.RunAsync(); + + // One digest per tenant, each on that tenant's scope — a Push channel resolves its devices from it. + Assert.Equal(2, result.TenantsNotified); + Assert.Equal(new[] { NotificationScope.Tenant(first), NotificationScope.Tenant(second) }, h.Scopes); + } + [Fact] public async Task Run_SkipsDisabledTenants() { diff --git a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj index 06d1ed656..273c9d1f4 100644 --- a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj +++ b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj @@ -61,6 +61,7 @@ + diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs index d2b0791a4..0caa2a0a8 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs @@ -64,6 +64,9 @@ public static IServiceCollection AddTableStorageDataAccess(this IServiceCollecti // Graph add-on permission feature: per-tenant cache of Intune script display names. services.AddSingleton(); + + // Web Push channel: paired devices, pairing grants, owner sign-in stamps, session watches. + services.AddSingleton(); return services; } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TablePushRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TablePushRepository.cs new file mode 100644 index 000000000..376a7fb6c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TablePushRepository.cs @@ -0,0 +1,394 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage; + +/// +/// Table-storage implementation of over the four push tables. +/// Unlike the older helpers this repository propagates storage exceptions: every row here is a +/// credential, and a swallowed write failure would leave a phone silently unpaired or a code +/// silently reusable. Callers wrap what must be fail-soft. +/// +public class TablePushRepository : IPushDeviceRepository +{ + private const string GrantPartition = "grant"; + private const int CasRetries = 4; + + private readonly TableClient _devices; + private readonly TableClient _grants; + private readonly TableClient _owners; + private readonly TableClient _watches; + private readonly ILogger _logger; + + public TablePushRepository(TableStorageService storage, ILogger logger) + { + _devices = storage.GetTableClient(Constants.TableNames.PushDevices); + _grants = storage.GetTableClient(Constants.TableNames.PushPairingGrants); + _owners = storage.GetTableClient(Constants.TableNames.PushOwners); + _watches = storage.GetTableClient(Constants.TableNames.PushSessionWatches); + _logger = logger; + } + + // ── Devices ───────────────────────────────────────────────────────────── + + public async Task GetDeviceAsync(string scope, string deviceId) + { + try + { + var entity = await _devices.GetEntityAsync(scope, deviceId).ConfigureAwait(false); + return MapDevice(entity.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public async Task> GetDevicesAsync(string scope) + { + var result = new List(); + var filter = TableClient.CreateQueryFilter($"PartitionKey eq {scope}"); + await foreach (var entity in _devices.QueryAsync(filter).ConfigureAwait(false)) + result.Add(MapDevice(entity)); + return result; + } + + public Task AddDeviceAsync(PushDevice device) + => _devices.AddEntityAsync(StoreDevice(device)); + + public async Task MutateDeviceAsync( + string scope, string deviceId, Func?> patch) + { + for (var attempt = 1; attempt <= CasRetries; attempt++) + { + TableEntity read; + try + { + read = (await _devices.GetEntityAsync(scope, deviceId).ConfigureAwait(false)).Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + + var changes = patch(MapDevice(read)); + if (changes == null || changes.Count == 0) + return false; + + // A Merge ignores null-valued properties (the service cannot clear a column that way), + // so a cleared StatusReason is written as "" and mapped back to null; other nulls are + // dropped from the patch (nothing to say). + var merge = new TableEntity(scope, deviceId); + foreach (var (key, value) in changes) + { + if (value == null) + { + if (key == "StatusReason") + merge[key] = string.Empty; + continue; + } + merge[key] = value is DateTime dt ? DateTime.SpecifyKind(dt, DateTimeKind.Utc) : value; + } + + try + { + await _devices.UpdateEntityAsync(merge, read.ETag, TableUpdateMode.Merge).ConfigureAwait(false); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 412) + { + // Lost the CAS race: re-read and try again; the last loss falls through to "no write landed". + if (attempt < CasRetries) + { + _logger.LogInformation("Push device {Scope}/{DeviceId} changed concurrently; retrying", scope, deviceId); + await Task.Delay(30 * attempt).ConfigureAwait(false); + } + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + + _logger.LogWarning("Push device {Scope}/{DeviceId} lost the CAS race {Retries} times", scope, deviceId, CasRetries); + return false; + } + + public async Task DeleteDeviceAsync(string scope, string deviceId) + { + try + { + await _devices.DeleteEntityAsync(scope, deviceId).ConfigureAwait(false); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + } + } + + public async Task> GetAllDevicesAsync() + { + var result = new List(); + await foreach (var entity in _devices.QueryAsync().ConfigureAwait(false)) + result.Add(MapDevice(entity)); + return result; + } + + // ── Grants ────────────────────────────────────────────────────────────── + + public async Task GetGrantAsync(string codeHash) + { + try + { + var entity = await _grants.GetEntityAsync(GrantPartition, codeHash).ConfigureAwait(false); + return MapGrant(entity.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public Task AddGrantAsync(PushPairingGrant grant) + => _grants.AddEntityAsync(StoreGrant(grant)); + + public async Task TryUpdateGrantAsync(PushPairingGrant grant) + { + if (string.IsNullOrEmpty(grant.ETag)) + throw new ArgumentException("A grant update needs the ETag it was read with.", nameof(grant)); + + try + { + await _grants.UpdateEntityAsync(StoreGrant(grant), new ETag(grant.ETag), TableUpdateMode.Replace).ConfigureAwait(false); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 412 || ex.Status == 404) + { + return false; + } + } + + public async Task DeleteGrantAsync(string codeHash) + { + try + { + await _grants.DeleteEntityAsync(GrantPartition, codeHash).ConfigureAwait(false); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + } + } + + public async Task> GetExpiredGrantsAsync(DateTime cutoffUtc) + { + var result = new List(); + var cutoff = new DateTimeOffset(DateTime.SpecifyKind(cutoffUtc, DateTimeKind.Utc)); + var filter = TableClient.CreateQueryFilter($"PartitionKey eq {GrantPartition} and ExpiresUtc lt {cutoff}"); + await foreach (var entity in _grants.QueryAsync(filter).ConfigureAwait(false)) + result.Add(MapGrant(entity)); + return result; + } + + // ── Owners ────────────────────────────────────────────────────────────── + + public async Task GetOwnerAsync(string homeTenantId, string objectId) + { + try + { + var entity = await _owners.GetEntityAsync(homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant()).ConfigureAwait(false); + return new PushOwner + { + HomeTenantId = entity.Value.PartitionKey, + ObjectId = entity.Value.RowKey, + Upn = entity.Value.GetString("Upn") ?? string.Empty, + LastSignInUtc = entity.Value.GetDateTime("LastSignInUtc") ?? DateTime.MinValue, + }; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc) + => _owners.UpsertEntityAsync(new TableEntity(homeTenantId.ToLowerInvariant(), objectId.ToLowerInvariant()) + { + ["Upn"] = upn.ToLowerInvariant(), + ["LastSignInUtc"] = DateTime.SpecifyKind(signInUtc, DateTimeKind.Utc), + }, TableUpdateMode.Merge); + + // ── Watches ───────────────────────────────────────────────────────────── + + private static string WatchRowKey(string sessionId, string objectId) => $"{sessionId}_{objectId.ToLowerInvariant()}"; + + public Task UpsertWatchAsync(PushSessionWatch watch) + => _watches.UpsertEntityAsync(StoreWatch(watch), TableUpdateMode.Replace); + + public async Task DeleteWatchAsync(string tenantId, string sessionId, string objectId) + { + try + { + await _watches.DeleteEntityAsync(tenantId.ToLowerInvariant(), WatchRowKey(sessionId, objectId)).ConfigureAwait(false); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + } + } + + public async Task GetWatchAsync(string tenantId, string sessionId, string objectId) + { + try + { + var entity = await _watches.GetEntityAsync(tenantId.ToLowerInvariant(), WatchRowKey(sessionId, objectId)).ConfigureAwait(false); + return MapWatch(entity.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public async Task> GetWatchesForSessionAsync(string tenantId, string sessionId) + { + var result = new List(); + var lower = sessionId + "_"; + var upper = sessionId + "`"; + var filter = TableClient.CreateQueryFilter( + $"PartitionKey eq {tenantId.ToLowerInvariant()} and RowKey ge {lower} and RowKey lt {upper}"); + await foreach (var entity in _watches.QueryAsync(filter).ConfigureAwait(false)) + result.Add(MapWatch(entity)); + return result.OrderByDescending(w => w.CreatedUtc).ToList(); + } + + public async Task> GetExpiredWatchesAsync(DateTime cutoffUtc) + { + var result = new List(); + var cutoff = new DateTimeOffset(DateTime.SpecifyKind(cutoffUtc, DateTimeKind.Utc)); + var filter = TableClient.CreateQueryFilter($"ExpiresUtc lt {cutoff}"); + await foreach (var entity in _watches.QueryAsync(filter).ConfigureAwait(false)) + result.Add(MapWatch(entity)); + return result; + } + + // ── Store / Map (memory: feedback_table_storage_serialization) ────────── + // Internal so the round-trip test can prove every property survives Store → Map. + + internal static TableEntity StoreDevice(PushDevice d) => + new(d.Scope, d.DeviceId) + { + ["OwnerUpn"] = d.OwnerUpn, + ["OwnerObjectId"] = d.OwnerObjectId, + ["OwnerHomeTenantId"] = d.OwnerHomeTenantId, + ["Kind"] = d.Kind, + ["Endpoint"] = d.Endpoint, + ["P256dh"] = d.P256dh, + ["Auth"] = d.Auth, + ["DeviceSecretHash"] = d.DeviceSecretHash, + ["VapidKid"] = d.VapidKid, + ["Label"] = d.Label, + ["Platform"] = d.Platform, + ["AppVersion"] = d.AppVersion, + ["Status"] = d.Status, + ["StatusReason"] = d.StatusReason, + ["PairedUtc"] = DateTime.SpecifyKind(d.PairedUtc, DateTimeKind.Utc), + ["ConfirmedUtc"] = Utc(d.ConfirmedUtc), + ["LastDeliveredUtc"] = Utc(d.LastDeliveredUtc), + ["LastOpenedUtc"] = Utc(d.LastOpenedUtc), + ["StatusChangedUtc"] = Utc(d.StatusChangedUtc), + ["LastStatusCode"] = d.LastStatusCode, + ["ConsecutiveFailures"] = d.ConsecutiveFailures, + ["WindowStartUtc"] = Utc(d.WindowStartUtc), + ["WindowCount"] = d.WindowCount, + ["SuppressedCount"] = d.SuppressedCount, + }; + + internal static PushDevice MapDevice(TableEntity e) => new() + { + Scope = e.PartitionKey, + DeviceId = e.RowKey, + OwnerUpn = e.GetString("OwnerUpn") ?? string.Empty, + OwnerObjectId = e.GetString("OwnerObjectId") ?? string.Empty, + OwnerHomeTenantId = e.GetString("OwnerHomeTenantId") ?? string.Empty, + Kind = e.GetString("Kind") ?? "webpush", + Endpoint = e.GetString("Endpoint") ?? string.Empty, + P256dh = e.GetString("P256dh") ?? string.Empty, + Auth = e.GetString("Auth") ?? string.Empty, + DeviceSecretHash = e.GetString("DeviceSecretHash") ?? string.Empty, + VapidKid = e.GetString("VapidKid") ?? string.Empty, + Label = e.GetString("Label") ?? string.Empty, + Platform = e.GetString("Platform") ?? "other", + AppVersion = e.GetString("AppVersion") ?? string.Empty, + Status = e.GetString("Status") ?? Constants.Push.DeviceStatus.Pending, + StatusReason = string.IsNullOrEmpty(e.GetString("StatusReason")) ? null : e.GetString("StatusReason"), + PairedUtc = e.GetDateTime("PairedUtc") ?? DateTime.MinValue, + ConfirmedUtc = e.GetDateTime("ConfirmedUtc"), + LastDeliveredUtc = e.GetDateTime("LastDeliveredUtc"), + LastOpenedUtc = e.GetDateTime("LastOpenedUtc"), + StatusChangedUtc = e.GetDateTime("StatusChangedUtc"), + LastStatusCode = e.GetInt32("LastStatusCode"), + ConsecutiveFailures = e.GetInt32("ConsecutiveFailures") ?? 0, + WindowStartUtc = e.GetDateTime("WindowStartUtc"), + WindowCount = e.GetInt32("WindowCount") ?? 0, + SuppressedCount = e.GetInt32("SuppressedCount") ?? 0, + }; + + internal static TableEntity StoreGrant(PushPairingGrant g) => + new(GrantPartition, g.CodeHash) + { + ["TenantId"] = g.TenantId, + ["OwnerUpn"] = g.OwnerUpn, + ["OwnerObjectId"] = g.OwnerObjectId, + ["OwnerHomeTenantId"] = g.OwnerHomeTenantId, + ["Status"] = g.Status, + ["CreatedUtc"] = DateTime.SpecifyKind(g.CreatedUtc, DateTimeKind.Utc), + ["ExpiresUtc"] = DateTime.SpecifyKind(g.ExpiresUtc, DateTimeKind.Utc), + ["RedeemedUtc"] = Utc(g.RedeemedUtc), + ["DeviceId"] = g.DeviceId, + ["FailedAttempts"] = g.FailedAttempts, + }; + + internal static PushPairingGrant MapGrant(TableEntity e) => new() + { + CodeHash = e.RowKey, + TenantId = e.GetString("TenantId") ?? string.Empty, + OwnerUpn = e.GetString("OwnerUpn") ?? string.Empty, + OwnerObjectId = e.GetString("OwnerObjectId") ?? string.Empty, + OwnerHomeTenantId = e.GetString("OwnerHomeTenantId") ?? string.Empty, + Status = e.GetString("Status") ?? Constants.Push.PairingStatus.Pending, + CreatedUtc = e.GetDateTime("CreatedUtc") ?? DateTime.MinValue, + ExpiresUtc = e.GetDateTime("ExpiresUtc") ?? DateTime.MinValue, + RedeemedUtc = e.GetDateTime("RedeemedUtc"), + DeviceId = e.GetString("DeviceId"), + FailedAttempts = e.GetInt32("FailedAttempts") ?? 0, + ETag = e.ETag.ToString(), + }; + + internal static TableEntity StoreWatch(PushSessionWatch w) => + new(w.TenantId.ToLowerInvariant(), WatchRowKey(w.SessionId, w.OwnerObjectId)) + { + ["SessionId"] = w.SessionId, + ["OwnerObjectId"] = w.OwnerObjectId.ToLowerInvariant(), + ["OwnerUpn"] = w.OwnerUpn.ToLowerInvariant(), + ["OwnerScopes"] = w.OwnerScopes, + ["CreatedUtc"] = DateTime.SpecifyKind(w.CreatedUtc, DateTimeKind.Utc), + ["ExpiresUtc"] = DateTime.SpecifyKind(w.ExpiresUtc, DateTimeKind.Utc), + }; + + internal static PushSessionWatch MapWatch(TableEntity e) => new() + { + TenantId = e.PartitionKey, + SessionId = e.GetString("SessionId") ?? string.Empty, + OwnerObjectId = e.GetString("OwnerObjectId") ?? string.Empty, + OwnerUpn = e.GetString("OwnerUpn") ?? string.Empty, + OwnerScopes = e.GetString("OwnerScopes") ?? string.Empty, + CreatedUtc = e.GetDateTime("CreatedUtc") ?? DateTime.MinValue, + ExpiresUtc = e.GetDateTime("ExpiresUtc") ?? DateTime.MinValue, + }; + + // Edm.DateTime cannot hold default(DateTime); nullable columns stay absent (null) instead. + private static DateTime? Utc(DateTime? value) + => value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null; +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/MaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/MaintenanceFunction.cs index 5299891f7..8d362afdb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/MaintenanceFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/MaintenanceFunction.cs @@ -12,13 +12,16 @@ namespace AutopilotMonitor.Functions.Functions.Admin public class MaintenanceFunction { private readonly MaintenanceService _maintenanceService; + private readonly Services.Push.PushMaintenanceService _pushMaintenance; private readonly ILogger _logger; public MaintenanceFunction( MaintenanceService maintenanceService, + Services.Push.PushMaintenanceService pushMaintenance, ILogger logger) { _maintenanceService = maintenanceService; + _pushMaintenance = pushMaintenance; _logger = logger; } @@ -35,6 +38,8 @@ public async Task Run([TimerTrigger("0 0 */2 * * *")] object timer) { _logger.LogInformation("Maintenance timer trigger fired"); await _maintenanceService.RunAllAsync(); + // Push channel retention rides on this timer (no timer of its own — K27); fail-soft inside. + await _pushMaintenance.RunAsync(); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestOpsChannelFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestOpsChannelFunction.cs index d2e50e4c1..df7103ab2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestOpsChannelFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestOpsChannelFunction.cs @@ -56,7 +56,7 @@ public async Task Run( ? channels.FirstOrDefault(c => string.Equals(c.Id, channelId, StringComparison.OrdinalIgnoreCase)) : channels.FirstOrDefault(); - if (channel == null || string.IsNullOrEmpty(channel.Url)) + if (channel == null || !channel.HasDestination()) { var notFound = req.CreateResponse(HttpStatusCode.OK); await notFound.WriteAsJsonAsync(new TestWebhookNotificationResponse @@ -70,7 +70,7 @@ await notFound.WriteAsJsonAsync(new TestWebhookNotificationResponse } var testAlert = NotificationAlertBuilder.BuildTestAlert(); - var result = await _channelDispatcher.SendWithResultAsync(channel, testAlert); + var result = await _channelDispatcher.SendWithResultAsync(channel, testAlert, NotificationScope.Platform); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new TestWebhookNotificationResponse diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs index d634f5478..582f6100c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs @@ -57,7 +57,7 @@ public async Task Run( ? channels.FirstOrDefault(c => string.Equals(c.Id, channelId, StringComparison.OrdinalIgnoreCase)) : channels.FirstOrDefault(); - if (channel == null || string.IsNullOrEmpty(channel.Url)) + if (channel == null || !channel.HasDestination()) { // 200 with success=false on purpose: a missing channel is a configuration state the // UI renders inline, not a failed request. @@ -71,7 +71,7 @@ public async Task Run( } var testAlert = NotificationAlertBuilder.BuildTestAlert(); - var result = await _channelDispatcher.SendWithResultAsync(channel, testAlert); + var result = await _channelDispatcher.SendWithResultAsync(channel, testAlert, NotificationScope.Tenant(requestCtx.TargetTenantId)); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new TestWebhookNotificationResponse diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index 5a5b8da88..c7e713a0f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -38,6 +38,7 @@ public class AuthFunction private readonly ISignalRNotificationService _signalRService; private readonly AdminConfigurationService _adminConfigService; private readonly IOffboardingAuditRepository _offboardingRepo; + private readonly IPushDeviceRepository? _pushRepo; public AuthFunction( ILogger logger, @@ -55,9 +56,11 @@ public AuthFunction( AdminIdentityResolver identityResolver, ISignalRNotificationService signalRService, AdminConfigurationService adminConfigService, - IOffboardingAuditRepository offboardingRepo) + IOffboardingAuditRepository offboardingRepo, + IPushDeviceRepository? pushRepo = null) { _logger = logger; + _pushRepo = pushRepo; _offboardingRepo = offboardingRepo; _adminConfigService = adminConfigService; _identityResolver = identityResolver; @@ -612,6 +615,21 @@ internal async Task HandlePostDecisionSideEffectsAsync( .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget RecordUserLoginAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + + // Push freshness stamp (K3): one upsert per portal sign-in, keyed by the person (home + // tenant + oid), so paired devices of someone who stopped signing in pause after the + // inactivity window. Awaited — a lost stamp is an early pause; fail-soft — never a 500. + if (_pushRepo != null && !string.IsNullOrEmpty(objectId)) + { + try + { + await _pushRepo.StampOwnerSignInAsync(tenantId, objectId, upn, DateTime.UtcNow); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Push owner sign-in stamp failed for tenant {TenantId}", tenantId); + } + } } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushDevicesFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushDevicesFunctions.cs new file mode 100644 index 000000000..5ee0a00dc --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushDevicesFunctions.cs @@ -0,0 +1,107 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Functions.Services.Push; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Push; + +/// +/// Device management from the portal: list (own devices for everyone on the tier, every device +/// of the scope for its admins), remove (wipe push, then the row is gone) and test. Tenant routes +/// under push/devices… (TenantAdminOrOperator), platform routes under +/// global/push/devices… (GlobalAdminOnly). +/// +public class PushDevicesFunctions +{ + private readonly PushPairingService _pairing; + private readonly ILogger _logger; + + public PushDevicesFunctions(PushPairingService pairing, ILogger logger) + { + _pairing = pairing; + _logger = logger; + } + + [Function("ListPushDevices")] + public Task List( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "push/devices")] HttpRequestData req) + => ListCoreAsync(req, PushFunctionSupport.TenantContext(req)); + + [Function("DeletePushDevice")] + public Task Delete( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "push/devices/{deviceId}")] HttpRequestData req, + string deviceId) + => DeleteCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req)); + + [Function("TestPushDevice")] + public Task Test( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/devices/{deviceId}/test")] HttpRequestData req, + string deviceId) + => TestCoreAsync(req, deviceId, PushFunctionSupport.TenantContext(req)); + + [Function("ListPlatformPushDevices")] + public Task ListPlatform( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/push/devices")] HttpRequestData req) + => ListCoreAsync(req, PushFunctionSupport.PlatformContext(req)); + + [Function("DeletePlatformPushDevice")] + public Task DeletePlatform( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/push/devices/{deviceId}")] HttpRequestData req, + string deviceId) + => DeleteCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req)); + + [Function("TestPlatformPushDevice")] + public Task TestPlatform( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/push/devices/{deviceId}/test")] HttpRequestData req, + string deviceId) + => TestCoreAsync(req, deviceId, PushFunctionSupport.PlatformContext(req)); + + private async Task ListCoreAsync(HttpRequestData req, (NotificationScope Scope, PushCaller Caller) ctx) + { + try + { + return await req.OkAsync(await _pairing.ListAsync(ctx.Scope, ctx.Caller)); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "ListPushDevices"); + } + } + + private async Task DeleteCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx) + { + try + { + if (!PushFunctionSupport.IsPlausibleId(deviceId)) + return await req.NotFoundAsync("Device not found."); + var error = await _pairing.DeleteAsync(ctx.Scope, deviceId, ctx.Caller); + if (error != PushOpError.None) + return await PushFunctionSupport.ErrorAsync(req, error, "Device not found."); + return req.CreateResponse(HttpStatusCode.NoContent); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "DeletePushDevice"); + } + } + + private async Task TestCoreAsync(HttpRequestData req, string deviceId, (NotificationScope Scope, PushCaller Caller) ctx) + { + try + { + if (!PushFunctionSupport.IsPlausibleId(deviceId)) + return await req.NotFoundAsync("Device not found."); + var result = await _pairing.TestAsync(ctx.Scope, deviceId, ctx.Caller); + return result.Ok + ? await req.OkAsync(result.Value!) + : await PushFunctionSupport.ErrorAsync(req, result.Error, "Device not found."); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "TestPushDevice"); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushFunctionSupport.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushFunctionSupport.cs new file mode 100644 index 000000000..0f4588b37 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushFunctionSupport.cs @@ -0,0 +1,63 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Functions.Services.Push; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker.Http; + +namespace AutopilotMonitor.Functions.Functions.Push; + +/// +/// Shared plumbing of the push functions: the caller as a , the scope a +/// route addresses, and the one mapping from to the error envelope. +/// +internal static class PushFunctionSupport +{ + /// + /// The tenant scope of a push/… route: the caller's own tenant. A Global Admin passes + /// the TenantAdminOrOperator tier by bypass, so the service re-checks the table-backed tenant + /// role (K2/K5) — a GA without a member row is told to use the platform routes. + /// + public static (NotificationScope Scope, PushCaller Caller) TenantContext(HttpRequestData req) + { + var ctx = req.GetRequestContext(); + var scope = NotificationScope.Tenant(ctx.TargetTenantId); + var caller = new PushCaller(ctx.UserPrincipalName, ctx.ObjectId, ctx.TenantId, + IsScopeAdmin: ctx.IsTenantAdmin || ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin); + return (scope, caller); + } + + /// The platform scope of a global/push/… route (GlobalAdminOnly tier). + public static (NotificationScope Scope, PushCaller Caller) PlatformContext(HttpRequestData req) + { + var ctx = req.GetRequestContext(); + var caller = new PushCaller(ctx.UserPrincipalName, ctx.ObjectId, ctx.TenantId, + IsScopeAdmin: ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin); + return (NotificationScope.Platform, caller); + } + + public static Task ErrorAsync(HttpRequestData req, PushOpError error, string? message) + => error switch + { + PushOpError.ChannelRequired => req.ErrorAsync(HttpStatusCode.Conflict, Constants.ApiErrorCodes.PushChannelRequired, + message ?? "A Push notification channel must be enabled in this scope first."), + PushOpError.NotEligible => req.ErrorAsync(HttpStatusCode.Forbidden, Constants.ApiErrorCodes.PushNotEligible, + message ?? "Pairing needs a table-backed Admin or Operator role in this scope."), + PushOpError.NotFound => req.NotFoundAsync(message ?? "Not found."), + PushOpError.CodeInvalid => req.ErrorAsync(HttpStatusCode.NotFound, Constants.ApiErrorCodes.PairingCodeInvalid, + "This pairing code is unknown, expired or already used."), + PushOpError.CodeUsed => req.ErrorAsync(HttpStatusCode.Conflict, Constants.ApiErrorCodes.PairingCodeUsed, + "This pairing code was just used by another device."), + PushOpError.InvalidSubscription => req.ErrorAsync(HttpStatusCode.BadRequest, Constants.ApiErrorCodes.InvalidSubscription, + message ?? "The push subscription is not acceptable."), + PushOpError.NotConfigured => req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable, + "The push channel is not configured on the platform."), + PushOpError.Conflict => req.ConflictAsync(message ?? "The operation conflicts with the current state."), + _ => req.InternalServerErrorAsync(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + new InvalidOperationException("unmapped push error"), "Push"), + }; + + public static bool IsPlausibleId(string? value) + => !string.IsNullOrWhiteSpace(value) && value.Length <= 64 && value.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'); +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushPairingFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushPairingFunctions.cs new file mode 100644 index 000000000..d7f706933 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushPairingFunctions.cs @@ -0,0 +1,144 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Functions.Services.Push; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Push; + +/// +/// PC side of pairing (K7): create a code, poll what the receiver did with it, confirm or reject +/// the Pending device. Tenant routes (push/pairings…, TenantAdminOrOperator, JWT scope) +/// and platform routes (global/push/pairings…, GlobalAdminOnly) share one core. +/// +public class PushPairingFunctions +{ + private readonly PushPairingService _pairing; + private readonly ILogger _logger; + + public PushPairingFunctions(PushPairingService pairing, ILogger logger) + { + _pairing = pairing; + _logger = logger; + } + + // ── Tenant scope ───────────────────────────────────────────────────────── + + [Function("CreatePushPairing")] + public Task Create( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/pairings")] HttpRequestData req) + => CreateCoreAsync(req, PushFunctionSupport.TenantContext(req)); + + [Function("GetPushPairingStatus")] + public Task Status( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "push/pairings/{pairingId}")] HttpRequestData req, + string pairingId) + => StatusCoreAsync(req, pairingId, PushFunctionSupport.TenantContext(req)); + + [Function("ConfirmPushPairing")] + public Task Confirm( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/pairings/{pairingId}/confirm")] HttpRequestData req, + string pairingId) + => ConfirmCoreAsync(req, pairingId, PushFunctionSupport.TenantContext(req)); + + [Function("RejectPushPairing")] + public Task Reject( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/pairings/{pairingId}/reject")] HttpRequestData req, + string pairingId) + => RejectCoreAsync(req, pairingId, PushFunctionSupport.TenantContext(req)); + + // ── Platform scope ─────────────────────────────────────────────────────── + + [Function("CreatePlatformPushPairing")] + public Task CreatePlatform( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/push/pairings")] HttpRequestData req) + => CreateCoreAsync(req, PushFunctionSupport.PlatformContext(req)); + + [Function("GetPlatformPushPairingStatus")] + public Task StatusPlatform( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/push/pairings/{pairingId}")] HttpRequestData req, + string pairingId) + => StatusCoreAsync(req, pairingId, PushFunctionSupport.PlatformContext(req)); + + [Function("ConfirmPlatformPushPairing")] + public Task ConfirmPlatform( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/push/pairings/{pairingId}/confirm")] HttpRequestData req, + string pairingId) + => ConfirmCoreAsync(req, pairingId, PushFunctionSupport.PlatformContext(req)); + + [Function("RejectPlatformPushPairing")] + public Task RejectPlatform( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/push/pairings/{pairingId}/reject")] HttpRequestData req, + string pairingId) + => RejectCoreAsync(req, pairingId, PushFunctionSupport.PlatformContext(req)); + + // ── Core ───────────────────────────────────────────────────────────────── + + private async Task CreateCoreAsync(HttpRequestData req, (NotificationScope Scope, PushCaller Caller) ctx) + { + try + { + var result = await _pairing.CreateAsync(ctx.Scope, ctx.Caller); + return result.Ok + ? await req.CreatedAsync(result.Value!) + : await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "CreatePushPairing"); + } + } + + private async Task StatusCoreAsync(HttpRequestData req, string pairingId, (NotificationScope Scope, PushCaller Caller) ctx) + { + try + { + if (!PushFunctionSupport.IsPlausibleId(pairingId)) + return await req.NotFoundAsync("Pairing not found."); + var result = await _pairing.GetStatusAsync(ctx.Scope, pairingId, ctx.Caller); + return result.Ok + ? await req.OkAsync(result.Value!) + : await PushFunctionSupport.ErrorAsync(req, result.Error, "Pairing not found."); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "GetPushPairingStatus"); + } + } + + private async Task ConfirmCoreAsync(HttpRequestData req, string pairingId, (NotificationScope Scope, PushCaller Caller) ctx) + { + try + { + if (!PushFunctionSupport.IsPlausibleId(pairingId)) + return await req.NotFoundAsync("Pairing not found."); + var result = await _pairing.ConfirmAsync(ctx.Scope, pairingId, ctx.Caller); + return result.Ok + ? await req.OkAsync(result.Value!) + : await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message ?? "Pairing not found."); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "ConfirmPushPairing"); + } + } + + private async Task RejectCoreAsync(HttpRequestData req, string pairingId, (NotificationScope Scope, PushCaller Caller) ctx) + { + try + { + if (!PushFunctionSupport.IsPlausibleId(pairingId)) + return await req.NotFoundAsync("Pairing not found."); + var error = await _pairing.RejectAsync(ctx.Scope, pairingId, ctx.Caller); + if (error != PushOpError.None) + return await PushFunctionSupport.ErrorAsync(req, error, "Pairing not found."); + return req.CreateResponse(HttpStatusCode.NoContent); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "RejectPushPairing"); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushReceiverFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushReceiverFunctions.cs new file mode 100644 index 000000000..e7e317a59 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Push/PushReceiverFunctions.cs @@ -0,0 +1,182 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Push; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Push; + +/// +/// The receiver's own routes — the phone never signs in (that is the point: Conditional Access +/// on the portal must not decide whether an admin's phone gets alerts). Two are anonymous and +/// gated by the one-shot pairing code (K8–K10), three by the device token in +/// (K11). Per-IP throttle on the anonymous pair, +/// one identical 404 for every unusable code, the code is never logged, and no response names +/// an endpoint. +/// +public class PushReceiverFunctions +{ + private const int RedeemRateLimitPerMinute = 10; + /// Per device: the token holder is one phone; its routes reach storage and (on a refused endpoint) an ops event. + private const int DeviceRouteLimitPerMinute = 10; + private const long MaxBodyBytes = 8 * 1024; + + private readonly PushPairingService _pairing; + private readonly RateLimitService _rateLimit; + private readonly ILogger _logger; + + public PushReceiverFunctions(PushPairingService pairing, RateLimitService rateLimit, ILogger logger) + { + _pairing = pairing; + _rateLimit = rateLimit; + _logger = logger; + } + + [Function("BeginPushPair")] + public async Task Begin( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/pair/begin")] HttpRequestData req) + { + try + { + if (await ThrottleAsync(req) is { } throttled) return throttled; + + var read = await req.ReadAsync(MaxBodyBytes); + if (read.Error != null) return read.Error; + + var result = await _pairing.BeginAsync(read.Value!.Code); + return result.Ok + ? await req.OkAsync(result.Value!) + : await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "BeginPushPair"); + } + } + + [Function("RedeemPushPair")] + public async Task Redeem( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "push/pair")] HttpRequestData req) + { + try + { + if (await ThrottleAsync(req) is { } throttled) return throttled; + + var read = await req.ReadAsync(MaxBodyBytes); + if (read.Error != null) return read.Error; + + var result = await _pairing.RedeemAsync(read.Value!); + return result.Ok + ? await req.CreatedAsync(result.Value!) + : await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "RedeemPushPair"); + } + } + + [Function("GetPushDevice")] + public async Task Get( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "push/device")] HttpRequestData req) + { + try + { + var device = await _pairing.ResolveDeviceAsync(DeviceToken(req)); + if (device == null) + return await req.ErrorAsync(HttpStatusCode.Unauthorized, Constants.ApiErrorCodes.InvalidDeviceToken, "Unknown device token."); + if (await ThrottleDeviceAsync(req, device) is { } throttled) return throttled; + + return await req.OkAsync(await _pairing.GetDeviceStatusAsync(device)); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "GetPushDevice"); + } + } + + [Function("ResubscribePushDevice")] + public async Task Resubscribe( + [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "push/device")] HttpRequestData req) + { + try + { + var device = await _pairing.ResolveDeviceAsync(DeviceToken(req)); + if (device == null) + return await req.ErrorAsync(HttpStatusCode.Unauthorized, Constants.ApiErrorCodes.InvalidDeviceToken, "Unknown device token."); + if (await ThrottleDeviceAsync(req, device) is { } throttled) return throttled; + + var read = await req.ReadAsync(MaxBodyBytes); + if (read.Error != null) return read.Error; + + var result = await _pairing.ResubscribeAsync(device, read.Value!); + return result.Ok + ? await req.OkAsync(result.Value!) + : await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message ?? "This device is no longer paired."); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "ResubscribePushDevice"); + } + } + + [Function("UnpairPushDevice")] + public async Task Unpair( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "push/device")] HttpRequestData req) + { + try + { + var device = await _pairing.ResolveDeviceAsync(DeviceToken(req)); + if (device == null) + return await req.ErrorAsync(HttpStatusCode.Unauthorized, Constants.ApiErrorCodes.InvalidDeviceToken, "Unknown device token."); + if (await ThrottleDeviceAsync(req, device) is { } throttled) return throttled; + + await _pairing.UnpairAsync(device); + return req.CreateResponse(HttpStatusCode.NoContent); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "UnpairPushDevice"); + } + } + + private static string? DeviceToken(HttpRequestData req) + => req.Headers.TryGetValues(Constants.Push.DeviceTokenHeader, out var values) ? values.FirstOrDefault() : null; + + /// + /// Per device, after the token matched: one phone is one device, and the PUT reaches storage + /// and (for a refused endpoint host) an ops event, so a leaked token must not turn into a + /// write loop. + /// + private async Task ThrottleDeviceAsync(HttpRequestData req, Shared.DataAccess.PushDevice device) + { + var result = _rateLimit.CheckRateLimit($"push-device:{device.Scope}/{device.DeviceId}", DeviceRouteLimitPerMinute); + if (result.IsAllowed) + return null; + + return await req.ErrorAsync(HttpStatusCode.TooManyRequests, Constants.ApiErrorCodes.RateLimited, + "Too many requests for this device. Try again in a minute.", + retryAfterSeconds: result.RetryAfter is { } retryAfter ? (int)Math.Ceiling(retryAfter.TotalSeconds) : 60); + } + + /// + /// Second layer only (K9): the first is the ≥50-bit single-use code with its five-failure + /// burn. Generous per IP because carriers put thousands of phones behind one address. + /// + private async Task ThrottleAsync(HttpRequestData req) + { + var clientIp = ClientIpExtractor.GetTrustedClientIp(req); + var result = _rateLimit.CheckRateLimit($"push-pair:{clientIp}", RedeemRateLimitPerMinute); + if (result.IsAllowed) + return null; + + return await req.ErrorAsync(HttpStatusCode.TooManyRequests, Constants.ApiErrorCodes.RateLimited, + "Too many pairing attempts. Try again in a minute.", + retryAfterSeconds: result.RetryAfter is { } retryAfter ? (int)Math.Ceiling(retryAfter.TotalSeconds) : 60); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Push/SessionWatchFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Push/SessionWatchFunctions.cs new file mode 100644 index 000000000..bb37f8418 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Push/SessionWatchFunctions.cs @@ -0,0 +1,97 @@ +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services.Push; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Push; + +/// +/// "Notify me when this session ends" on the session page (TenantAdminOrOperator, QueryParam +/// scoping like the annotations route so a Global Admin can watch a customer's session from +/// their own paired devices — the devices stay in the caller's own scope). +/// +public class SessionWatchFunctions +{ + private readonly PushSessionWatchService _watches; + private readonly ISessionRepository _sessionRepo; + private readonly ILogger _logger; + + public SessionWatchFunctions(PushSessionWatchService watches, ISessionRepository sessionRepo, ILogger logger) + { + _watches = watches; + _sessionRepo = sessionRepo; + _logger = logger; + } + + [Function("GetSessionWatch")] + public async Task Get( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/watch")] HttpRequestData req, + string sessionId) + { + try + { + var (tenantId, caller) = await ScopeAsync(req, sessionId); + return await req.OkAsync(await _watches.GetAsync(tenantId, sessionId, caller)); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "GetSessionWatch"); + } + } + + [Function("WatchSession")] + public async Task Watch( + [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "sessions/{sessionId}/watch")] HttpRequestData req, + string sessionId) + { + try + { + if (!PushFunctionSupport.IsPlausibleId(sessionId)) + return await req.NotFoundAsync("Session not found."); + var (tenantId, caller) = await ScopeAsync(req, sessionId); + if (await _sessionRepo.GetSessionAsync(tenantId, sessionId) == null) + return await req.NotFoundAsync("Session not found."); + + var result = await _watches.WatchAsync(tenantId, sessionId, caller); + return result.Ok + ? await req.OkAsync(result.Value!) + : await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "WatchSession"); + } + } + + [Function("UnwatchSession")] + public async Task Unwatch( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "sessions/{sessionId}/watch")] HttpRequestData req, + string sessionId) + { + try + { + var (tenantId, caller) = await ScopeAsync(req, sessionId); + return await req.OkAsync(await _watches.UnwatchAsync(tenantId, sessionId, caller)); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "UnwatchSession"); + } + } + + /// + /// The watch lives in the SESSION's tenant; the devices it fires on are the caller's own + /// (home-tenant scope, plus the platform scope for a Global Admin), resolved at terminal status + /// by owner oid. + /// + private async Task<(string TenantId, PushCaller Caller)> ScopeAsync(HttpRequestData req, string sessionId) + { + var ctx = req.GetRequestContext(); + var tenantId = await ctx.ResolveSessionScopeAsync(_sessionRepo, sessionId, requireGlobalAdmin: true); + var caller = new PushCaller(ctx.UserPrincipalName, ctx.ObjectId, ctx.TenantId, + IsScopeAdmin: ctx.IsTenantAdmin || ctx.IsGlobalAdmin, IsGlobalAdmin: ctx.IsGlobalAdmin); + return (tenantId, caller); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs index 0c3c1b672..af4453a73 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs @@ -19,13 +19,13 @@ public class TableQueryFunction private readonly ILogger _logger; private readonly TableStorageService _storage; - // Tables that must never be exposed (contain secrets) - // NOTE: Intentionally empty during preview — only Global Admin (single user) has access - // to this endpoint. Consider adding TenantConfiguration, AdminConfiguration, BootstrapSessions - // before GA release when more users may have Global Admin access. - private static readonly HashSet _blacklistedTables = new(StringComparer.OrdinalIgnoreCase) - { - }; + // Tables that must never be exposed: Constants.TableNames.CredentialBearing (push endpoints + // + key material + hashed secrets — together with the VAPID key a send capability for + // somebody's phone, and this surface feeds AI-assistant transcripts via MCP query_table). + // Consider adding TenantConfiguration, AdminConfiguration, BootstrapSessions before more + // users hold Global Admin; PushChannelRoutingTests pins the credential-bearing set. + internal static readonly HashSet _blacklistedTables = + new(Constants.TableNames.CredentialBearing, StringComparer.OrdinalIgnoreCase); public TableQueryFunction(ILogger logger, TableStorageService storage) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index 0d130c3cf..d7ddb54c5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -408,7 +408,7 @@ private async Task SendStartNotificationAsync(string tenantId, string sessionId, isResume: isResume, sessionUrl: sessionUrl); - await _channelDispatcher.SendToChannelsAsync(startChannels, alert); + await _channelDispatcher.SendToChannelsAsync(startChannels, alert, NotificationScope.Tenant(tenantId)); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs index e10be81f2..2508f8641 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs @@ -355,10 +355,14 @@ internal static bool IsValidDomainName(string? domainName) // Telegram's destination is a chat ID, not a URL — the SSRF gate does not apply // (there is no caller-supplied endpoint; the bot URL is platform-owned) and would - // reject every valid value. Format-check the chat ID instead. + // reject every valid value. Format-check the chat ID instead. A Push channel has + // no destination at all: a URL on it is refused so it can never fall through to + // the webhook path should a future branch forget the provider. var destinationError = channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Telegram ? ValidateTelegramChatId(channel.Url) + : channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push + ? ValidatePushDestination(channel.Url) : SsrfGuard.ValidateWebhookUrlFormat(channel.Url); if (destinationError != null) return $"channel \"{label}\": {destinationError}"; @@ -415,13 +419,24 @@ internal static bool IsValidDomainName(string? domainName) } /// - /// Global-Admin gate for Telegram channels. A Telegram channel sends through the PLATFORM's - /// bot (the token lives in PreviewConfig, not in tenant config), so a tenant admin must not - /// be able to create or retarget one — hiding the provider in the UI is not a control. + /// A Push channel carries no destination (its recipients are the scope's paired devices, + /// resolved at send time); a URL on it is refused rather than ignored. + /// + internal static string? ValidatePushDestination(string? url) + => string.IsNullOrWhiteSpace(url) ? null : "a Push channel has no destination — leave the URL empty."; + + /// + /// Global-Admin gate for the providers that send through PLATFORM-owned infrastructure + /// (): + /// Telegram through the platform bot (the token lives in PreviewConfig, not in tenant + /// config), Push through the platform's push sender and VAPID key, GA-only until the + /// customer release. A tenant admin must not be able to create or retarget one — hiding + /// the provider in the UI is not a control. /// /// Compares against the stored config so a non-GA caller can still save unrelated changes - /// while a GA-created Telegram channel is present: only ADDING one, or changing an existing - /// one's destination/enabled state, is refused. Same shape as the retention-cap check. + /// while a GA-created gated channel is present: only ADDING one, changing an existing + /// one's provider, destination or enabled state is refused. Renames, NotifyOn* toggles and + /// deletion stay with the tenant admin. Same shape as the retention-cap check. /// /// internal static string? ValidateTelegramChannelGate(string? candidateJson, string? existingJson, bool isGlobalAdmin) @@ -430,24 +445,26 @@ internal static bool IsValidDomainName(string? domainName) return null; var candidates = Shared.Models.Notifications.NotificationChannel.ParseList(candidateJson) - .Where(c => c.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Telegram) + .Where(c => Shared.Models.Notifications.NotificationChannel.IsGlobalAdminOnlyProvider(c.ProviderType)) .ToList(); if (candidates.Count == 0) return null; var existingById = Shared.Models.Notifications.NotificationChannel.ParseList(existingJson) - .Where(c => c.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Telegram) + .Where(c => Shared.Models.Notifications.NotificationChannel.IsGlobalAdminOnlyProvider(c.ProviderType)) .GroupBy(c => c.Id, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); foreach (var channel in candidates) { if (!existingById.TryGetValue(channel.Id, out var stored) - || !string.Equals(stored.Url, channel.Url, StringComparison.Ordinal) + || stored.ProviderType != channel.ProviderType + || !string.Equals(stored.Url ?? string.Empty, channel.Url ?? string.Empty, StringComparison.Ordinal) || stored.Enabled != channel.Enabled) { - return "Telegram channels can only be configured by a Global Administrator."; + var provider = channel.ProviderType == (int)Shared.Models.Notifications.WebhookProviderType.Push ? "Push" : "Telegram"; + return $"{provider} channels can only be configured by a Global Administrator."; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/NoStoreCacheMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/NoStoreCacheMiddleware.cs index 6f7a002e4..ac4e76495 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/NoStoreCacheMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/NoStoreCacheMiddleware.cs @@ -61,6 +61,8 @@ public class NoStoreCacheMiddleware : IFunctionsWorkerMiddleware "/api/config/", // /api/config/{tenantId} + sub-routes "/api/sessions/", // /api/sessions/{id} + sub-routes (events, signals, …) "/api/search/", // /api/search/{quick,sessions,sessions-by-event,sessions-by-cve} + "/api/push/", // pairing codes, device tokens, device lists (Web Push channel) + "/api/global/push/", // the platform-scope twin "/api/global/raw/", // GA cross-tenant raw event/session/table surfaces "/api/global/search/", // GA cross-tenant search results "/api/global/session-reports/", // /download-url (SAS) + /{reportId}/note diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 4aa68698e..454610114 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -8,6 +8,7 @@ using AutopilotMonitor.Functions.Middleware; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Functions.Services.Push; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -504,6 +505,30 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Web Push channel (plan push-relay). Settings are read once (an invalid or missing VAPID key +// turns the channel off with a startup warning); the sender is a singleton over the SSRF-gated +// "push" client; the delivery service is the dispatcher's Push transport AND the role-loss +// revoker; OpsEventService is reached lazily because the ops alert path leads back into the +// dispatcher (OpsEventService → OpsAlertDispatchService → dispatcher → push sender). +builder.Services.AddSingleton(sp => AutopilotMonitor.Functions.Services.Push.PushSettings.Load( + builder.Configuration, sp.GetRequiredService().CreateLogger("Push"))); +builder.Services.AddPushSender(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => new Lazy(() => sp.GetRequiredService())); +builder.Services.AddSingleton(); +builder.Services.AddSingleton( + sp => sp.GetRequiredService()); +builder.Services.AddSingleton( + sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton( + sp => sp.GetRequiredService()); +// Push endpoints are per-device send capabilities: the dependency rows for the push services +// keep scheme + host only (K20). +builder.Services.AddApplicationInsightsTelemetryProcessor(); + var app = builder.Build(); // Validate critical security configuration at startup diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index d98a5a14d..76e8fe740 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -210,6 +210,15 @@ public static class EndpointAccessPolicyCatalog // in constant time against the OpsAlertWebhookSecret app setting (fail-closed while // unset). See AzureMonitorAlertWebhookFunction. new("POST", "ops/alert-webhook", EndpointPolicy.PublicAnonymous), + // Web Push receiver (the phone never signs in — plan push-relay K8–K11). begin/pair are + // gated by the one-shot pairing code (hashed, ≥50 bits, 10 min, five-failure burn) plus a + // per-IP throttle; the device routes by the device token in X-Push-Device-Token, which + // authorizes nothing beyond the device's own registration. See PushReceiverFunctions. + new("POST", "push/pair/begin", EndpointPolicy.PublicAnonymous), + new("POST", "push/pair", EndpointPolicy.PublicAnonymous), + new("GET", "push/device", EndpointPolicy.PublicAnonymous), + new("PUT", "push/device", EndpointPolicy.PublicAnonymous), + new("DELETE", "push/device", EndpointPolicy.PublicAnonymous), // ── DeviceOrBootstrapAuth ─────────────────────────────────────── new("POST", "agent/register-session", EndpointPolicy.DeviceOrBootstrapAuth), @@ -708,6 +717,27 @@ public static class EndpointAccessPolicyCatalog // Dismiss endpoints stay TenantAdminOrGA: dismissal is currently tenant-shared (clearing for // one user clears for all). When per-user dismiss lands, dismiss can drop to MemberRead too. new("GET", "notifications", EndpointPolicy.MemberRead, TenantScoping.Jwt), + // Web Push pairing + devices, tenant scope (plan push-relay K5): Admin/Operator only (F10, + // no Viewer); the function re-checks the TABLE role because a Global Admin passes this tier + // by bypass without holding a tenant role. Watches: QueryParam like the annotations route. + new("POST", "push/pairings", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt), + new("GET", "push/pairings/{pairingId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt), + new("POST", "push/pairings/{pairingId}/confirm", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt), + new("POST", "push/pairings/{pairingId}/reject", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt), + new("GET", "push/devices", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt), + new("DELETE", "push/devices/{deviceId}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt), + new("POST", "push/devices/{deviceId}/test", EndpointPolicy.TenantAdminOrOperator, TenantScoping.Jwt), + new("GET", "sessions/{sessionId}/watch", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam), + new("PUT", "sessions/{sessionId}/watch", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam), + new("DELETE", "sessions/{sessionId}/watch", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam), + // The platform-scope twin (operator devices, ops channels). + new("POST", "global/push/pairings", EndpointPolicy.GlobalAdminOnly), + new("GET", "global/push/pairings/{pairingId}", EndpointPolicy.GlobalAdminOnly), + new("POST", "global/push/pairings/{pairingId}/confirm", EndpointPolicy.GlobalAdminOnly), + new("POST", "global/push/pairings/{pairingId}/reject", EndpointPolicy.GlobalAdminOnly), + new("GET", "global/push/devices", EndpointPolicy.GlobalAdminOnly), + new("DELETE", "global/push/devices/{deviceId}", EndpointPolicy.GlobalAdminOnly), + new("POST", "global/push/devices/{deviceId}/test", EndpointPolicy.GlobalAdminOnly), new("POST", "notifications/dismiss-all", EndpointPolicy.TenantAdminOrGA, TenantScoping.Jwt), new("POST", "notifications/{notificationId}/dismiss", EndpointPolicy.TenantAdminOrGA, TenantScoping.Jwt), // ops-events is PLATFORM-OPERATIONAL data (Consent/Maintenance/Security/Tenant/Agent/SLA) for the diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs index 8d23ebe02..d5c93a129 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs @@ -216,7 +216,7 @@ private async Task SafeNotifyRuleChannelsAsync( var alert = Notifications.NotificationAlertBuilder.BuildRuleFiredAlert( result, session?.DeviceName, session?.SerialNumber, sessionUrl); - await _channelDispatcher.SendToChannelsAsync(targets, alert).ConfigureAwait(false); + await _channelDispatcher.SendToChannelsAsync(targets, alert, Notifications.NotificationScope.Tenant(envelope.TenantId)).ConfigureAwait(false); _logger.LogInformation( "{Prefix} Rule-notify: {RuleId} → {ChannelCount} channel(s)", diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs index 6d2dfa2cc..36dee63c1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs @@ -20,6 +20,21 @@ private async Task SendWebhookNotificationsAsync( SessionSummary? updatedSession, bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason, List ruleResults) { + // A failure alert requires an actual failure-ish verdict: an agent_timeout + // enrollment_failed can honestly classify to AwaitingUser or even Succeeded + // (ApplyAgentGaveUpVerdictAsync), in which case failureReason stays null and no + // failure notification must go out for a session that did not fail. + var failureVerdictApplies = c.FailureEvent != null && failureReason != null; + var reachedTerminalStatus = statusTransitioned && (c.CompletionEvent != null || failureVerdictApplies); + + // Session watches ("notify me when this session ends") fire on the same terminal gate + // as the enrollment alert, independent of any channel configuration. Fail-soft inside. + if (reachedTerminalStatus && _pushWatchNotifier != null) + { + await _pushWatchNotifier.NotifyAsync(request.TenantId, request.SessionId, c.CompletionEvent != null, + updatedSession?.DeviceName, updatedSession?.SerialNumber).ConfigureAwait(false); + } + var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); // Per-channel routing: each enabled channel opts into event kinds via its NotifyOn* // toggles. Legacy single-webhook tenants get one synthesized channel with their @@ -40,12 +55,7 @@ private async Task SendWebhookNotificationsAsync( ? Constants.PortalSessionUrl(request.SessionId) : null; - // A failure alert requires an actual failure-ish verdict: an agent_timeout - // enrollment_failed can honestly classify to AwaitingUser or even Succeeded - // (ApplyAgentGaveUpVerdictAsync), in which case failureReason stays null and no - // failure notification must go out for a session that did not fail. - var failureVerdictApplies = c.FailureEvent != null && failureReason != null; - if (statusTransitioned && (c.CompletionEvent != null || failureVerdictApplies)) + if (reachedTerminalStatus) { var isSuccess = c.CompletionEvent != null; var targets = isSuccess ? successChannels : failureChannels; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs index c08bab473..2df1f0fc7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs @@ -49,6 +49,7 @@ public sealed partial class EventIngestProcessor private readonly Ime.IImeMsiArchiveProducer _imeMsiArchiveProducer; private readonly IConfiguration _configuration; private readonly Ime.ImePatternHealthService _imePatternHealth; + private readonly Push.IPushSessionWatchNotifier? _pushWatchNotifier; /// App setting kill switch: set to "true" to skip the CMTrace skew tripwire entirely. Fail-open — the tripwire only notifies, it never mutates data. internal const string CmTraceSkewTripwireKillSwitchSetting = "CmTraceSkewTripwireDisabled"; @@ -71,8 +72,10 @@ public EventIngestProcessor( IVulnerabilityCorrelateProducer vulnProducer, Ime.IImeMsiArchiveProducer imeMsiArchiveProducer, IConfiguration configuration, - Ime.ImePatternHealthService imePatternHealth) + Ime.ImePatternHealthService imePatternHealth, + Push.IPushSessionWatchNotifier? pushWatchNotifier = null) { + _pushWatchNotifier = pushWatchNotifier; _imePatternHealth = imePatternHealth; _logger = logger; _sessionRepo = sessionRepo; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs index 08ee0a295..36393ec6c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs @@ -48,6 +48,28 @@ public GlobalAdminService( _logger = logger; } + /// + /// The container picks this (longest satisfiable) constructor; the four-argument one stays + /// for the tests that mock the service with positional arguments (Moq does not fill optional + /// parameters). + /// + public GlobalAdminService( + IAdminRepository adminRepo, + AdminIdentityBindingService bindings, + IMemoryCache cache, + ILogger logger, + Push.IPushDeviceRevoker pushRevoker) + : this(adminRepo, bindings, cache, logger) + { + _pushRevoker = pushRevoker; + } + + private readonly Push.IPushDeviceRevoker? _pushRevoker; + + /// Role-loss hook (K6): a removed or disabled Global Admin loses the platform-scope push devices. Fail-soft. + private Task RevokePushDevicesAsync(string upn, string reason) + => _pushRevoker?.RevokeOwnerAsync(Notifications.NotificationScope.Platform, upn, reason) ?? Task.CompletedTask; + /// /// Checks whether the caller is a Global Admin (the GlobalAdmin platform role, identity-bound). /// @@ -148,6 +170,7 @@ public async Task RemoveGlobalAdminAsync(string upn) // Invalidate cache _cache.Remove($"global-role:{upn}"); + await RevokePushDevicesAsync(upn, "global_admin_removed"); } /// @@ -161,6 +184,7 @@ public async Task DisableGlobalAdminAsync(string upn) // Invalidate cache _cache.Remove($"global-role:{upn}"); + await RevokePushDevicesAsync(upn, "global_admin_disabled"); } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/IPushChannelSender.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/IPushChannelSender.cs new file mode 100644 index 000000000..816aef9bb --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/IPushChannelSender.cs @@ -0,0 +1,16 @@ +using AutopilotMonitor.Shared.Models.Notifications; + +namespace AutopilotMonitor.Functions.Services.Notifications; + +/// +/// The Push transport behind : fans one alert out to +/// the Active paired devices of a scope. Implemented by PushDeliveryService; an interface +/// so the dispatcher's routing tests need no push plumbing. Never throws. +/// +public interface IPushChannelSender +{ + Task SendAsync(NotificationScope scope, NotificationAlert alert); + + /// The "send test" variant: reports the outcome instead of swallowing it. + Task SendWithResultAsync(NotificationScope scope, NotificationAlert alert); +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs index b7fbd1ae5..54674973d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs @@ -10,40 +10,53 @@ namespace AutopilotMonitor.Functions.Services.Notifications /// (enrollment, SLA, analyze rules, ops alerts) goes through here, so a new provider is added /// in exactly one place. /// - /// Two transports today: goes to + /// Three transports: goes to /// (plain text through the platform bot, the channel's - /// Url field carrying the chat ID); everything else is a rendered webhook POST via - /// . Telegram is deliberately NOT a renderer — it has no - /// endpoint URL of its own and must never reach the SSRF-guarded webhook path. + /// Url field carrying the chat ID); goes to the + /// (encrypted Web Push to the paired devices of the + /// , the channel carrying no destination at all); everything + /// else is a rendered webhook POST via . Neither + /// Telegram nor Push is a renderer — they have no caller-supplied endpoint and must never + /// reach the SSRF-guarded webhook path. /// /// public class NotificationChannelDispatcher { private readonly WebhookNotificationService _webhook; private readonly TelegramNotificationService _telegram; + private readonly IPushChannelSender _push; public NotificationChannelDispatcher( WebhookNotificationService webhook, - TelegramNotificationService telegram) + TelegramNotificationService telegram, + IPushChannelSender push) { _webhook = webhook; _telegram = telegram; + _push = push; } /// /// Sends a notification to every channel in (callers pre-filter /// by and the relevant NotifyOn* toggle, or by - /// rule-level channel ids). Channels are dispatched sequentially and independently — a - /// failing destination only logs a warning and never blocks the remaining channels or the - /// caller's pipeline. + /// rule-level channel ids) on behalf of . Channels are dispatched + /// sequentially and independently — a failing destination only logs a warning and never + /// blocks the remaining channels or the caller's pipeline. /// - public virtual async Task SendToChannelsAsync(IEnumerable channels, NotificationAlert alert) + public virtual async Task SendToChannelsAsync( + IEnumerable channels, NotificationAlert alert, NotificationScope scope) { foreach (var channel in channels) { - if (channel == null || string.IsNullOrEmpty(channel.Url)) + if (channel == null || !channel.HasDestination()) continue; + if (channel.ProviderType == (int)WebhookProviderType.Push) + { + await _push.SendAsync(scope, alert); + continue; + } + if (channel.ProviderType == (int)WebhookProviderType.Telegram) { await _telegram.SendOpsAlertAsync(channel.Url!, alert); @@ -63,11 +76,15 @@ await _webhook.SendNotificationAsync( /// Sends to a single channel and REPORTS the outcome — the "send test notification" /// endpoints. Not fire-and-forget; never throws. /// - public virtual async Task SendWithResultAsync(NotificationChannel channel, NotificationAlert alert) + public virtual async Task SendWithResultAsync( + NotificationChannel channel, NotificationAlert alert, NotificationScope scope) { - if (channel == null || string.IsNullOrWhiteSpace(channel.Url)) + if (channel == null || !channel.HasDestination()) return new WebhookTestResult { Success = false, Message = "This channel has no destination configured." }; + if (channel.ProviderType == (int)WebhookProviderType.Push) + return await _push.SendWithResultAsync(scope, alert); + if (channel.ProviderType == (int)WebhookProviderType.Telegram) return await _telegram.SendAlertWithResultAsync(channel.Url!, alert); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationDispatchHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationDispatchHandler.cs index e4ee5f5d4..ee220f147 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationDispatchHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationDispatchHandler.cs @@ -58,7 +58,7 @@ public virtual async Task HandleAsync(NotificationDispatchEnvelope envelope, Can return; } - await _dispatcher.SendToChannelsAsync(targets, envelope.Alert).ConfigureAwait(false); + await _dispatcher.SendToChannelsAsync(targets, envelope.Alert, NotificationScope.Tenant(envelope.TenantId)).ConfigureAwait(false); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationScope.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationScope.cs new file mode 100644 index 000000000..16b8bda75 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationScope.cs @@ -0,0 +1,40 @@ +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Functions.Services.Notifications; + +/// +/// Whose channels an alert is being sent through: the platform's own ops channels or one +/// tenant's channels. Webhook providers never needed this — their destination is in the +/// channel — but a Push channel has no destination of its own: its recipients are the paired +/// devices of the SCOPE, resolved at send time. Every caller of the dispatcher sets the scope +/// from trusted context (the envelope's tenant, the evaluated tenant config, the ops path); +/// it is never derived from the alert's facts, because an ops alert's "Tenant" fact names the +/// customer the event is ABOUT, not the channel owner. +/// +public readonly record struct NotificationScope +{ + /// PushDevices partition key: or the lowercase tenant id. + public string Key { get; } + + private NotificationScope(string key) => Key = key; + + public static NotificationScope Platform { get; } = new(Constants.Push.PlatformScope); + + public static NotificationScope Tenant(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + throw new ArgumentException("tenantId required", nameof(tenantId)); + return new NotificationScope(tenantId.Trim().ToLowerInvariant()); + } + + /// The scope a stored partition key names (device rows, grants, watch scopes). + public static NotificationScope FromKey(string key) + => key == Constants.Push.PlatformScope ? Platform : Tenant(key); + + public bool IsPlatform => Key == Constants.Push.PlatformScope; + + /// False for default(NotificationScope) — a caller that forgot the scope; the push branch refuses it. + public bool IsSet => !string.IsNullOrEmpty(Key); + + public override string ToString() => IsSet ? Key : "(unset)"; +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index a14d6de97..e55922344 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -106,6 +106,12 @@ public class TenantOffboardingHandler // Self-service delegation invitations + slot holds this tenant issued as a MANAGING tenant // (PK=home tenant id). Rows where it is the MANAGED tenant go via the property wipe below. Constants.TableNames.DelegationInvitations, + // Web Push channel: paired devices and session watches of the tenant scope (PK=tenantId; + // the "platform" partition never matches a tenant GUID) and the owner sign-in stamps + // homed in this tenant (PK=home tenant id). + Constants.TableNames.PushDevices, + Constants.TableNames.PushSessionWatches, + Constants.TableNames.PushOwners, }; // Variant D — RowKey-anchored wipes for tables whose ROW key is the tenant id. @@ -166,6 +172,8 @@ private static readonly (string Table, string Discriminator)[] DiscriminatorTabl // Delegation invitation / hold rows where this tenant is the MANAGED side (TenantId property set on // accept). Pending rows carry no TenantId and belong to the managing tenant's partition. Constants.TableNames.DelegationInvitations, + // Push pairing grants are keyed by code hash (PK="grant"); TenantId carries the scope. + Constants.TableNames.PushPairingGrants, }; // PR3.B plan §3 — Customs rules tables: archive each row to diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs index 48d14b2e6..74a73e22d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs @@ -58,16 +58,20 @@ public async Task DispatchAsync(string category, string eventType, string severi var (withPayload, plain) = ResolveTargets(matchingRules, channels); if (withPayload.Count == 0 && plain.Count == 0) return; + // Always the platform scope: the event's tenantId is the SUBJECT of the alert, never + // the channel owner — a Push channel here reaches the operators' devices only. if (plain.Count > 0) { await _channelDispatcher.SendToChannelsAsync(plain, - BuildAlert(category, eventType, severity, message, tenantId, detailsJson: null)); + BuildAlert(category, eventType, severity, message, tenantId, detailsJson: null), + NotificationScope.Platform); } if (withPayload.Count > 0) { await _channelDispatcher.SendToChannelsAsync(withPayload, - BuildAlert(category, eventType, severity, message, tenantId, detailsJson)); + BuildAlert(category, eventType, severity, message, tenantId, detailsJson), + NotificationScope.Platform); } _logger.LogInformation( diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index 09fe2b651..a43bd8f76 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -1160,6 +1160,27 @@ public Task RecordAzureMonitorAlertAsync(string alertRule, string severity, stri new { alertRule, monitorCondition, azureSeverity, monitoringService, targetResource, metricValue }); } + /// + /// The push sender got a configuration-class answer (400/401/403) from a push service, or a + /// device's VAPID kid matches no configured key. Error. Deduplicated to one per cause and + /// hour by the caller; details carry the service, status and kid — never an endpoint. + /// + public virtual Task RecordPushDeliveryFailedAsync(string service, int? statusCode, string kid, string reason) + => WriteAsync(OpsEventCategory.Platform, OpsEventTypes.PushDeliveryFailed, OpsEventSeverity.Error, + $"Push delivery to {service} failed: {reason}" + (statusCode is int s ? $" (HTTP {s})" : string.Empty), + null, "System.Push", + new { service, statusCode, kid, reason, portalPath = "/admin/settings/alerts" }); + + /// + /// A pairing or re-subscribe named an endpoint outside the push-service allow-list. Warning; + /// the hostname is the only endpoint-derived value recorded (the endpoint is a credential). + /// + public virtual Task RecordPushEndpointRefusedAsync(string host, string scopeKind) + => WriteAsync(OpsEventCategory.Platform, OpsEventTypes.PushEndpointRefused, OpsEventSeverity.Warning, + $"Push subscription refused: {host} is not a known push service", + null, "System.Push", + new { host, scope = scopeKind }); + // ── Core write method ────────────────────────────────────────────────── private async Task WriteAsync(string category, string eventType, string severity, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushAlertProjector.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushAlertProjector.cs new file mode 100644 index 000000000..4871bf703 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushAlertProjector.cs @@ -0,0 +1,286 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models.Notifications; + +namespace AutopilotMonitor.Functions.Services.Push; + +/// What one push carries after projection — the plaintext before encryption. +public sealed class PushPayload +{ + public string Id { get; init; } = default!; + public string Type { get; init; } = default!; + public string Title { get; init; } = default!; + public string Body { get; init; } = default!; + /// error · warning · success · info (the receiver's accent). + public string Severity { get; init; } = "info"; + public List<(string Name, string Value)> Facts { get; init; } = new(); + public string? PortalUrl { get; init; } + /// Notification tag: same tag = the browser replaces the earlier one where it honours tags. + public string Tag { get; init; } = default!; + public string Scope { get; init; } = "tenant"; + public DateTime TimestampUtc { get; init; } +} + +/// +/// The lock-screen projection (Korrektur K23): a push carries only what may stand on a lock +/// screen and in an unmanaged phone's history. It reads , +/// , the allow-listed , +/// , and the +/// portal deep link in — never Sections (rule +/// explanations interpolated from log lines), never DataJson, never the "Failure Reason" +/// fact (the agent's event message, telemetry free text), and for the platform scope never the +/// ops event's message (it embeds UPNs and mail addresses). A test pins the members read. +/// +public static class PushAlertProjector +{ + /// Plaintext budget: well under RFC 8291's 3993 bytes and the ~2.7 KB of bridged routes. + public const int MaxPayloadBytes = 2048; + public const int MaxFactLength = 64; + public const int MaxTitleLength = 80; + public const int MaxBodyLength = 160; + + /// + /// Fact names a tenant-scope push may carry. "Failure Reason" AND "Last Failure" (the same agent + /// event message under the consecutive-failures alert's name) are deliberately absent. + /// + public static readonly IReadOnlySet TenantFactAllowList = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Device", "Serial", "Hardware", "Manufacturer", "Model", "Duration", "Rule", "Severity", "Category", + "Confidence", "Breach Type", "Current Rate", "Target Rate", "Current P95", "Target Max", "Current", + "Target", "Total Sessions", "Failed Sessions", "Period", "Consecutive Failures", + "Last Device", "Active For", "Started At", "Tenant", "Top Failing App", "Data Quality", + }; + + /// Fact names a platform-scope (ops) push may carry — the baseline only, payload facts never. + public static readonly IReadOnlySet PlatformFactAllowList = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Category", "Event", "Severity", "Tenant", + }; + + private const string HardwareRejectedEventType = "hardware_rejected"; + + public static PushPayload Project(NotificationAlert alert, NotificationScope scope, string entryId, DateTime nowUtc) + { + var eventType = string.IsNullOrWhiteSpace(alert.EventType) ? "alert" : alert.EventType!; + var portalUrl = FindPortalUrl(alert); + var allow = scope.IsPlatform ? PlatformFactAllowList : TenantFactAllowList; + + var facts = new List<(string, string)>(); + foreach (var fact in alert.Facts) + { + if (fact == null || string.IsNullOrWhiteSpace(fact.Name) || !allow.Contains(fact.Name)) + continue; + + var value = string.Equals(fact.Name, "Serial", StringComparison.OrdinalIgnoreCase) + ? MaskSerial(fact.Value) + : Clean(fact.Value, MaxFactLength); + if (value.Length == 0) + continue; + + facts.Add((fact.Name, value)); + } + + string title; + string body; + if (scope.IsPlatform) + { + // The ops message is the alert's Summary: category/event/severity is all a lock screen gets. + var category = facts.FirstOrDefault(f => f.Item1.Equals("Category", StringComparison.OrdinalIgnoreCase)).Item2; + var severity = facts.FirstOrDefault(f => f.Item1.Equals("Severity", StringComparison.OrdinalIgnoreCase)).Item2; + title = "Ops alert"; + body = Clean(string.IsNullOrEmpty(category) ? eventType : $"{category}/{eventType}", MaxBodyLength) + + (string.IsNullOrEmpty(severity) ? string.Empty : $" · {severity}"); + } + else if (string.Equals(eventType, HardwareRejectedEventType, StringComparison.OrdinalIgnoreCase)) + { + // Pre-auth distress strings are whoever-sent-them text; a fixed template keeps the + // lock screen free of them, the portal keeps the full strings. + var serial = facts.FirstOrDefault(f => f.Item1.Equals("Serial", StringComparison.OrdinalIgnoreCase)).Item2; + title = "Hardware rejected"; + body = string.IsNullOrEmpty(serial) ? "A device outside the hardware whitelist tried to enroll." : $"Serial {serial} is not in the hardware whitelist."; + facts.RemoveAll(f => !f.Item1.Equals("Serial", StringComparison.OrdinalIgnoreCase)); + } + else + { + title = Clean(alert.Title, MaxTitleLength); + body = Clean(alert.Summary, MaxBodyLength); + } + + if (title.Length == 0) + title = "Autopilot Monitor"; + + return new PushPayload + { + Id = entryId, + Type = eventType, + Title = title, + Body = body, + Severity = alert.Severity switch + { + NotificationSeverity.Error => "error", + NotificationSeverity.Warning => "warning", + NotificationSeverity.Success => "success", + _ => "info", + }, + Facts = facts, + PortalUrl = portalUrl, + Tag = BuildTag(eventType, portalUrl, entryId), + Scope = scope.IsPlatform ? "platform" : "tenant", + TimestampUtc = nowUtc, + }; + } + + /// A system message to one device (test, paired, revoked, muted, paused, session watch). + public static PushPayload SystemMessage(string type, string title, string body, string severity, NotificationScope scope, DateTime nowUtc, string? portalUrl = null) + => new() + { + Id = Guid.NewGuid().ToString("N"), + Type = type, + Title = Clean(title, MaxTitleLength), + Body = Clean(body, MaxBodyLength), + Severity = severity, + PortalUrl = portalUrl, + Tag = type, + Scope = scope.IsPlatform ? "platform" : "tenant", + TimestampUtc = nowUtc, + }; + + /// + /// Declarative Web Push JSON (web_push: 8030): Safari ≥ 18.4 shows it without the + /// service worker when the worker fails; mutable wakes the worker so it can store the + /// history entry; every other browser hands the same object to the worker's push event. + /// Over budget, facts are dropped from the end, then the body is shortened. + /// + public static byte[] ToJsonBytes(PushPayload payload) + { + var facts = new List<(string Name, string Value)>(payload.Facts); + var body = payload.Body; + while (true) + { + var bytes = Serialize(payload, facts, body); + if (bytes.Length <= MaxPayloadBytes) + return bytes; + if (facts.Count > 0) + facts.RemoveAt(facts.Count - 1); + else if (body.Length > 32) + body = body.Substring(0, body.Length / 2); + else + return bytes; + } + } + + private static byte[] Serialize(PushPayload p, List<(string Name, string Value)> facts, string body) + { + using var stream = new MemoryStream(); + using (var w = new Utf8JsonWriter(stream)) + { + w.WriteStartObject(); + w.WriteNumber("web_push", 8030); + w.WriteBoolean("mutable", true); + w.WriteStartObject("notification"); + w.WriteString("title", p.Title); + w.WriteString("body", body); + w.WriteString("navigate", Constants.PortalPushHistoryUrl(p.Id)); + w.WriteString("tag", p.Tag); + w.WriteStartObject("data"); + w.WriteNumber("v", 1); + w.WriteString("id", p.Id); + w.WriteString("type", p.Type); + w.WriteString("ts", p.TimestampUtc.ToString("O")); + w.WriteString("severity", p.Severity); + w.WriteStartArray("facts"); + foreach (var (name, value) in facts) + { + w.WriteStartObject(); + w.WriteString("name", name); + w.WriteString("value", value); + w.WriteEndObject(); + } + w.WriteEndArray(); + if (p.PortalUrl != null) + w.WriteString("portalUrl", p.PortalUrl); + else + w.WriteNull("portalUrl"); + w.WriteString("scope", p.Scope); + w.WriteEndObject(); + w.WriteEndObject(); + w.WriteEndObject(); + } + return stream.ToArray(); + } + + /// Last four characters only: the full serial is the Progress Portal's access proof (F13). + public static string MaskSerial(string? serial) + { + var s = Clean(serial, 64); + if (s.Length == 0 || s == "–" || s == "-") + return string.Empty; + return s.Length <= 4 ? "…" + s : "…" + s.Substring(s.Length - 4); + } + + /// Control and format characters out, whitespace collapsed, hard length cap. + public static string Clean(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var sb = new StringBuilder(value.Length); + var lastWasSpace = false; + foreach (var ch in value.Trim()) + { + if (char.IsControl(ch) || char.GetUnicodeCategory(ch) == System.Globalization.UnicodeCategory.Format) + continue; + if (char.IsWhiteSpace(ch)) + { + if (lastWasSpace) continue; + sb.Append(' '); + lastWasSpace = true; + continue; + } + sb.Append(ch); + lastWasSpace = false; + } + + // A removed trailing control character can leave one collapsed space behind. + var result = sb.ToString().TrimEnd(); + return result.Length > maxLength ? result.Substring(0, maxLength - 1).TrimEnd() + "…" : result; + } + + private static string? FindPortalUrl(NotificationAlert alert) + { + foreach (var action in alert.Actions) + { + if (action?.Url != null && action.Url.StartsWith(Constants.PortalBaseUrl, StringComparison.OrdinalIgnoreCase)) + return action.Url; + } + return null; + } + + /// + /// Session-bound alerts collapse per session where the browser honours tags (best effort, + /// K21); everything else is its own notification. + /// + private static string BuildTag(string eventType, string? portalUrl, string entryId) + { + if (portalUrl == null || !portalUrl.Contains("id=", StringComparison.Ordinal)) + return entryId; + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(portalUrl)); + return "s-" + Convert.ToHexString(digest, 0, 6).ToLowerInvariant(); + } + + /// + /// The push-service Topic for a session-bound alert: 24 HMAC bytes = exactly 32 base64url + /// characters (Apple's cap), keyed with the platform's signing key so the push services see + /// an opaque value, never a session id. Null without a session or a key. + /// + public static string? BuildTopic(string? portalUrl, byte[]? hmacKey) + { + if (portalUrl == null || hmacKey == null || hmacKey.Length == 0 || !portalUrl.Contains("id=", StringComparison.Ordinal)) + return null; + var mac = HMACSHA256.HashData(hmacKey, Encoding.UTF8.GetBytes(portalUrl)); + return System.Buffers.Text.Base64Url.EncodeToString(mac.AsSpan(0, 24)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs new file mode 100644 index 000000000..267ddd6c2 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushDeliveryService.cs @@ -0,0 +1,539 @@ +using System.Collections.Concurrent; +using System.Text; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Push; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models.Notifications; +using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services.Push; + +/// +/// Revokes every device of one owner in a scope — the role-loss hook (K6). Implementations never +/// throw: the admin services call it after the role row changed, and a push failure must not turn +/// a completed role change into an error. +/// +public interface IPushDeviceRevoker +{ + Task RevokeOwnerAsync(NotificationScope scope, string upn, string reason); +} + +/// +/// The Push transport: resolves the Active devices of a scope, re-checks every owner's role and +/// freshness at send time, projects the alert onto the lock-screen shape, encrypts it per device +/// (RFC 8291) and sends it through the SSRF-gated push client. Outcomes land on the device row +/// (delivered / stale / failures) and in the PushDelivery custom event; endpoints never +/// land in a log. Fan-out per scope runs with bounded concurrency so the direct ops path stays +/// short. Never throws into the dispatcher. +/// +public class PushDeliveryService : IPushChannelSender, IPushDeviceRevoker +{ + public const string DeliveryEventName = "PushDelivery"; + public const int FanOutConcurrency = 8; + public const int FloodWindowMinutes = 10; + public const int FloodWindowCap = 10; + /// Consecutive transport failures after which a device pauses (re-armed by the next successful re-subscribe). + public const int PauseAfterConsecutiveFailures = 20; + private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(4); + private static readonly TimeSpan SystemTtl = TimeSpan.FromMinutes(30); + + private readonly IPushDeviceRepository _repo; + private readonly PushEligibility _eligibility; + private readonly PushSettings _settings; + private readonly WebPushSender _sender; + private readonly Lazy _opsEvents; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly TelemetryClient? _telemetry; + private readonly byte[]? _topicKey; + + public PushDeliveryService( + IPushDeviceRepository repo, + PushEligibility eligibility, + PushSettings settings, + WebPushSender sender, + Lazy opsEvents, + IMemoryCache cache, + ILogger logger, + TelemetryClient? telemetry = null) + { + _repo = repo; + _eligibility = eligibility; + _settings = settings; + _sender = sender; + _opsEvents = opsEvents; + _cache = cache; + _logger = logger; + _telemetry = telemetry; + // Topic HMAC key (K21): the platform's pagination signing key, any bytes do; absent ⇒ no Topic. + var key = Environment.GetEnvironmentVariable("PaginationTokenSigningKey"); + _topicKey = string.IsNullOrEmpty(key) ? null : Encoding.UTF8.GetBytes(key); + } + + // ── IPushChannelSender ─────────────────────────────────────────────────── + + public async Task SendAsync(NotificationScope scope, NotificationAlert alert) + { + try + { + await SendCoreAsync(scope, alert).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Push fan-out failed for scope {Scope} alert {EventType}", scope, alert?.EventType); + } + } + + public async Task SendWithResultAsync(NotificationScope scope, NotificationAlert alert) + { + try + { + if (!_settings.IsConfigured) + return new WebhookTestResult { Success = false, Message = "The push channel is not configured on the platform (VAPID key missing)." }; + + var stats = await SendCoreAsync(scope, alert).ConfigureAwait(false); + if (stats.Targets == 0) + return new WebhookTestResult { Success = false, Message = "No active paired device in this scope — pair a device first." }; + + return new WebhookTestResult + { + Success = stats.Delivered > 0, + Message = $"Delivered to {stats.Delivered} of {stats.Targets} paired device(s)" + + (stats.Suppressed > 0 ? $", {stats.Suppressed} muted by flood control" : string.Empty) + ".", + }; + } + catch (Exception ex) + { + return new WebhookTestResult { Success = false, Message = $"Push send failed: {ex.Message}" }; + } + } + + internal sealed class DeliveryStats + { + public int Targets; + public int Delivered; + public int Suppressed; + } + + internal async Task SendCoreAsync(NotificationScope scope, NotificationAlert alert) + { + var stats = new DeliveryStats(); + if (!scope.IsSet) + { + _logger.LogError("Push send refused: the caller passed no NotificationScope for alert {EventType}", alert.EventType); + return stats; + } + if (!_settings.IsConfigured) + { + _logger.LogWarning("Push send skipped for scope {Scope}: {Reason}", scope, _settings.ConfigurationError); + return stats; + } + + var now = DateTime.UtcNow; + var candidates = (await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false)) + .Where(d => d.Status == Constants.Push.DeviceStatus.Active && d.Kind == "webpush") + .ToList(); + if (candidates.Count == 0) + return stats; + + var recipients = await FilterEligibleAsync(scope, candidates, now).ConfigureAwait(false); + if (recipients.Count == 0) + return stats; + + var payload = PushAlertProjector.Project(alert, scope, Guid.NewGuid().ToString("N"), now); + var bytes = PushAlertProjector.ToJsonBytes(payload); + var options = BuildOptions(alert, payload); + stats.Targets = recipients.Count; + + var delivered = 0; + var suppressed = 0; + await Parallel.ForEachAsync(recipients, new ParallelOptions { MaxDegreeOfParallelism = FanOutConcurrency }, async (device, ct) => + { + var slot = await ReserveSlotAsync(device, now).ConfigureAwait(false); + if (slot != FloodDecision.Allowed) + { + Interlocked.Increment(ref suppressed); + if (slot == FloodDecision.SuppressedFirst) + { + var until = (device.WindowStartUtc ?? now).AddMinutes(FloodWindowMinutes); + var muted = PushAlertProjector.SystemMessage("push_muted", "Further alerts muted", + $"More than {FloodWindowCap} alerts in {FloodWindowMinutes} minutes — muted until {until:HH:mm} UTC.", + "warning", scope, now); + await SendToDeviceAsync(device, muted, PushAlertProjector.ToJsonBytes(muted), new PushSendOptions(SystemTtl, PushUrgency.Normal, null), alert.EventType).ConfigureAwait(false); + } + return; + } + + var outcome = await SendToDeviceAsync(device, payload, bytes, options, alert.EventType).ConfigureAwait(false); + if (outcome == PushOutcome.Delivered) + Interlocked.Increment(ref delivered); + }).ConfigureAwait(false); + + stats.Delivered = delivered; + stats.Suppressed = suppressed; + return stats; + } + + /// One push to one device, outside the flood window: test, paired, revoked, paused, session watch. + public async Task SendSystemAsync(PushDevice device, PushPayload payload, TimeSpan? ttl = null) + { + if (!_settings.IsConfigured) + return PushOutcome.ConfigError; + return await SendToDeviceAsync(device, payload, PushAlertProjector.ToJsonBytes(payload), + new PushSendOptions(ttl ?? SystemTtl, PushUrgency.High, null), payload.Type).ConfigureAwait(false); + } + + /// + /// A system push to every Active device ONE person owns in a scope, through the same role and + /// freshness filter as an alert (D-327: resolved at every send) — the session-watch fan-out. + /// Returns the number of deliveries. + /// + public async Task SendSystemToOwnerAsync(NotificationScope scope, string ownerObjectId, PushPayload payload, TimeSpan? ttl = null) + { + if (!scope.IsSet || !_settings.IsConfigured || string.IsNullOrEmpty(ownerObjectId)) + return 0; + + var now = DateTime.UtcNow; + var owned = (await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false)) + .Where(d => d.Status == Constants.Push.DeviceStatus.Active + && string.Equals(d.OwnerObjectId, ownerObjectId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (owned.Count == 0) + return 0; + + var delivered = 0; + foreach (var device in await FilterEligibleAsync(scope, owned, now).ConfigureAwait(false)) + { + if (await SendSystemAsync(device, payload, ttl).ConfigureAwait(false) == PushOutcome.Delivered) + delivered++; + } + return delivered; + } + + // ── IPushDeviceRevoker ─────────────────────────────────────────────────── + + public async Task RevokeOwnerAsync(NotificationScope scope, string upn, string reason) + { + try + { + if (!scope.IsSet || string.IsNullOrWhiteSpace(upn)) + return; + var devices = (await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false)) + .Where(d => string.Equals(d.OwnerUpn, upn, StringComparison.OrdinalIgnoreCase)) + .ToList(); + foreach (var device in devices) + await RevokeDeviceAsync(device, reason).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Revoking push devices after role loss failed for scope {Scope}", scope); + } + } + + /// Wipe push (best effort, only reaches an online device), then the row is gone. + public async Task RevokeDeviceAsync(PushDevice device, string reason) + { + var scope = NotificationScope.FromKey(device.Scope); + if (device.Status == Constants.Push.DeviceStatus.Active || device.Status == Constants.Push.DeviceStatus.Paused) + { + var wipe = PushAlertProjector.SystemMessage("push_revoked", "Device unpaired", + "This device no longer receives Autopilot Monitor alerts.", "info", scope, DateTime.UtcNow); + await SendSystemAsync(device, wipe).ConfigureAwait(false); + } + await _repo.DeleteDeviceAsync(device.Scope, device.DeviceId).ConfigureAwait(false); + Track("PushPairing", new Dictionary + { + ["action"] = "revoked", ["reason"] = reason, ["scope"] = scope.IsPlatform ? "platform" : "tenant", ["platform"] = device.Platform, + }); + } + + // ── Recipients ────────────────────────────────────────────────────────── + + private async Task> FilterEligibleAsync(NotificationScope scope, List devices, DateTime now) + { + var eligibleByOwner = new Dictionary(StringComparer.OrdinalIgnoreCase); + var freshByOwner = new Dictionary(StringComparer.OrdinalIgnoreCase); + var result = new List(); + + foreach (var device in devices) + { + var ownerKey = $"{device.OwnerHomeTenantId}|{device.OwnerObjectId}|{device.OwnerUpn}"; + if (!eligibleByOwner.TryGetValue(ownerKey, out var eligible)) + { + eligible = await _eligibility.IsEligibleAsync(scope, device.OwnerUpn, device.OwnerObjectId, device.OwnerHomeTenantId).ConfigureAwait(false); + eligibleByOwner[ownerKey] = eligible; + } + if (!eligible) + { + // The role-loss hook normally gets here first; this is the belt to its braces. + _logger.LogInformation("Push device {DeviceId} skipped: owner no longer eligible in scope {Scope}", device.DeviceId, scope); + continue; + } + + if (!freshByOwner.TryGetValue(ownerKey, out var fresh)) + { + fresh = await _eligibility.IsOwnerFreshAsync(device.OwnerHomeTenantId, device.OwnerObjectId, now).ConfigureAwait(false); + freshByOwner[ownerKey] = fresh; + } + if (!fresh) + { + await PauseAsync(device, "owner_inactive", scope, now).ConfigureAwait(false); + continue; + } + + result.Add(device); + } + + return result; + } + + /// + /// The one pause path (send time and maintenance): a last "sign in to resume" push, then the + /// row flips to Paused. Only Active rows pause — the pause push itself may have found the + /// endpoint gone, and a Stale row stays Stale. + /// + public async Task PauseAsync(PushDevice device, string reason, NotificationScope scope, DateTime now) + { + var paused = PushAlertProjector.SystemMessage("push_paused", "Alerts paused", + "Sign in to the portal to resume alerts on this device.", "warning", scope, now); + await SendSystemAsync(device, paused).ConfigureAwait(false); + await _repo.MutateDeviceAsync(device.Scope, device.DeviceId, current => + current.Status != Constants.Push.DeviceStatus.Active ? null : new Dictionary + { + ["Status"] = Constants.Push.DeviceStatus.Paused, + ["StatusReason"] = reason, + ["StatusChangedUtc"] = now, + }).ConfigureAwait(false); + Track("PushPairing", new Dictionary { ["action"] = "paused", ["reason"] = reason, ["scope"] = scope.IsPlatform ? "platform" : "tenant", ["platform"] = device.Platform }); + } + + /// An owner-inactivity pause lifts itself once the owner signs in again (maintenance) — no re-pairing. + public async Task ResumeAsync(PushDevice device, DateTime now) + { + var scope = NotificationScope.FromKey(device.Scope); + var reason = device.StatusReason ?? string.Empty; + var landed = await _repo.MutateDeviceAsync(device.Scope, device.DeviceId, current => + current.Status != Constants.Push.DeviceStatus.Paused ? null : new Dictionary + { + ["Status"] = Constants.Push.DeviceStatus.Active, + ["StatusReason"] = null, + ["StatusChangedUtc"] = now, + }).ConfigureAwait(false); + if (landed) + Track("PushPairing", new Dictionary { ["action"] = "resumed", ["reason"] = reason, ["scope"] = scope.IsPlatform ? "platform" : "tenant", ["platform"] = device.Platform }); + } + + // ── Flood control (K19): the window lives on the row, merged under CAS ──── + + internal enum FloodDecision { Allowed, SuppressedFirst, Suppressed } + + private async Task ReserveSlotAsync(PushDevice device, DateTime now) + { + var decision = FloodDecision.Allowed; + var landed = await _repo.MutateDeviceAsync(device.Scope, device.DeviceId, current => + { + var windowStart = current.WindowStartUtc; + if (windowStart == null || now - windowStart.Value > TimeSpan.FromMinutes(FloodWindowMinutes)) + { + decision = FloodDecision.Allowed; + return new Dictionary { ["WindowStartUtc"] = now, ["WindowCount"] = 1, ["SuppressedCount"] = 0 }; + } + if (current.WindowCount < FloodWindowCap) + { + decision = FloodDecision.Allowed; + return new Dictionary { ["WindowCount"] = current.WindowCount + 1 }; + } + decision = current.SuppressedCount == 0 ? FloodDecision.SuppressedFirst : FloodDecision.Suppressed; + device.WindowStartUtc = windowStart; + return new Dictionary { ["SuppressedCount"] = current.SuppressedCount + 1 }; + }).ConfigureAwait(false); + + // A lost CAS race or a vanished row: do not send rather than send unbounded. + return landed ? decision : FloodDecision.Suppressed; + } + + // ── Transport ─────────────────────────────────────────────────────────── + + private PushSendOptions BuildOptions(NotificationAlert alert, PushPayload payload) + { + var urgency = alert.Severity switch + { + NotificationSeverity.Error => PushUrgency.High, + NotificationSeverity.Warning => PushUrgency.High, + NotificationSeverity.Success => PushUrgency.Normal, + _ => PushUrgency.Low, + }; + var ttl = string.Equals(alert.EventType, "test", StringComparison.OrdinalIgnoreCase) ? TimeSpan.FromMinutes(5) : DefaultTtl; + return new PushSendOptions(ttl, urgency, PushAlertProjector.BuildTopic(payload.PortalUrl, _topicKey)); + } + + private async Task SendToDeviceAsync(PushDevice device, PushPayload payload, byte[] plaintext, PushSendOptions options, string? alertEventType) + { + var keys = _settings.Keys!; + var service = ClassifyService(device.Endpoint); + var scopeKind = device.Scope == Constants.Push.PlatformScope ? "platform" : "tenant"; + + var key = keys.Resolve(device.VapidKid); + if (key == null) + { + await MarkStaleAsync(device, "vapid_key_mismatch", null).ConfigureAwait(false); + await RaiseDeliveryFailedAsync(service, null, device.VapidKid, "vapid_key_mismatch", alertEventType).ConfigureAwait(false); + Track(DeliveryEventName, new Dictionary { ["service"] = service, ["outcome"] = "config_error", ["statusCode"] = "", ["scope"] = scopeKind, ["kid"] = device.VapidKid }); + return PushOutcome.ConfigError; + } + + if (!WebPushSubscription.TryParse(device.Endpoint, device.P256dh, device.Auth, out var subscription, out _) || subscription == null) + { + await MarkStaleAsync(device, "invalid_subscription", null).ConfigureAwait(false); + return PushOutcome.Failed; + } + + PushSendResult result; + try + { + var body = WebPushEncryptor.Encrypt(plaintext, subscription); + result = await _sender.SendAsync(subscription, body, options, key, _settings.Subject, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Push send threw for device {DeviceId} ({Service})", device.DeviceId, service); + result = new PushSendResult(PushOutcome.Failed, null, null, "exception"); + } + + await ApplyOutcomeAsync(device, result, service, alertEventType).ConfigureAwait(false); + Track(DeliveryEventName, new Dictionary + { + ["service"] = service, + ["outcome"] = OutcomeName(result.Outcome), + ["statusCode"] = result.StatusCode?.ToString() ?? string.Empty, + ["scope"] = scopeKind, + ["kid"] = device.VapidKid, + ["type"] = payload.Type, + }); + return result.Outcome; + } + + private async Task ApplyOutcomeAsync(PushDevice device, PushSendResult result, string service, string? alertEventType) + { + var now = DateTime.UtcNow; + switch (result.Outcome) + { + case PushOutcome.Delivered: + await _repo.MutateDeviceAsync(device.Scope, device.DeviceId, _ => new Dictionary + { + ["LastDeliveredUtc"] = now, ["ConsecutiveFailures"] = 0, ["LastStatusCode"] = result.StatusCode, + }).ConfigureAwait(false); + break; + + case PushOutcome.Gone: + // K14: never delete — the receiver re-arms a Stale row on its next open (PUT push/device). + await MarkStaleAsync(device, "gone", result.StatusCode).ConfigureAwait(false); + break; + + case PushOutcome.PayloadTooLarge: + _logger.LogError("Push payload exceeded the service limit for {Service} — projection budget bug", service); + break; + + case PushOutcome.RetryLater: + await _repo.MutateDeviceAsync(device.Scope, device.DeviceId, _ => new Dictionary + { + ["LastStatusCode"] = result.StatusCode, + }).ConfigureAwait(false); + break; + + case PushOutcome.ConfigError: + await RecordFailureAsync(device, result.StatusCode, now).ConfigureAwait(false); + await RaiseDeliveryFailedAsync(service, result.StatusCode, device.VapidKid, result.Reason ?? "config_error", alertEventType).ConfigureAwait(false); + break; + + default: + await RecordFailureAsync(device, result.StatusCode, now).ConfigureAwait(false); + break; + } + } + + private Task RecordFailureAsync(PushDevice device, int? statusCode, DateTime now) + => _repo.MutateDeviceAsync(device.Scope, device.DeviceId, current => + { + var failures = current.ConsecutiveFailures + 1; + var patch = new Dictionary { ["ConsecutiveFailures"] = failures, ["LastStatusCode"] = statusCode }; + if (failures >= PauseAfterConsecutiveFailures) + { + patch["Status"] = Constants.Push.DeviceStatus.Paused; + patch["StatusReason"] = "delivery_failures"; + patch["StatusChangedUtc"] = now; + } + return patch; + }); + + private Task MarkStaleAsync(PushDevice device, string reason, int? statusCode) + => _repo.MutateDeviceAsync(device.Scope, device.DeviceId, _ => new Dictionary + { + ["Status"] = Constants.Push.DeviceStatus.Stale, + ["StatusReason"] = reason, + ["StatusChangedUtc"] = DateTime.UtcNow, + ["LastStatusCode"] = statusCode, + }); + + /// + /// At most one ops event per cause and hour per instance; never for a push that itself carried + /// a PushDeliveryFailed alert (the alarm must not feed its own loop, D-185). + /// + private async Task RaiseDeliveryFailedAsync(string service, int? statusCode, string kid, string reason, string? alertEventType) + { + if (string.Equals(alertEventType, OpsEventTypes.PushDeliveryFailed, StringComparison.Ordinal)) + return; + + var cacheKey = $"push-delivery-failed:{service}:{statusCode}:{reason}"; + if (_cache.TryGetValue(cacheKey, out _)) + return; + _cache.Set(cacheKey, true, TimeSpan.FromHours(1)); + + try + { + await _opsEvents.Value.RecordPushDeliveryFailedAsync(service, statusCode, kid, reason).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Recording PushDeliveryFailed ops event failed"); + } + } + + private static string OutcomeName(PushOutcome outcome) => outcome switch + { + PushOutcome.Delivered => "delivered", + PushOutcome.Gone => "gone", + PushOutcome.PayloadTooLarge => "bug", + PushOutcome.RetryLater => "retry_later", + PushOutcome.ConfigError => "config_error", + _ => "failed", + }; + + /// The push service behind an endpoint, by host — the only endpoint-derived value that leaves the backend. + public static string ClassifyService(string endpoint) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + return "other"; + var host = uri.Host; + if (host.EndsWith(".push.apple.com", StringComparison.OrdinalIgnoreCase)) return "apple"; + if (host.Equals("fcm.googleapis.com", StringComparison.OrdinalIgnoreCase)) return "fcm"; + if (host.EndsWith(".push.services.mozilla.com", StringComparison.OrdinalIgnoreCase)) return "mozilla"; + if (host.EndsWith(".notify.windows.com", StringComparison.OrdinalIgnoreCase)) return "wns"; + return "other"; + } + + private void Track(string name, Dictionary properties) + { + try + { + _telemetry?.TrackEvent(name, properties); + } + catch + { + // Telemetry is never allowed to fail a send. + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushEligibility.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushEligibility.cs new file mode 100644 index 000000000..0b55e6d4c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushEligibility.cs @@ -0,0 +1,106 @@ +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models.Notifications; + +namespace AutopilotMonitor.Functions.Services.Push; + +/// +/// The ONE answer to "may this person pair and receive push in this scope", asked at pairing +/// time and again at every send (Korrektur K2). Table-backed only — at send time there is no +/// token, so a role that exists solely as an Entra app-role claim cannot be re-checked and is +/// excluded fail-closed. Tenant scope: an enabled TenantAdmins row with role Admin or Operator +/// (Viewer may not pair, F10). Platform scope: an enabled GlobalAdmins row with role GlobalAdmin +/// (Global Reader may not, F6) whose identity binding matches the owner's home tenant and object +/// id — the same corroboration GlobalAdminService performs for a live caller. Reads the +/// repositories, not the per-process role caches, so a revocation takes effect at the next send. +/// +public class PushEligibility +{ + private readonly IAdminRepository _adminRepo; + private readonly IPushDeviceRepository _pushRepo; + private readonly TenantConfigurationService _tenantConfig; + private readonly AdminConfigurationService _adminConfig; + private readonly PushSettings _settings; + + public PushEligibility( + IAdminRepository adminRepo, + IPushDeviceRepository pushRepo, + TenantConfigurationService tenantConfig, + AdminConfigurationService adminConfig, + PushSettings settings) + { + _adminRepo = adminRepo; + _pushRepo = pushRepo; + _tenantConfig = tenantConfig; + _adminConfig = adminConfig; + _settings = settings; + } + + /// + /// The channel is the one switch (K4): nothing is paired, watched or sent in a scope that has + /// no enabled Push channel. Platform scope reads the ops channels, tenant scope the tenant's. + /// + public virtual async Task HasPushChannelAsync(NotificationScope scope) + { + if (!scope.IsSet) + return false; + + if (scope.IsPlatform) + { + var admin = await _adminConfig.GetConfigurationAsync(); + return admin.GetOpsNotificationChannels().Any(IsEnabledPush); + } + + var (config, exists) = await _tenantConfig.TryGetConfigurationAsync(scope.Key); + return exists && config.GetNotificationChannels().Any(IsEnabledPush); + } + + private static bool IsEnabledPush(NotificationChannel c) + => c.Enabled && c.ProviderType == (int)WebhookProviderType.Push; + + /// Role predicate shared by the pairing routes and the send-time recipient filter. + public virtual async Task IsEligibleAsync(NotificationScope scope, string upn, string objectId, string homeTenantId) + { + if (!scope.IsSet || string.IsNullOrWhiteSpace(upn) || Constants.PrincipalKeys.IsApplication(upn)) + return false; + + upn = upn.ToLowerInvariant(); + + if (scope.IsPlatform) + { + if (await _adminRepo.GetGlobalRoleAsync(upn) != Constants.GlobalRoles.GlobalAdmin) + return false; + + // A GlobalAdmins row without a binding is inert (D-279 posture); a device whose owner + // identity no longer matches the binding is somebody else's device by now. + var binding = await _adminRepo.GetIdentityBindingAsync(upn); + return binding != null + && string.Equals(binding.TenantId, homeTenantId, StringComparison.OrdinalIgnoreCase) + && (string.IsNullOrEmpty(binding.ObjectId) + || string.Equals(binding.ObjectId, objectId, StringComparison.OrdinalIgnoreCase)); + } + + var member = await _adminRepo.GetTenantMemberAsync(scope.Key, upn); + if (member == null || !member.IsEnabled) + return false; + + var role = string.IsNullOrEmpty(member.Role) ? Constants.TenantRoles.Admin : member.Role; + return role == Constants.TenantRoles.Admin || role == Constants.TenantRoles.Operator; + } + + /// + /// Freshness (K3): the owner signed in to the portal within the inactivity window. The stamp + /// is written at pairing and on every portal sign-in, so an absent row means "never" — not + /// fresh. A device of an owner who stopped signing in pauses; roles are app-internal and a + /// deactivated Entra account would otherwise keep receiving alerts. + /// + public virtual async Task IsOwnerFreshAsync(string homeTenantId, string objectId, DateTime nowUtc) + { + if (string.IsNullOrEmpty(homeTenantId) || string.IsNullOrEmpty(objectId)) + return false; + + var owner = await _pushRepo.GetOwnerAsync(homeTenantId, objectId); + return owner != null && owner.LastSignInUtc >= nowUtc.AddDays(-_settings.OwnerInactivityDays); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushHttpClientRegistration.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushHttpClientRegistration.cs new file mode 100644 index 000000000..13d623214 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushHttpClientRegistration.cs @@ -0,0 +1,35 @@ +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Push; +using Microsoft.Extensions.DependencyInjection; + +namespace AutopilotMonitor.Functions.Services.Push; + +public static class PushHttpClientRegistration +{ + public const string ClientName = "push"; + + /// + /// The push sender's HttpClient: same connect-time SSRF gate as the webhook client (D-286 — + /// the anonymous pair route stores a caller-chosen URL, so the gate must sit on the + /// connection), no redirects, short timeouts, and deliberately WITHOUT the Notification + /// retry policy — push services send Retry-After and throttle senders that re-POST in 2 s; + /// the library classifies once and retries 5xx exactly once (K15). The sender is a singleton + /// (it caches VAPID tokens per push-service origin), so the pooled connection lifetime keeps + /// DNS changes from going unnoticed. + /// + public static IServiceCollection AddPushSender(this IServiceCollection services) + { + services.AddHttpClient(ClientName) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + AllowAutoRedirect = false, + ConnectTimeout = TimeSpan.FromSeconds(5), + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + ConnectCallback = SsrfGuard.ConnectToPublicAddress, + }) + .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(10)); + + services.AddSingleton(sp => new WebPushSender(sp.GetRequiredService().CreateClient(ClientName))); + return services; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushMaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushMaintenanceService.cs new file mode 100644 index 000000000..473b90f6a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushMaintenanceService.cs @@ -0,0 +1,160 @@ +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services.Push; + +/// +/// The push channel's retention, folded into the 2-hourly maintenance run (K27: no timer of its +/// own). Expired grants and their never-confirmed Pending devices go; Stale devices (endpoint +/// gone) go after ; Active devices whose +/// owner stopped signing in — or has no owner row at all — pause after +/// through the delivery service's one pause path +/// (a last "sign in to resume" push) and resume by themselves once the owner is back; Paused +/// devices are deleted after the owner's +/// last sign-in (without an owner row: after the pause itself); expired session watches go. +/// Nothing here deletes a device a sign-in could still resume. Every step is fail-soft and +/// reports counts; nothing here is a hot path. +/// +public class PushMaintenanceService +{ + private readonly IPushDeviceRepository _repo; + private readonly PushSettings _settings; + private readonly PushDeliveryService _delivery; + private readonly ILogger _logger; + + public PushMaintenanceService( + IPushDeviceRepository repo, + PushSettings settings, + PushDeliveryService delivery, + ILogger logger) + { + _repo = repo; + _settings = settings; + _delivery = delivery; + _logger = logger; + } + + public async Task RunAsync(DateTime? nowUtc = null) + { + var now = nowUtc ?? DateTime.UtcNow; + var result = new PushMaintenanceResult(); + + try + { + foreach (var grant in await _repo.GetExpiredGrantsAsync(now).ConfigureAwait(false)) + { + if (grant.DeviceId != null && grant.Status == Constants.Push.PairingStatus.Redeemed) + { + var pending = await _repo.GetDeviceAsync(grant.TenantId, grant.DeviceId).ConfigureAwait(false); + if (pending != null && pending.Status == Constants.Push.DeviceStatus.Pending) + { + await _repo.DeleteDeviceAsync(pending.Scope, pending.DeviceId).ConfigureAwait(false); + result.PendingDevicesDeleted++; + } + } + await _repo.DeleteGrantAsync(grant.CodeHash).ConfigureAwait(false); + result.GrantsDeleted++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Push maintenance: grant sweep failed"); + } + + try + { + var lastSignIns = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var device in await _repo.GetAllDevicesAsync().ConfigureAwait(false)) + { + switch (device.Status) + { + case Constants.Push.DeviceStatus.Pending when device.PairedUtc < now.AddMinutes(-(Constants.Push.PairingGrantMinutes + 5)): + await _repo.DeleteDeviceAsync(device.Scope, device.DeviceId).ConfigureAwait(false); + result.PendingDevicesDeleted++; + break; + + case Constants.Push.DeviceStatus.Stale when (device.StatusChangedUtc ?? device.PairedUtc) < now.AddDays(-_settings.StaleDeviceRetentionDays): + await _repo.DeleteDeviceAsync(device.Scope, device.DeviceId).ConfigureAwait(false); + result.StaleDevicesDeleted++; + break; + + case Constants.Push.DeviceStatus.Active: + { + var lastSignIn = await LastSignInAsync(device, lastSignIns).ConfigureAwait(false); + if (lastSignIn == null || lastSignIn < now.AddDays(-_settings.OwnerInactivityDays)) + { + await _delivery.PauseAsync(device, "owner_inactive", NotificationScope.FromKey(device.Scope), now).ConfigureAwait(false); + result.DevicesPaused++; + } + break; + } + + case Constants.Push.DeviceStatus.Paused: + { + var lastSignIn = await LastSignInAsync(device, lastSignIns).ConfigureAwait(false); + // Without an owner row the pause is the anchor: a sign-in since would have written the row. + var anchor = lastSignIn ?? device.StatusChangedUtc ?? device.PairedUtc; + if (anchor < now.AddDays(-_settings.PausedDeviceRetentionDays)) + { + await _repo.DeleteDeviceAsync(device.Scope, device.DeviceId).ConfigureAwait(false); + result.InactiveDevicesDeleted++; + } + else if (device.StatusReason == "owner_inactive" && lastSignIn >= now.AddDays(-_settings.OwnerInactivityDays)) + { + await _delivery.ResumeAsync(device, now).ConfigureAwait(false); + result.DevicesResumed++; + } + break; + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Push maintenance: device sweep failed"); + } + + try + { + foreach (var watch in await _repo.GetExpiredWatchesAsync(now).ConfigureAwait(false)) + { + await _repo.DeleteWatchAsync(watch.TenantId, watch.SessionId, watch.OwnerObjectId).ConfigureAwait(false); + result.WatchesDeleted++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Push maintenance: watch sweep failed"); + } + + if (result.Any) + _logger.LogWarning("Push maintenance: {Result}", result); + return result; + } + + private async Task LastSignInAsync(PushDevice device, Dictionary cache) + { + var key = $"{device.OwnerHomeTenantId}|{device.OwnerObjectId}"; + if (cache.TryGetValue(key, out var cached)) + return cached; + var owner = await _repo.GetOwnerAsync(device.OwnerHomeTenantId, device.OwnerObjectId).ConfigureAwait(false); + cache[key] = owner?.LastSignInUtc; + return owner?.LastSignInUtc; + } +} + +public sealed class PushMaintenanceResult +{ + public int GrantsDeleted { get; set; } + public int PendingDevicesDeleted { get; set; } + public int StaleDevicesDeleted { get; set; } + public int InactiveDevicesDeleted { get; set; } + public int DevicesPaused { get; set; } + public int DevicesResumed { get; set; } + public int WatchesDeleted { get; set; } + public bool Any => GrantsDeleted + PendingDevicesDeleted + StaleDevicesDeleted + InactiveDevicesDeleted + DevicesPaused + DevicesResumed + WatchesDeleted > 0; + public override string ToString() + => $"grants={GrantsDeleted} pending={PendingDevicesDeleted} stale={StaleDevicesDeleted} inactive={InactiveDevicesDeleted} paused={DevicesPaused} resumed={DevicesResumed} watches={WatchesDeleted}"; +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs new file mode 100644 index 000000000..bde27a321 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushPairingService.cs @@ -0,0 +1,627 @@ +using System.Security.Cryptography; +using System.Text; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Push; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services.Push; + +/// +/// Who is pairing or managing devices: the authenticated caller as the middleware resolved it. +/// = sees and manages every device of the scope; +/// = the caller's own devices may live in the platform partition (session watches fan out there). +/// +public sealed record PushCaller(string Upn, string ObjectId, string HomeTenantId, bool IsScopeAdmin, bool IsGlobalAdmin = false); + +/// Outcome of a pairing/device operation; the function maps it to a status + error code. +public enum PushOpError +{ + None, + ChannelRequired, + NotEligible, + NotFound, + CodeInvalid, + CodeUsed, + InvalidSubscription, + NotConfigured, + Conflict, +} + +public sealed record PushOpResult(T? Value, PushOpError Error, string? Message = null) +{ + public bool Ok => Error == PushOpError.None; + public static PushOpResult Success(T value) => new(value, PushOpError.None); + public static PushOpResult Fail(PushOpError error, string? message = null) => new(default, error, message); +} + +/// +/// Pairing (Korrekturen K7–K13) and device management for the Web Push channel. The PC side +/// creates a ten-minute one-shot code from an authenticated session; the receiver redeems it +/// anonymously (code-gated) and ends up Pending; the PC confirms. Codes are stored hashed, the +/// device secret is stored hashed, and no method here ever logs or returns an endpoint. +/// +public class PushPairingService +{ + public const int PollSeconds = 3; + private const int DeviceSecretBytes = 32; + + private readonly IPushDeviceRepository _repo; + private readonly PushEligibility _eligibility; + private readonly PushSettings _settings; + private readonly PushDeliveryService _delivery; + private readonly OpsEventService _opsEvents; + private readonly IMaintenanceRepository _maintenanceRepo; + private readonly TenantConfigurationService _tenantConfig; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly TelemetryClient? _telemetry; + + public PushPairingService( + IPushDeviceRepository repo, + PushEligibility eligibility, + PushSettings settings, + PushDeliveryService delivery, + OpsEventService opsEvents, + IMaintenanceRepository maintenanceRepo, + TenantConfigurationService tenantConfig, + IMemoryCache cache, + ILogger logger, + TelemetryClient? telemetry = null) + { + _repo = repo; + _eligibility = eligibility; + _settings = settings; + _delivery = delivery; + _opsEvents = opsEvents; + _maintenanceRepo = maintenanceRepo; + _tenantConfig = tenantConfig; + _cache = cache; + _logger = logger; + _telemetry = telemetry; + } + + // ── PC side: create / status / confirm / reject ────────────────────────── + + public async Task> CreateAsync(NotificationScope scope, PushCaller caller) + { + if (!_settings.IsConfigured) + return PushOpResult.Fail(PushOpError.NotConfigured, "The push channel is not configured on the platform."); + if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false)) + return PushOpResult.Fail(PushOpError.ChannelRequired, "Add and enable a Push notification channel first."); + if (!await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false)) + return PushOpResult.Fail(PushOpError.NotEligible, "Pairing needs an Admin or Operator role held in the member list of this tenant."); + + var now = DateTime.UtcNow; + var code = GenerateCode(); + var grant = new PushPairingGrant + { + CodeHash = HashCode(code), + TenantId = scope.Key, + OwnerUpn = caller.Upn.ToLowerInvariant(), + OwnerObjectId = caller.ObjectId.ToLowerInvariant(), + OwnerHomeTenantId = caller.HomeTenantId.ToLowerInvariant(), + Status = Constants.Push.PairingStatus.Pending, + CreatedUtc = now, + ExpiresUtc = now.AddMinutes(Constants.Push.PairingGrantMinutes), + }; + await _repo.AddGrantAsync(grant).ConfigureAwait(false); + + // Pairing is a portal action from a signed-in session: it counts as a sign-in for freshness. + await _repo.StampOwnerSignInAsync(grant.OwnerHomeTenantId, grant.OwnerObjectId, grant.OwnerUpn, now).ConfigureAwait(false); + Track("created", scope); + + return PushOpResult.Success(new CreatePairingResponse + { + PairingId = grant.CodeHash, + Code = code, + Url = Constants.PortalPushPairUrl(code), + ExpiresUtc = grant.ExpiresUtc, + }); + } + + public async Task> GetStatusAsync(NotificationScope scope, string pairingId, PushCaller caller) + { + var grant = await GetOwnedGrantAsync(scope, pairingId, caller).ConfigureAwait(false); + if (grant == null) + return PushOpResult.Fail(PushOpError.NotFound); + + var now = DateTime.UtcNow; + var status = grant.Status; + if ((status == Constants.Push.PairingStatus.Pending || status == Constants.Push.PairingStatus.Redeemed) && grant.ExpiresUtc < now) + status = Constants.Push.PairingStatus.Expired; + + PairingDeviceDto? dto = null; + if (grant.DeviceId != null && status != Constants.Push.PairingStatus.Expired) + { + var device = await _repo.GetDeviceAsync(scope.Key, grant.DeviceId).ConfigureAwait(false); + if (device != null) + dto = new PairingDeviceDto { DeviceId = device.DeviceId, Label = device.Label, Platform = device.Platform, RedeemedUtc = grant.RedeemedUtc ?? device.PairedUtc }; + } + + return PushOpResult.Success(new PairingStatusResponse { Status = status, Device = dto }); + } + + public async Task> ConfirmAsync(NotificationScope scope, string pairingId, PushCaller caller) + { + var grant = await GetOwnedGrantAsync(scope, pairingId, caller).ConfigureAwait(false); + if (grant == null || grant.DeviceId == null) + return PushOpResult.Fail(PushOpError.NotFound); + + var now = DateTime.UtcNow; + if (grant.Status != Constants.Push.PairingStatus.Redeemed || grant.ExpiresUtc < now) + return PushOpResult.Fail(PushOpError.Conflict, "This pairing is not waiting for confirmation (expired, rejected or already confirmed)."); + + var device = await _repo.GetDeviceAsync(scope.Key, grant.DeviceId).ConfigureAwait(false); + if (device == null || device.Status != Constants.Push.DeviceStatus.Pending) + return PushOpResult.Fail(PushOpError.NotFound); + + grant.Status = Constants.Push.PairingStatus.Confirmed; + if (!await _repo.TryUpdateGrantAsync(grant).ConfigureAwait(false)) + return PushOpResult.Fail(PushOpError.Conflict, "The pairing changed concurrently — reload and try again."); + + await _repo.MutateDeviceAsync(scope.Key, device.DeviceId, _ => new Dictionary + { + ["Status"] = Constants.Push.DeviceStatus.Active, + ["StatusReason"] = null, + ["ConfirmedUtc"] = now, + ["StatusChangedUtc"] = now, + }).ConfigureAwait(false); + device.Status = Constants.Push.DeviceStatus.Active; + + var paired = PushAlertProjector.SystemMessage("push_paired", "Device paired", + $"This device now receives Autopilot Monitor alerts for {await ScopeNameAsync(scope).ConfigureAwait(false)}.", + "success", scope, now); + await _delivery.SendSystemAsync(device, paired).ConfigureAwait(false); + + await AuditAsync(scope, "CREATE", device, caller).ConfigureAwait(false); + Track("confirmed", scope, device.Platform); + return PushOpResult.Success(new ConfirmPairingResponse { DeviceId = device.DeviceId }); + } + + public async Task RejectAsync(NotificationScope scope, string pairingId, PushCaller caller) + { + var grant = await GetOwnedGrantAsync(scope, pairingId, caller).ConfigureAwait(false); + if (grant == null) + return PushOpError.NotFound; + + if (grant.Status == Constants.Push.PairingStatus.Pending || grant.Status == Constants.Push.PairingStatus.Redeemed) + { + grant.Status = Constants.Push.PairingStatus.Rejected; + await _repo.TryUpdateGrantAsync(grant).ConfigureAwait(false); + } + if (grant.DeviceId != null) + { + var device = await _repo.GetDeviceAsync(scope.Key, grant.DeviceId).ConfigureAwait(false); + if (device != null && device.Status == Constants.Push.DeviceStatus.Pending) + await _repo.DeleteDeviceAsync(scope.Key, device.DeviceId).ConfigureAwait(false); + } + Track("rejected", scope); + return PushOpError.None; + } + + // ── Receiver side: begin / redeem / device token routes ───────────────── + + public async Task> BeginAsync(string? rawCode) + { + if (!_settings.IsConfigured) + return PushOpResult.Fail(PushOpError.NotConfigured); + + var grant = await LookupGrantForRedeemAsync(rawCode).ConfigureAwait(false); + if (grant == null) + return PushOpResult.Fail(PushOpError.CodeInvalid); + + var key = _settings.Keys!.Active; + return PushOpResult.Success(new BeginPairResponse { Kid = key.Kid, VapidPublicKey = key.PublicKeyBase64Url }); + } + + public async Task> RedeemAsync(RedeemPairRequest request) + { + if (!_settings.IsConfigured) + return PushOpResult.Fail(PushOpError.NotConfigured); + + var grant = await LookupGrantForRedeemAsync(request.Code).ConfigureAwait(false); + if (grant == null) + return PushOpResult.Fail(PushOpError.CodeInvalid); + + var key = _settings.Keys!.Active; + if (!string.Equals(request.Kid, key.Kid, StringComparison.Ordinal)) + return PushOpResult.Fail(PushOpError.InvalidSubscription, "The server key changed — start the pairing again."); + + var scope = grant.TenantId == Constants.Push.PlatformScope ? NotificationScope.Platform : NotificationScope.Tenant(grant.TenantId); + var subscriptionError = await ValidateSubscriptionAsync(request.Endpoint, request.P256dh, request.Auth, scope).ConfigureAwait(false); + if (subscriptionError != null) + return PushOpResult.Fail(PushOpError.InvalidSubscription, subscriptionError); + + var now = DateTime.UtcNow; + var secret = RandomNumberGenerator.GetBytes(DeviceSecretBytes); + var secretText = System.Buffers.Text.Base64Url.EncodeToString(secret); + var device = new PushDevice + { + Scope = scope.Key, + DeviceId = System.Buffers.Text.Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(12)), + OwnerUpn = grant.OwnerUpn, + OwnerObjectId = grant.OwnerObjectId, + OwnerHomeTenantId = grant.OwnerHomeTenantId, + Kind = "webpush", + Endpoint = request.Endpoint.Trim(), + P256dh = request.P256dh.Trim(), + Auth = request.Auth.Trim(), + DeviceSecretHash = HashSecret(secret), + VapidKid = key.Kid, + Label = CleanLabel(request.Label, request.Platform), + Platform = NormalizePlatform(request.Platform), + AppVersion = PushAlertProjector.Clean(request.AppVersion, Constants.Push.MaxAppVersionLength), + Status = Constants.Push.DeviceStatus.Pending, + PairedUtc = now, + }; + + // Device first, grant second (one-shot CAS): an orphaned Pending device is harmless and + // swept, a Redeemed grant without a device would strand the owner's confirm. + await _repo.AddDeviceAsync(device).ConfigureAwait(false); + grant.Status = Constants.Push.PairingStatus.Redeemed; + grant.RedeemedUtc = now; + grant.DeviceId = device.DeviceId; + if (!await _repo.TryUpdateGrantAsync(grant).ConfigureAwait(false)) + { + await _repo.DeleteDeviceAsync(device.Scope, device.DeviceId).ConfigureAwait(false); + return PushOpResult.Fail(PushOpError.CodeUsed); + } + + Track("redeemed", scope, device.Platform); + return PushOpResult.Success(new RedeemPairResponse + { + DeviceToken = $"{device.Scope}.{device.DeviceId}.{secretText}", + DeviceId = device.DeviceId, + Status = Constants.Push.DeviceStatus.Pending, + PollSeconds = PollSeconds, + }); + } + + /// The device a token names, or null — the token's secret must hash to the row's hash (constant time). + public async Task ResolveDeviceAsync(string? token) + { + if (string.IsNullOrWhiteSpace(token) || token.Length > 256) + return null; + var parts = token.Split('.', 3); + if (parts.Length != 3 || parts.Any(string.IsNullOrEmpty)) + return null; + if (parts[0] != Constants.Push.PlatformScope && !Guid.TryParse(parts[0], out _)) + return null; + + var device = await _repo.GetDeviceAsync(parts[0].ToLowerInvariant(), parts[1]).ConfigureAwait(false); + if (device == null) + return null; + + byte[] secret; + try + { + secret = System.Buffers.Text.Base64Url.DecodeFromChars(parts[2]); + } + catch (FormatException) + { + return null; + } + + var expected = Encoding.ASCII.GetBytes(device.DeviceSecretHash); + var actual = Encoding.ASCII.GetBytes(HashSecret(secret)); + return CryptographicOperations.FixedTimeEquals(expected, actual) ? device : null; + } + + public async Task GetDeviceStatusAsync(PushDevice device) + { + // A GET that stamps: "opened at" is the health signal the device list shows. + try + { + await _repo.MutateDeviceAsync(device.Scope, device.DeviceId, _ => new Dictionary { ["LastOpenedUtc"] = DateTime.UtcNow }).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "LastOpenedUtc stamp failed for push device {DeviceId}", device.DeviceId); + } + + var scope = device.Scope == Constants.Push.PlatformScope ? NotificationScope.Platform : NotificationScope.Tenant(device.Scope); + return new PushDeviceStatusResponse + { + DeviceId = device.DeviceId, + Status = device.Status, + Label = device.Label, + Platform = device.Platform, + Kid = device.VapidKid, + PairedUtc = device.PairedUtc, + ConfirmedUtc = device.ConfirmedUtc, + LastDeliveredUtc = device.LastDeliveredUtc, + OwnerUpn = device.OwnerUpn, + ScopeName = await ScopeNameAsync(scope).ConfigureAwait(false), + }; + } + + public async Task> ResubscribeAsync(PushDevice device, ResubscribeRequest request) + { + if (!_settings.IsConfigured) + return PushOpResult.Fail(PushOpError.NotConfigured); + + var key = _settings.Keys!.Resolve(request.Kid); + if (key == null) + return PushOpResult.Fail(PushOpError.InvalidSubscription, "Unknown server key — re-pair this device."); + + var scope = device.Scope == Constants.Push.PlatformScope ? NotificationScope.Platform : NotificationScope.Tenant(device.Scope); + var error = await ValidateSubscriptionAsync(request.Endpoint, request.P256dh, request.Auth, scope).ConfigureAwait(false); + if (error != null) + return PushOpResult.Fail(PushOpError.InvalidSubscription, error); + + var now = DateTime.UtcNow; + await _repo.MutateDeviceAsync(device.Scope, device.DeviceId, current => + { + var patch = new Dictionary + { + ["Endpoint"] = request.Endpoint.Trim(), + ["P256dh"] = request.P256dh.Trim(), + ["Auth"] = request.Auth.Trim(), + ["VapidKid"] = key.Kid, + ["ConsecutiveFailures"] = 0, + }; + // A Stale row (endpoint gone, key mismatch) and a row paused for transport failures are + // re-armed by a fresh subscription; Pending and an owner-inactivity pause keep their state + // (the owner's sign-in lifts that one). + if (current.Status == Constants.Push.DeviceStatus.Stale + || (current.Status == Constants.Push.DeviceStatus.Paused && current.StatusReason == "delivery_failures")) + { + patch["Status"] = Constants.Push.DeviceStatus.Active; + patch["StatusReason"] = null; + patch["StatusChangedUtc"] = now; + } + return patch; + }).ConfigureAwait(false); + + var refreshed = await _repo.GetDeviceAsync(device.Scope, device.DeviceId).ConfigureAwait(false) ?? device; + return PushOpResult.Success(await GetDeviceStatusAsync(refreshed).ConfigureAwait(false)); + } + + public async Task UnpairAsync(PushDevice device) + { + await _repo.DeleteDeviceAsync(device.Scope, device.DeviceId).ConfigureAwait(false); + Track("revoked", device.Scope == Constants.Push.PlatformScope ? NotificationScope.Platform : NotificationScope.Tenant(device.Scope), device.Platform, "unpaired"); + } + + // ── Device management (PC side) ───────────────────────────────────────── + + public async Task ListAsync(NotificationScope scope, PushCaller caller) + { + var devices = await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false); + var items = devices + .Where(d => d.Status != Constants.Push.DeviceStatus.Pending) + .Where(d => caller.IsScopeAdmin || IsOwn(d, caller)) + .OrderByDescending(d => d.PairedUtc) + .Select(d => new PushDeviceDto + { + DeviceId = d.DeviceId, + Label = d.Label, + Platform = d.Platform, + Status = d.Status, + Kid = d.VapidKid, + PairedUtc = d.PairedUtc, + ConfirmedUtc = d.ConfirmedUtc, + LastDeliveredUtc = d.LastDeliveredUtc, + LastOpenedUtc = d.LastOpenedUtc, + OwnerUpn = d.OwnerUpn, + IsOwn = IsOwn(d, caller), + }) + .ToList(); + return new PushDeviceListResponse { Devices = items }; + } + + public async Task DeleteAsync(NotificationScope scope, string deviceId, PushCaller caller) + { + var device = await _repo.GetDeviceAsync(scope.Key, deviceId).ConfigureAwait(false); + if (device == null || !(caller.IsScopeAdmin || IsOwn(device, caller))) + return PushOpError.NotFound; + + await _delivery.RevokeDeviceAsync(device, IsOwn(device, caller) ? "unpaired" : "removed_by_admin").ConfigureAwait(false); + await AuditAsync(scope, "DELETE", device, caller).ConfigureAwait(false); + return PushOpError.None; + } + + public async Task> TestAsync(NotificationScope scope, string deviceId, PushCaller caller) + { + var device = await _repo.GetDeviceAsync(scope.Key, deviceId).ConfigureAwait(false); + if (device == null || !(caller.IsScopeAdmin || IsOwn(device, caller))) + return PushOpResult.Fail(PushOpError.NotFound); + if (device.Status != Constants.Push.DeviceStatus.Active) + return PushOpResult.Success(new TestWebhookNotificationResponse { Success = false, Message = $"The device is {device.Status.ToLowerInvariant()} — only active devices receive pushes." }); + + var payload = PushAlertProjector.SystemMessage("push_test", "Test notification", + "This is a test notification from Autopilot Monitor.", "info", scope, DateTime.UtcNow, Constants.PortalBaseUrl); + var outcome = await _delivery.SendSystemAsync(device, payload).ConfigureAwait(false); + return PushOpResult.Success(new TestWebhookNotificationResponse + { + Success = outcome == PushOutcome.Delivered, + Message = outcome switch + { + PushOutcome.Delivered => "Test notification sent.", + PushOutcome.Gone => "The push service no longer knows this device — open the receiver app on it to re-subscribe.", + PushOutcome.RetryLater => "The push service is throttling — try again in a minute.", + PushOutcome.ConfigError => "The push service refused the platform's key — check the VAPID configuration.", + _ => "The push service did not accept the message.", + }, + }); + } + + public async Task ScopeNameAsync(NotificationScope scope) + { + if (scope.IsPlatform) + return "Platform operator"; + var (config, exists) = await _tenantConfig.TryGetConfigurationAsync(scope.Key).ConfigureAwait(false); + if (!exists) + return scope.Key; + if (!string.IsNullOrWhiteSpace(config.CompanyName)) return config.CompanyName; + if (!string.IsNullOrWhiteSpace(config.DomainName)) return config.DomainName; + return scope.Key; + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private static bool IsOwn(PushDevice device, PushCaller caller) + => string.Equals(device.OwnerObjectId, caller.ObjectId, StringComparison.OrdinalIgnoreCase) + && string.Equals(device.OwnerHomeTenantId, caller.HomeTenantId, StringComparison.OrdinalIgnoreCase); + + private async Task GetOwnedGrantAsync(NotificationScope scope, string pairingId, PushCaller caller) + { + if (string.IsNullOrWhiteSpace(pairingId) || pairingId.Length > 64) + return null; + // Same ownership rule as IsOwn for devices: the person (oid) in their home tenant, within the scope. + var grant = await _repo.GetGrantAsync(pairingId).ConfigureAwait(false); + if (grant == null || grant.TenantId != scope.Key + || !string.Equals(grant.OwnerObjectId, caller.ObjectId, StringComparison.OrdinalIgnoreCase) + || !string.Equals(grant.OwnerHomeTenantId, caller.HomeTenantId, StringComparison.OrdinalIgnoreCase)) + return null; + return grant; + } + + /// + /// Unknown, expired, used and burnt codes all come back as null — one answer for the anonymous + /// caller (K9). A wrong-state hit counts as a failed attempt on the row; five burn the code. + /// + private async Task LookupGrantForRedeemAsync(string? rawCode) + { + var code = NormalizeCode(rawCode); + if (code == null) + return null; + + var grant = await _repo.GetGrantAsync(HashCode(code)).ConfigureAwait(false); + if (grant == null) + return null; + + var usable = grant.Status == Constants.Push.PairingStatus.Pending && grant.ExpiresUtc >= DateTime.UtcNow; + if (usable) + return grant; + + grant.FailedAttempts++; + if (grant.FailedAttempts >= Constants.Push.MaxRedeemFailures) + await _repo.DeleteGrantAsync(grant.CodeHash).ConfigureAwait(false); + else + await _repo.TryUpdateGrantAsync(grant).ConfigureAwait(false); + return null; + } + + private async Task ValidateSubscriptionAsync(string? endpoint, string? p256dh, string? auth, NotificationScope scope) + { + if (!Uri.TryCreate(endpoint?.Trim(), UriKind.Absolute, out var uri)) + return "The subscription endpoint is not a valid URL."; + + var policyError = PushEndpointPolicy.Validate(uri); + if (policyError != null) + { + // Hostname only — the endpoint is a credential; a new browser push service shows up here. + // Once per host, scope and hour per instance: the caller chooses the host, and every ops + // event fans out to the operator channels (D-185). + var dedupeKey = $"push-endpoint-refused:{scope.Key}:{uri.Host.ToLowerInvariant()}"; + if (!_cache.TryGetValue(dedupeKey, out _)) + { + _cache.Set(dedupeKey, true, TimeSpan.FromHours(1)); + try + { + await _opsEvents.RecordPushEndpointRefusedAsync(uri.Host, scope.IsPlatform ? "platform" : "tenant").ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Recording PushEndpointRefused ops event failed"); + } + } + return policyError; + } + + return WebPushSubscription.TryParse(uri.ToString(), p256dh ?? string.Empty, auth ?? string.Empty, out _, out var parseError) + ? null + : parseError ?? "The subscription keys have the wrong shape."; + } + + private Task AuditAsync(NotificationScope scope, string action, PushDevice device, PushCaller caller) + { + if (scope.IsPlatform) + return Task.CompletedTask; + return _maintenanceRepo.LogAuditEntryAsync(scope.Key, action, "PushDevice", device.DeviceId, caller.Upn, + new Dictionary { ["platform"] = device.Platform, ["label"] = device.Label, ["owner"] = device.OwnerUpn }); + } + + private void Track(string action, NotificationScope scope, string? platform = null, string? reason = null) + { + try + { + _telemetry?.TrackEvent("PushPairing", new Dictionary + { + ["action"] = action, + ["scope"] = scope.IsPlatform ? "platform" : "tenant", + ["platform"] = platform ?? string.Empty, + ["reason"] = reason ?? string.Empty, + }); + } + catch + { + // Telemetry never fails a pairing. + } + } + + // ── Codes ─────────────────────────────────────────────────────────────── + + /// 11 characters drawn uniformly from the Crockford alphabet (55 bits). + public static string GenerateCode() + => RandomNumberGenerator.GetString(Constants.Push.CodeAlphabet, Constants.Push.CodeLength); + + /// + /// What a typed code becomes before hashing: upper-case, spaces and hyphens dropped, the + /// Crockford look-alikes folded (O→0, I/L→1). Null when the result is not exactly a code. + /// + public static string? NormalizeCode(string? raw) + { + if (string.IsNullOrWhiteSpace(raw) || raw.Length > 64) + return null; + + var sb = new StringBuilder(Constants.Push.CodeLength); + foreach (var ch in raw) + { + if (ch == ' ' || ch == '-') + continue; + var c = char.ToUpperInvariant(ch); + c = c switch { 'O' => '0', 'I' => '1', 'L' => '1', _ => c }; + if (Constants.Push.CodeAlphabet.IndexOf(c) < 0) + return null; + sb.Append(c); + if (sb.Length > Constants.Push.CodeLength) + return null; + } + return sb.Length == Constants.Push.CodeLength ? sb.ToString() : null; + } + + public static string HashCode(string code) + => System.Buffers.Text.Base64Url.EncodeToString(SHA256.HashData(Encoding.ASCII.GetBytes(code))); + + private static string HashSecret(byte[] secret) + => System.Buffers.Text.Base64Url.EncodeToString(SHA256.HashData(secret)); + + private static string NormalizePlatform(string? platform) + { + var p = (platform ?? string.Empty).Trim().ToLowerInvariant(); + return Constants.Push.Platforms.Contains(p) ? p : "other"; + } + + private static string CleanLabel(string? label, string? platform) + { + var clean = PushAlertProjector.Clean(label, Constants.Push.MaxLabelLength); + if (clean.Length > 0) + return clean; + return NormalizePlatform(platform) switch + { + "ios-homescreen" => "iPhone", + "android-chrome" => "Android phone", + "windows-chromium" => "Windows browser", + "macos-safari" => "Mac", + "firefox" => "Firefox", + _ => "Device", + }; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs new file mode 100644 index 000000000..da65430c3 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSessionWatchService.cs @@ -0,0 +1,165 @@ +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services.Push; + +/// Terminal-status hook for the ingest path: fires the pushes of everyone watching the session. +public interface IPushSessionWatchNotifier +{ + Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber); +} + +/// +/// "Notify my devices when this session ends" (plan use case 3): one watch per (session, person), +/// consumed at the terminal status. The watch row lives in the SESSION's tenant (tenant isolation +/// of the session), the devices it fires on are the watcher's OWN: their home-tenant scope and, +/// for a Global Admin, the platform scope — so an operator can watch a customer's session from +/// their own paired phone. Admin and Operator only (F14), only in a scope with an enabled Push +/// channel (K4), and the fan-out runs through the same role and freshness filter as any alert. +/// Watches expire after 48 hours (the agent's emergency break) and die with the session's tenant. +/// +public class PushSessionWatchService : IPushSessionWatchNotifier +{ + public static readonly TimeSpan WatchLifetime = TimeSpan.FromHours(48); + /// The terminal push waits for an offline phone longer than a system message: plan "Watch bis Session-Ende + 1 h". + public static readonly TimeSpan WatchPushTtl = TimeSpan.FromHours(1); + private const char ScopeSeparator = ';'; + + private readonly IPushDeviceRepository _repo; + private readonly PushEligibility _eligibility; + private readonly PushDeliveryService _delivery; + private readonly ILogger _logger; + + public PushSessionWatchService( + IPushDeviceRepository repo, + PushEligibility eligibility, + PushDeliveryService delivery, + ILogger logger) + { + _repo = repo; + _eligibility = eligibility; + _delivery = delivery; + _logger = logger; + } + + /// The scopes a caller's own devices can live in: the platform partition for a Global Admin, the home tenant for everyone. + internal static IReadOnlyList OwnScopes(PushCaller caller) + { + var scopes = new List(2); + if (caller.IsGlobalAdmin) + scopes.Add(NotificationScope.Platform); + if (!string.IsNullOrWhiteSpace(caller.HomeTenantId)) + scopes.Add(NotificationScope.Tenant(caller.HomeTenantId)); + return scopes; + } + + public async Task> WatchAsync(string tenantId, string sessionId, PushCaller caller) + { + var anyChannel = false; + var anyEligible = false; + var usable = new List(); + + foreach (var scope in OwnScopes(caller)) + { + if (!await _eligibility.HasPushChannelAsync(scope).ConfigureAwait(false)) + continue; + anyChannel = true; + if (!await _eligibility.IsEligibleAsync(scope, caller.Upn, caller.ObjectId, caller.HomeTenantId).ConfigureAwait(false)) + continue; + anyEligible = true; + var hasDevice = (await _repo.GetDevicesAsync(scope.Key).ConfigureAwait(false)) + .Any(d => d.Status == Constants.Push.DeviceStatus.Active + && string.Equals(d.OwnerObjectId, caller.ObjectId, StringComparison.OrdinalIgnoreCase)); + if (hasDevice) + usable.Add(scope.Key); + } + + if (usable.Count == 0) + { + if (!anyChannel) + return PushOpResult.Fail(PushOpError.ChannelRequired, "Enable a Push notification channel first."); + if (!anyEligible) + return PushOpResult.Fail(PushOpError.NotEligible); + return PushOpResult.Fail(PushOpError.Conflict, "Pair a device first — there is nothing to notify."); + } + + var now = DateTime.UtcNow; + var watch = new PushSessionWatch + { + TenantId = tenantId.ToLowerInvariant(), + SessionId = sessionId, + OwnerObjectId = caller.ObjectId, + OwnerUpn = caller.Upn, + OwnerScopes = string.Join(ScopeSeparator, usable), + CreatedUtc = now, + ExpiresUtc = now + WatchLifetime, + }; + await _repo.UpsertWatchAsync(watch).ConfigureAwait(false); + return PushOpResult.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc }); + } + + public async Task UnwatchAsync(string tenantId, string sessionId, PushCaller caller) + { + await _repo.DeleteWatchAsync(tenantId, sessionId, caller.ObjectId).ConfigureAwait(false); + return new SessionWatchResponse { Watching = false }; + } + + public async Task GetAsync(string tenantId, string sessionId, PushCaller caller) + { + var watch = await _repo.GetWatchAsync(tenantId, sessionId, caller.ObjectId).ConfigureAwait(false); + var active = watch != null && watch.ExpiresUtc >= DateTime.UtcNow; + return new SessionWatchResponse { Watching = active, ExpiresUtc = active ? watch!.ExpiresUtc : null }; + } + + /// Fail-soft: a watch push must never cost the ingest path anything. + public async Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber) + { + try + { + var watches = await _repo.GetWatchesForSessionAsync(tenantId, sessionId).ConfigureAwait(false); + if (watches.Count == 0) + return; + + var now = DateTime.UtcNow; + var device = PushAlertProjector.Clean(deviceName, 48); + var serial = PushAlertProjector.MaskSerial(serialNumber); + var body = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty) + + (success ? " finished enrollment." : " failed enrollment."); + + foreach (var watch in watches) + { + if (watch.ExpiresUtc < now) + { + await _repo.DeleteWatchAsync(tenantId, sessionId, watch.OwnerObjectId).ConfigureAwait(false); + continue; + } + + foreach (var scope in ScopesOf(watch, tenantId)) + { + var payload = PushAlertProjector.SystemMessage("session_watch", + success ? "Enrollment finished" : "Enrollment failed", + body, success ? "success" : "error", scope, now, Constants.PortalSessionUrl(sessionId)); + await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false); + } + + await _repo.DeleteWatchAsync(tenantId, sessionId, watch.OwnerObjectId).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Session watch notification failed for session {SessionId}", sessionId); + } + } + + private static IEnumerable ScopesOf(PushSessionWatch watch, string sessionTenantId) + { + var keys = (watch.OwnerScopes ?? string.Empty).Split(ScopeSeparator, StringSplitOptions.RemoveEmptyEntries); + if (keys.Length == 0) + keys = new[] { sessionTenantId }; + foreach (var key in keys) + yield return NotificationScope.FromKey(key); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSettings.cs b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSettings.cs new file mode 100644 index 000000000..642c207ab --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Push/PushSettings.cs @@ -0,0 +1,81 @@ +using AutopilotMonitor.Push; +using AutopilotMonitor.Shared; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services.Push; + +/// +/// The push channel's configuration, read once at startup (App Settings use Push__Key): +/// Push:Vapid:ActiveKey (the key new pairings subscribe with; generated once, backed up in +/// the operator's secrets file, never rotated without a re-pair campaign), +/// Push:Vapid:RetiredKeys (optional, ;-separated: keys devices paired earlier still +/// reference by kid), Push:Vapid:Subject (VAPID contact, mailto: or https://; +/// defaults to the product website so no personal address reaches the push services), +/// Push:OwnerInactivityDays (devices pause when their owner stopped signing in; 30), +/// Push:PausedDeviceRetentionDays (paused devices are deleted after; 90), +/// Push:StaleDeviceRetentionDays (devices whose endpoint is gone are deleted after; 14). +/// Without a valid active key the channel is off: pairing refuses, sends are logged no-ops. +/// +public sealed class PushSettings +{ + public const string ActiveKeyConfigKey = "Push:Vapid:ActiveKey"; + public const string RetiredKeysConfigKey = "Push:Vapid:RetiredKeys"; + public const string SubjectConfigKey = "Push:Vapid:Subject"; + public const string OwnerInactivityDaysConfigKey = "Push:OwnerInactivityDays"; + public const string PausedDeviceRetentionDaysConfigKey = "Push:PausedDeviceRetentionDays"; + public const string StaleDeviceRetentionDaysConfigKey = "Push:StaleDeviceRetentionDays"; + + public bool IsConfigured => Keys != null; + public VapidKeyRing? Keys { get; private init; } + public string Subject { get; private init; } = Constants.WebsiteBaseUrl; + public int OwnerInactivityDays { get; private init; } = 30; + public int PausedDeviceRetentionDays { get; private init; } = 90; + public int StaleDeviceRetentionDays { get; private init; } = 14; + /// Why the channel is off (startup warning text); null when configured. + public string? ConfigurationError { get; private init; } + + public static PushSettings Disabled(string reason) => new() { ConfigurationError = reason }; + + public static PushSettings Load(IConfiguration configuration, ILogger logger) + { + var active = configuration[ActiveKeyConfigKey]; + if (string.IsNullOrWhiteSpace(active)) + { + logger.LogWarning("{Key} is not configured — the Web Push channel is off (pairing refuses, sends are skipped)", ActiveKeyConfigKey); + return Disabled($"{ActiveKeyConfigKey} is not configured."); + } + + VapidKeyRing keys; + try + { + keys = VapidKeyRing.FromSettings(active, configuration[RetiredKeysConfigKey]); + } + catch (ArgumentException ex) + { + // The message never echoes key material (library contract). + logger.LogError("{Key} is invalid — the Web Push channel is off: {Reason}", ActiveKeyConfigKey, ex.Message); + return Disabled($"{ActiveKeyConfigKey} is invalid: {ex.Message}"); + } + + var subject = configuration[SubjectConfigKey]; + if (string.IsNullOrWhiteSpace(subject)) + subject = Constants.WebsiteBaseUrl; + + return new PushSettings + { + Keys = keys, + Subject = subject.Trim(), + OwnerInactivityDays = ReadDays(configuration, OwnerInactivityDaysConfigKey, 30), + PausedDeviceRetentionDays = ReadDays(configuration, PausedDeviceRetentionDaysConfigKey, 90), + StaleDeviceRetentionDays = ReadDays(configuration, StaleDeviceRetentionDaysConfigKey, 14), + }; + } + + /// Test seam: a ring built in code. + internal static PushSettings ForKeys(VapidKeyRing keys, string? subject = null, int ownerInactivityDays = 30) + => new() { Keys = keys, Subject = subject ?? Constants.WebsiteBaseUrl, OwnerInactivityDays = ownerInactivityDays }; + + private static int ReadDays(IConfiguration configuration, string key, int fallback) + => int.TryParse(configuration[key], out var days) && days >= 1 ? days : fallback; +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs index 5957ee59e..a102bbc75 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs @@ -547,7 +547,7 @@ private async Task SendBreachNotificationAsync(TenantConfiguration config, strin var slaChannels = GetSlaChannels(config); if (slaChannels.Count > 0) { - await _channelDispatcher.SendToChannelsAsync(slaChannels, alert); + await _channelDispatcher.SendToChannelsAsync(slaChannels, alert, Notifications.NotificationScope.Tenant(config.TenantId)); } await _tenantNotificationService.CreateNotificationAsync( @@ -590,7 +590,7 @@ private async Task SendResolvedNotificationAsync(TenantConfiguration config, str var slaChannels = GetSlaChannels(config); if (slaChannels.Count > 0) { - await _channelDispatcher.SendToChannelsAsync(slaChannels, alert); + await _channelDispatcher.SendToChannelsAsync(slaChannels, alert, Notifications.NotificationScope.Tenant(config.TenantId)); } await _tenantNotificationService.CreateNotificationAsync( @@ -678,7 +678,7 @@ public async Task EvaluateSessionCompletionAsync(string tenantId, SessionSummary var slaChannels = GetSlaChannels(config); if (slaChannels.Count > 0) { - await _channelDispatcher.SendToChannelsAsync(slaChannels, alert); + await _channelDispatcher.SendToChannelsAsync(slaChannels, alert, Notifications.NotificationScope.Tenant(config.TenantId)); } await _tenantNotificationService.CreateNotificationAsync( diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs index 099945c4b..452d7bf7f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs @@ -37,6 +37,30 @@ public TenantAdminsService( _logger = logger; } + /// + /// The container picks this (longest satisfiable) constructor; the three-argument one stays + /// for the tests that mock the service with positional arguments (Moq does not fill optional + /// parameters). + /// + public TenantAdminsService( + IAdminRepository adminRepo, + IMemoryCache cache, + ILogger logger, + Push.IPushDeviceRevoker pushRevoker) + : this(adminRepo, cache, logger) + { + _pushRevoker = pushRevoker; + } + + private readonly Push.IPushDeviceRevoker? _pushRevoker; + + /// + /// Role-loss hook (K6): a member who is removed, disabled or demoted below Operator loses the + /// paired push devices of this tenant — wipe push first, then the rows. Fail-soft. + /// + private Task RevokePushDevicesAsync(string tenantId, string upn, string reason) + => _pushRevoker?.RevokeOwnerAsync(Notifications.NotificationScope.Tenant(tenantId), upn, reason) ?? Task.CompletedTask; + /// /// Checks if a UPN is a Tenant Admin for a specific tenant /// Uses caching for performance @@ -117,6 +141,7 @@ public async Task RemoveTenantAdminAsync(string tenantId, string upn) await _adminRepo.RemoveTenantMemberAsync(tenantId, upn); InvalidateMemberCache(tenantId, upn); + await RevokePushDevicesAsync(tenantId, upn, "member_removed"); _logger.LogInformation($"Removed Tenant Admin: {upn} from tenant {tenantId}"); } @@ -132,6 +157,7 @@ public async Task DisableTenantAdminAsync(string tenantId, string upn) await _adminRepo.SetTenantMemberEnabledAsync(tenantId, upn, false); InvalidateMemberCache(tenantId, upn); + await RevokePushDevicesAsync(tenantId, upn, "member_disabled"); _logger.LogInformation($"Disabled Tenant Admin: {upn} for tenant {tenantId}"); } @@ -289,6 +315,8 @@ public async Task UpdateMemberPermissionsAsync(string tenantId, string upn if (result) { InvalidateMemberCache(tenantId, upn); + if (role != Constants.TenantRoles.Admin && role != Constants.TenantRoles.Operator) + await RevokePushDevicesAsync(tenantId, upn, "role_lost"); _logger.LogInformation("Updated member permissions: {Upn} -> role={Role}, canManageBootstrap={CanManageBootstrap} in tenant {TenantId}", upn, role, canManageBootstrapTokens, tenantId); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/WhatsNew/WhatsNewNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/WhatsNew/WhatsNewNotificationService.cs index 0a76849f8..70d3e922e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/WhatsNew/WhatsNewNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/WhatsNew/WhatsNewNotificationService.cs @@ -171,13 +171,13 @@ public async Task RunAsync(CancellationToken ct = default) if (config.IsCurrentlyDisabled()) continue; var targets = config.GetNotificationChannels() - .Where(c => c.Enabled && c.NotifyOnWhatsNew && !string.IsNullOrEmpty(c.Url)) + .Where(c => c.Enabled && c.NotifyOnWhatsNew && c.HasDestination()) .ToList(); if (targets.Count == 0) continue; try { - await _channelDispatcher.SendToChannelsAsync(targets, alert); + await _channelDispatcher.SendToChannelsAsync(targets, alert, Notifications.NotificationScope.Tenant(config.TenantId)); tenants++; channels += targets.Count; } diff --git a/src/Backend/AutopilotMonitor.Functions/Telemetry/PushDependencyRedactionProcessor.cs b/src/Backend/AutopilotMonitor.Functions/Telemetry/PushDependencyRedactionProcessor.cs new file mode 100644 index 000000000..234ad6f95 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Telemetry/PushDependencyRedactionProcessor.cs @@ -0,0 +1,54 @@ +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; + +namespace AutopilotMonitor.Functions.Telemetry; + +/// +/// A Web Push endpoint is a per-device send capability, so the HttpClient dependency rows the +/// worker emits for the push services must not carry it (K20): Data and Name are +/// cut down to scheme + host for those hosts. Target, ResultCode and Duration stay, which is all +/// /platform-health reads. Everything else passes through untouched. +/// +public sealed class PushDependencyRedactionProcessor : ITelemetryProcessor +{ + private static readonly string[] PushHostSuffixes = + { + ".push.apple.com", + "fcm.googleapis.com", + ".push.services.mozilla.com", + ".notify.windows.com", + }; + + private readonly ITelemetryProcessor _next; + + public PushDependencyRedactionProcessor(ITelemetryProcessor next) => _next = next; + + public void Process(ITelemetry item) + { + if (item is DependencyTelemetry dependency) + Redact(dependency); + _next.Process(item); + } + + internal static void Redact(DependencyTelemetry dependency) + { + if (!Uri.TryCreate(dependency.Data, UriKind.Absolute, out var uri) || !IsPushHost(uri.Host)) + return; + + var origin = $"{uri.Scheme}://{uri.Host}"; + dependency.Data = origin; + dependency.Name = $"POST {origin}"; + } + + private static bool IsPushHost(string host) + { + foreach (var suffix in PushHostSuffixes) + { + if (suffix.StartsWith('.') ? host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) + : host.Equals(suffix, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/local.settings.json.template b/src/Backend/AutopilotMonitor.Functions/local.settings.json.template index eb34fc649..124a11f96 100644 --- a/src/Backend/AutopilotMonitor.Functions/local.settings.json.template +++ b/src/Backend/AutopilotMonitor.Functions/local.settings.json.template @@ -9,6 +9,14 @@ "// DataStorage": "Connection the [QueueTrigger] functions under Functions/Queue bind to (Constants.QueueNames.TriggerConnection). Locally a connection string; in Azure the identity form 'DataStorage__queueServiceUri=https://.queue.core.windows.net'. The Functions host resolves it, so it cannot be derived from AzureStorageAccountName in code.", "DataStorage": "UseDevelopmentStorage=true", + "// Web Push channel": "Optional. Without Push__Vapid__ActiveKey the Push notification provider is off (pairing refuses, sends are skipped). ActiveKey = the 96-byte base64url form from infra/scripts/push/New-VapidKeys.ps1 (generated once; rotating it invalidates every paired device, see plan push-relay K17); RetiredKeys = ';'-separated older keys devices may still reference; Subject = VAPID contact (mailto: or https://, default: the product website); OwnerInactivityDays = devices pause when their owner has not signed in for this long (default 30); PausedDeviceRetentionDays = paused devices are deleted this long after the owner's last sign-in (default 90); StaleDeviceRetentionDays = devices whose endpoint is gone are deleted after this long (default 14).", + "Push__Vapid__ActiveKey": "", + "Push__Vapid__RetiredKeys": "", + "Push__Vapid__Subject": "", + "Push__OwnerInactivityDays": "30", + "Push__PausedDeviceRetentionDays": "90", + "Push__StaleDeviceRetentionDays": "14", + "// SignalR quota watcher": "Optional. Set to the full Azure resource id of the SignalR Service to enable hourly OpsEvents when the plan limits are approached (defaults: Standard S1 x1 unit = 1000 connections / 1M messages per day; override the limits when adding units or changing tier). Function App's managed identity needs 'Monitoring Reader' on this resource. Leave unset to skip the watcher.", "SignalRResourceId": "", "SignalRConnectionLimit": "1000", diff --git a/src/McpServer/autopilot-monitor-mcp/package-lock.json b/src/McpServer/autopilot-monitor-mcp/package-lock.json index 7f276ed5a..dd2085c62 100644 --- a/src/McpServer/autopilot-monitor-mcp/package-lock.json +++ b/src/McpServer/autopilot-monitor-mcp/package-lock.json @@ -620,9 +620,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -639,9 +636,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -658,9 +652,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -677,9 +668,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -696,9 +684,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -715,9 +700,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -734,9 +716,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -753,9 +732,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -772,9 +748,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -797,9 +770,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -822,9 +792,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -847,9 +814,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -872,9 +836,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -897,9 +858,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -922,9 +880,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -947,9 +902,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1828,13 +1780,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2092,12 +2037,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2164,12 +2103,6 @@ "node": ">= 0.4" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "license": "MIT" - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2485,17 +2418,15 @@ } }, "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", "license": "BSD-3-Clause", "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" }, "engines": { "node": ">=10.0" @@ -2660,12 +2591,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -2944,15 +2869,18 @@ } }, "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", "license": "MIT", "dependencies": { "escape-string-regexp": "^4.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/math-intrinsics": { @@ -3360,23 +3288,6 @@ "node": ">=0.10.0" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "license": "BSD-3-Clause", - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rolldown": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", @@ -3465,12 +3376,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT" - }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -3498,12 +3403,12 @@ } }, "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", "license": "MIT", "dependencies": { - "type-fest": "^0.13.1" + "type-fest": "^0.20.2" }, "engines": { "node": ">=10" @@ -3698,12 +3603,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3823,9 +3722,9 @@ } }, "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" diff --git a/src/McpServer/autopilot-monitor-mcp/package.json b/src/McpServer/autopilot-monitor-mcp/package.json index 307675ea6..5228d5198 100644 --- a/src/McpServer/autopilot-monitor-mcp/package.json +++ b/src/McpServer/autopilot-monitor-mcp/package.json @@ -42,6 +42,7 @@ }, "overrides": { "sharp": "^0.35.5", - "fast-uri": "^3.1.4" + "fast-uri": "^3.1.4", + "global-agent": "^4.1.3" } } diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 8e8c3cb71..3be8c3cc7 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -932,6 +932,18 @@ export interface BackupTriggerResponse { statusUrl: string; } +/** POST push/pair/begin — the receiver validates its code before asking for notification permission. */ +export interface BeginPairRequest { + code: string; +} + +/** The VAPID application server key the receiver must subscribe with, and its identity. */ +export interface BeginPairResponse { + kid: string; + /** Uncompressed P-256 point, base64url — pushManager.subscribe({applicationServerKey}). */ + vapidPublicKey: string; +} + /** Body of POST devices/block (Global Admin). */ export interface BlockDeviceRequest { tenantId: string; @@ -1111,6 +1123,11 @@ export interface ConfidenceFactor { weight: number; } +/** POST push/pairings/{pairingId}/confirm — the device is Active from now on. */ +export interface ConfirmPairingResponse { + deviceId: string; +} + /** One CPE mapping row (seed, custom, or community) as browsed by the Admin UI. */ export interface CpeMappingItem { normalizedVendor: string; @@ -1185,6 +1202,17 @@ export interface CreateMcpClientRegistrationResponse { registration: McpClientRegistrationItem; } +/** POST push/pairings · POST global/push/pairings — a fresh pairing code for the caller's own person. */ +export interface CreatePairingResponse { + /** Handle for the PC-side status poll and the confirm/reject calls. */ + pairingId: string; + /** The 11-character code to type into the receiver app. */ + code: string; + /** The same code as the pairing link (QR payload): …/push/pair/#p=. */ + url: string; + expiresUtc: string; +} + /** Body of POST global/tenant-groups. */ export interface CreateTenantGroupRequest { name: string; @@ -3087,6 +3115,21 @@ export interface OsUpdateSpan { rebootCount: number; } +export interface PairingDeviceDto { + deviceId: string; + label: string; + platform: string; + redeemedUtc: string; +} + +/** GET push/pairings/{pairingId} — what the receiver has done with the code so far. */ +export interface PairingStatusResponse { + /** Pending · Redeemed (a device waits for confirmation) · Confirmed · Rejected · Expired. */ + status: string; + /** The redeeming device, present from Redeemed on. */ + device?: PairingDeviceDto; +} + /** Body of PATCH global/config: { "fields": { : , ... } } with at least one field, keyed by the AdminConfiguration wire name. Only the fields sent are written; values take the field's own JSON type. */ export interface PatchAdminConfigurationRequest { fields?: Record; @@ -3273,6 +3316,43 @@ export interface ProgressTenantStatusResponse { signedUpAt?: string; } +export interface PushDeviceDto { + deviceId: string; + label: string; + platform: string; + status: string; + kid: string; + pairedUtc: string; + confirmedUtc?: string; + lastDeliveredUtc?: string; + lastOpenedUtc?: string; + ownerUpn: string; + /** True for the caller's own devices. */ + isOwn: boolean; +} + +/** GET push/devices · GET global/push/devices — own devices (everyone) plus every device of the scope (admins). */ +export interface PushDeviceListResponse { + devices: PushDeviceDto[]; +} + +/** GET/PUT push/device — the receiver's own view of its registration. */ +export interface PushDeviceStatusResponse { + deviceId: string; + /** Pending · Active · Paused · Stale (a revoked device is gone: the token answers 401). */ + status: string; + label: string; + platform: string; + kid: string; + pairedUtc: string; + confirmedUtc?: string; + lastDeliveredUtc?: string; + /** The owner's UPN — this is the owner's own device. */ + ownerUpn: string; + /** Tenant display name, or "Platform operator" for the platform scope. */ + scopeName: string; +} + /** Error body of POST /api/global/raw/logs when the telemetry store rejected or failed the query (400 for a caller-side KQL error, 502 for store/grant failures): the envelope prefix plus the store's own error code, HTTP status and — capped — its full response, so nothing the CLI would print is lost. hint tells the caller how to fix the query. */ export interface QueryBackendLogsErrorResponse { error: string; @@ -3429,6 +3509,28 @@ export interface ReclassifyJobRunResponse { triggeredAt: string; } +/** POST push/pair — the receiver hands over its subscription; the device is created Pending. */ +export interface RedeemPairRequest { + code: string; + kid: string; + endpoint: string; + p256dh: string; + auth: string; + label?: string | null; + platform?: string | null; + appVersion?: string | null; +} + +export interface RedeemPairResponse { + /** "{scope}.{deviceId}.{secret}" — the receiver's only credential; header X-Push-Device-Token. */ + deviceToken: string; + deviceId: string; + /** Always Pending here; the PC confirms. */ + status: string; + /** Suggested interval for the receiver's status poll while Pending. */ + pollSeconds: number; +} + /** Request to register a new session */ export interface RegisterSessionRequest { registration: SessionRegistration; @@ -3586,6 +3688,14 @@ export interface RestoreSessionRequest { reason?: string | null; } +/** PUT push/device — a changed or re-created subscription (same device, same owner). */ +export interface ResubscribeRequest { + endpoint: string; + p256dh: string; + auth: string; + kid: string; +} + /** Body of POST config/{tenantId}/revert. */ export interface RevertTenantConfigurationRequest { backupId?: string | null; @@ -4393,6 +4503,12 @@ export interface SessionTimeBreakdown { qualityFlags: TimeAttributionFlags; } +/** PUT sessions/{sessionId}/watch — "notify my devices when this session ends". */ +export interface SessionWatchResponse { + watching: boolean; + expiresUtc?: string; +} + /** Response of PATCH global/mcp-users/{upn}/usage-plan: the UPN and the plan now in effect ("(inherit)" when cleared to the tenant default). */ export interface SetMcpUserUsagePlanResponse { upn: string; diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts index 6420e5e05..368df48fc 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts @@ -141,6 +141,8 @@ export const OPS_EVENT_TYPES = [ "SlaConsecutiveFailures", "SlaEvaluationCompleted", "AzureMonitorAlert", + "PushDeliveryFailed", + "PushEndpointRefused", ] as const; export type OpsEventTypeName = (typeof OPS_EVENT_TYPES)[number]; @@ -221,6 +223,13 @@ export const API_ERROR_CODES = [ "TenantSuspended", "ClientAppNotRegistered", "QuotaExceeded", + "PairingCodeInvalid", + "PairingCodeUsed", + "InvalidSubscription", + "InvalidDeviceToken", + "DeviceNotFound", + "PushChannelRequired", + "PushNotEligible", "PayloadTooLarge", "TelemetryItemsRejected", "SessionOwnerMismatch", diff --git a/src/Push/AutopilotMonitor.Push.Tests/AutopilotMonitor.Push.Tests.csproj b/src/Push/AutopilotMonitor.Push.Tests/AutopilotMonitor.Push.Tests.csproj new file mode 100644 index 000000000..7989dbdcf --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/AutopilotMonitor.Push.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/src/Push/AutopilotMonitor.Push.Tests/P256Tests.cs b/src/Push/AutopilotMonitor.Push.Tests/P256Tests.cs new file mode 100644 index 000000000..012602b81 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/P256Tests.cs @@ -0,0 +1,44 @@ +using System.Numerics; +using System.Security.Cryptography; +using AutopilotMonitor.Push; + +namespace AutopilotMonitor.Push.Tests; + +public class P256Tests +{ + [Fact] + public void Constants_match_the_runtime_explicit_curve_parameters() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var curve = key.ExportExplicitParameters(false).Curve; + + Assert.Equal(new BigInteger(curve.Prime!, isUnsigned: true, isBigEndian: true), P256.Prime); + Assert.Equal(new BigInteger(curve.B!, isUnsigned: true, isBigEndian: true), P256.B); + Assert.Equal(P256.Prime - 3, new BigInteger(curve.A!, isUnsigned: true, isBigEndian: true)); + } + + [Fact] + public void Generated_points_are_on_the_curve_and_a_flipped_bit_is_not() + { + for (var i = 0; i < 8; i++) + { + var (point, key) = TestKeys.UserAgentKey(); + key.Dispose(); + Assert.True(P256.IsOnCurve(point.AsSpan(1, 32), point.AsSpan(33, 32))); + + point[1 + (i * 7) % 64] ^= 0x01; + Assert.False(P256.IsOnCurve(point.AsSpan(1, 32), point.AsSpan(33, 32))); + } + } + + [Fact] + public void Coordinates_outside_the_field_and_wrong_lengths_are_rejected() + { + var p = P256.Prime.ToByteArray(isUnsigned: true, isBigEndian: true); + Assert.False(P256.IsOnCurve(p, new byte[32])); + Assert.False(P256.IsOnCurve(new byte[32], p)); + Assert.False(P256.IsOnCurve(new byte[31], new byte[32])); + Assert.False(P256.IsOnCurve(new byte[32], new byte[33])); + Assert.False(P256.IsOnCurve(new byte[32], new byte[32])); + } +} diff --git a/src/Push/AutopilotMonitor.Push.Tests/PushEndpointPolicyTests.cs b/src/Push/AutopilotMonitor.Push.Tests/PushEndpointPolicyTests.cs new file mode 100644 index 000000000..fb4e30982 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/PushEndpointPolicyTests.cs @@ -0,0 +1,69 @@ +using AutopilotMonitor.Push; + +namespace AutopilotMonitor.Push.Tests; + +public class PushEndpointPolicyTests +{ + [Fact] + public void Allowed_host_list_is_pinned() + { + Assert.Equal( + new[] { ".push.apple.com", "fcm.googleapis.com", "updates.push.services.mozilla.com", ".notify.windows.com" }, + PushEndpointPolicy.AllowedHostSuffixes); + Assert.Equal(2048, PushEndpointPolicy.MaxEndpointLength); + } + + [Theory] + [InlineData("https://web.push.apple.com/QGxxxxxxxxxxxxxxxxxxxx")] + [InlineData("https://WEB.PUSH.APPLE.COM/QGxxxxxxxxxxxxxxxxxxxx")] + [InlineData("https://fcm.googleapis.com/wp/dGVzdA")] + [InlineData("https://fcm.googleapis.com/fcm/send/dGVzdA")] + [InlineData("https://updates.push.services.mozilla.com/wpush/v2/dGVzdA")] + [InlineData("https://wns2-par02p.notify.windows.com/w/?token=dGVzdA")] + [InlineData("https://db5p.notify.windows.com/w/?token=dGVzdA")] + public void Known_push_services_are_allowed(string endpoint) + { + Assert.Null(PushEndpointPolicy.Validate(new Uri(endpoint))); + } + + [Theory] + [InlineData("https://attacker.push.apple.com.evil.invalid/x", "known push service")] + [InlineData("https://push.apple.com/x", "known push service")] + [InlineData("https://notify.windows.com/x", "known push service")] + [InlineData("https://evilfcm.googleapis.com/wp/x", "known push service")] + [InlineData("https://fcm.googleapis.com.evil.invalid/wp/x", "known push service")] + [InlineData("https://sub.updates.push.services.mozilla.com/x", "known push service")] + [InlineData("https://contoso.invalid/push", "known push service")] + [InlineData("http://web.push.apple.com/x", "https")] + [InlineData("ftp://web.push.apple.com/x", "https")] + [InlineData("https://192.0.2.10/x", "DNS name")] + [InlineData("https://[2001:db8::1]/x", "DNS name")] + [InlineData("https://localhost/x", "DNS name")] + [InlineData("https://user:secret@web.push.apple.com/x", "credentials")] + [InlineData("https://user@web.push.apple.com/x", "credentials")] + public void Unknown_hosts_plain_http_ip_literals_and_userinfo_are_denied(string endpoint, string reasonFragment) + { + var error = PushEndpointPolicy.Validate(new Uri(endpoint)); + Assert.NotNull(error); + Assert.Contains(reasonFragment, error); + Assert.DoesNotContain(endpoint, error); + } + + [Fact] + public void Too_long_endpoints_are_denied() + { + var prefix = "https://web.push.apple.com/"; + var ok = new Uri(prefix + new string('a', 2048 - prefix.Length)); + var tooLong = new Uri(prefix + new string('a', 2049 - prefix.Length)); + + Assert.Equal(2048, ok.AbsoluteUri.Length); + Assert.Null(PushEndpointPolicy.Validate(ok)); + Assert.Equal("Endpoint is too long.", PushEndpointPolicy.Validate(tooLong)); + } + + [Fact] + public void Relative_uri_is_denied() + { + Assert.Equal("Endpoint must be an absolute URL.", PushEndpointPolicy.Validate(new Uri("/push/x", UriKind.Relative))); + } +} diff --git a/src/Push/AutopilotMonitor.Push.Tests/PushSendOptionsTests.cs b/src/Push/AutopilotMonitor.Push.Tests/PushSendOptionsTests.cs new file mode 100644 index 000000000..9ba5f33c1 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/PushSendOptionsTests.cs @@ -0,0 +1,60 @@ +using AutopilotMonitor.Push; + +namespace AutopilotMonitor.Push.Tests; + +public class PushSendOptionsTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Ttl_must_be_positive(int seconds) + { + Assert.Throws(() => new PushSendOptions(TimeSpan.FromSeconds(seconds))); + } + + [Fact] + public void Ttl_seconds_round_up_and_never_reach_zero() + { + Assert.Equal(1, new PushSendOptions(TimeSpan.FromMilliseconds(1)).TtlSeconds); + Assert.Equal(2, new PushSendOptions(TimeSpan.FromMilliseconds(1500)).TtlSeconds); + Assert.Equal(14400, new PushSendOptions(TimeSpan.FromHours(4)).TtlSeconds); + } + + [Theory] + [InlineData("")] + [InlineData("123456789012345678901234567890123")] + [InlineData("has+plus")] + [InlineData("has/slash")] + [InlineData("has=padding")] + [InlineData("has space")] + [InlineData("umlaut-ä")] + public void Topic_must_be_1_to_32_base64url_characters(string topic) + { + Assert.Throws(() => new PushSendOptions(TimeSpan.FromMinutes(5), PushUrgency.Normal, topic)); + } + + [Fact] + public void Topic_at_the_limit_is_accepted() + { + const string topic = "abcDEF0123456789-_abcDEF01234567"; + Assert.Equal(32, topic.Length); + Assert.Equal(topic, new PushSendOptions(TimeSpan.FromMinutes(5), PushUrgency.High, topic).Topic); + Assert.Null(new PushSendOptions(TimeSpan.FromMinutes(5)).Topic); + } + + [Theory] + [InlineData(PushUrgency.VeryLow, "very-low")] + [InlineData(PushUrgency.Low, "low")] + [InlineData(PushUrgency.Normal, "normal")] + [InlineData(PushUrgency.High, "high")] + public void Urgency_maps_to_the_RFC_8030_header_value(PushUrgency urgency, string expected) + { + Assert.Equal(expected, new PushSendOptions(TimeSpan.FromMinutes(5), urgency).UrgencyHeaderValue); + } + + [Fact] + public void Undefined_urgency_is_rejected() + { + Assert.Throws(() => new PushSendOptions(TimeSpan.FromMinutes(5), (PushUrgency)42)); + } +} diff --git a/src/Push/AutopilotMonitor.Push.Tests/Rfc8291VectorTests.cs b/src/Push/AutopilotMonitor.Push.Tests/Rfc8291VectorTests.cs new file mode 100644 index 000000000..337c44e0a --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/Rfc8291VectorTests.cs @@ -0,0 +1,163 @@ +using System.Security.Cryptography; +using System.Text; +using AutopilotMonitor.Push; + +namespace AutopilotMonitor.Push.Tests; + +/// +/// RFC 8291 Appendix A and section 5, values copied verbatim from the RFC text with the +/// presentation whitespace removed. Every intermediate of the derivation is pinned. +/// +public class Rfc8291VectorTests +{ + private const string Plaintext = "V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24"; + private const string AsPublic = "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8"; + private const string AsPrivate = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"; + private const string UaPublic = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"; + private const string UaPrivate = "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"; + private const string Salt = "DGv6ra1nlYgDCS1FRnbzlw"; + private const string AuthSecret = "BTBZMqHH6r4Tts7J_aSIgg"; + + private const string EcdhSecret = "kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs"; + private const string PrkKey = "Snr3JMxaHVDXHWJn5wdC52WjpCtd2EIEGBykDcZW32k"; + private const string KeyInfo = "V2ViUHVzaDogaW5mbwAEJXGyvs3942BVGq8e0PTNNmwRzr5VX4m8t7GGpTM5FzFo7OLr4BhZe9MEebhuPI-OztV3ylkYfpJGmQ22ggCLDgT-M_SrDepxkU21WCP3O1SUj0EwbZIHMtu5pZpTKGSCIA5Zent7wmC6HCJ5mFgJkuk5cwAvMBKiiujwa7t45ewP"; + private const string Ikm = "S4lYMb_L0FxCeq0WhDx813KgSYqU26kOyzWUdsXYyrg"; + private const string Prk = "09_eUZGrsvxChDCGRCdkLiDXrReGOEVeSCdCcPBSJSc"; + private const string Cek = "oIhVW04MRdy2XN9CiKLxTg"; + private const string Nonce = "4h_95klXJ5E_qnoN"; + + private const string Header = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8"; + private const string PaddedPlaintext = "V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24C"; + private const string Ciphertext = "8pfeW0KbunFT06SuDKoJH9Ql87S1QUrdirN6GcG7sFz1y1sqLgVi1VhjVkHsUoEsbI_0LpXMuGvnzQ"; + private const string Body = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"; + + private static WebPushSubscription VectorSubscription() => + new(new Uri("https://push.example.net/push/JzLQ3raZJfFBR0aqvOMsLrt54w4rJUsV"), TestKeys.Decode(UaPublic), TestKeys.Decode(AuthSecret)); + + private static ECDiffieHellman SenderKey() + { + var point = TestKeys.Decode(AsPublic); + return ECDiffieHellman.Create(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + D = TestKeys.Decode(AsPrivate), + Q = new ECPoint { X = point[1..33], Y = point[33..] }, + }); + } + + [Fact] + public void Plaintext_is_the_watermelon_sentence() + { + Assert.Equal("When I grow up, I want to be a watermelon", Encoding.ASCII.GetString(TestKeys.Decode(Plaintext))); + Assert.Equal(41, TestKeys.Decode(Plaintext).Length); + } + + [Fact] + public void DeriveKeys_matches_every_intermediate() + { + using var sender = SenderKey(); + var keys = WebPushEncryptor.DeriveKeys(VectorSubscription(), TestKeys.Decode(Salt), sender); + + Assert.Equal(AsPublic, TestKeys.Base64Url(keys.SenderPublicKey)); + Assert.Equal(EcdhSecret, TestKeys.Base64Url(keys.EcdhSecret)); + Assert.Equal(PrkKey, TestKeys.Base64Url(keys.PrkKey)); + Assert.Equal(KeyInfo, TestKeys.Base64Url(keys.KeyInfo)); + Assert.Equal(144, keys.KeyInfo.Length); + Assert.Equal(Ikm, TestKeys.Base64Url(keys.Ikm)); + Assert.Equal(Prk, TestKeys.Base64Url(keys.Prk)); + Assert.Equal(Cek, TestKeys.Base64Url(keys.Cek)); + Assert.Equal(16, keys.Cek.Length); + Assert.Equal(Nonce, TestKeys.Base64Url(keys.Nonce)); + Assert.Equal(12, keys.Nonce.Length); + } + + [Fact] + public void Encrypt_produces_the_RFC_message_body() + { + using var sender = SenderKey(); + var body = WebPushEncryptor.Encrypt(TestKeys.Decode(Plaintext), VectorSubscription(), TestKeys.Decode(Salt), sender); + + Assert.Equal(144, body.Length); + Assert.Equal(Header, TestKeys.Base64Url(body[..86])); + Assert.Equal(Ciphertext, TestKeys.Base64Url(body[86..])); + Assert.Equal(Body, TestKeys.Base64Url(body)); + Assert.Equal(192, TestKeys.Base64Url(body).Length); + } + + [Fact] + public void Padded_plaintext_is_plaintext_plus_delimiter_0x02() + { + var padded = TestKeys.Decode(PaddedPlaintext); + Assert.Equal(TestKeys.Decode(Plaintext), padded[..^1]); + Assert.Equal(0x02, padded[^1]); + } + + [Fact] + public void Header_layout_is_salt_rs_idlen_keyid() + { + var header = TestKeys.Decode(Header); + Assert.Equal(TestKeys.Decode(Salt), header[..16]); + Assert.Equal(new byte[] { 0x00, 0x00, 0x10, 0x00 }, header[16..20]); + Assert.Equal(65, header[20]); + Assert.Equal(TestKeys.Decode(AsPublic), header[21..]); + } + + [Fact] + public void Public_Encrypt_uses_fresh_salt_and_key_and_the_receiver_can_decrypt() + { + var subscription = VectorSubscription(); + var plaintext = TestKeys.Decode(Plaintext); + var first = WebPushEncryptor.Encrypt(plaintext, subscription); + var second = WebPushEncryptor.Encrypt(plaintext, subscription); + + Assert.Equal(144, first.Length); + Assert.NotEqual(first[..16], second[..16]); + Assert.NotEqual(first[21..86], second[21..86]); + Assert.Equal(plaintext, ReceiverDecrypt(first)); + Assert.Equal(plaintext, ReceiverDecrypt(second)); + } + + [Fact] + public void Encrypt_refuses_plaintext_above_the_RFC_bound_and_accepts_the_bound() + { + var subscription = VectorSubscription(); + Assert.Equal(3993, WebPushEncryptor.MaxPlaintextLength); + var atBound = WebPushEncryptor.Encrypt(new byte[3993], subscription); + Assert.Equal(4096, atBound.Length); + Assert.Throws(() => WebPushEncryptor.Encrypt(new byte[3994], subscription)); + } + + /// The user agent's side of RFC 8291 section 3.4, written independently of the encryptor. + private static byte[] ReceiverDecrypt(byte[] body) + { + var salt = body[..16]; + var senderPublic = body[21..86]; + var uaPublicPoint = TestKeys.Decode(UaPublic); + using var uaKey = ECDiffieHellman.Create(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + D = TestKeys.Decode(UaPrivate), + Q = new ECPoint { X = uaPublicPoint[1..33], Y = uaPublicPoint[33..] }, + }); + using var senderKey = ECDiffieHellman.Create(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint { X = senderPublic[1..33], Y = senderPublic[33..] }, + }); + + var ecdhSecret = uaKey.DeriveRawSecretAgreement(senderKey.PublicKey); + var prkKey = HKDF.Extract(HashAlgorithmName.SHA256, ecdhSecret, TestKeys.Decode(AuthSecret)); + var keyInfo = "WebPush: info\0"u8.ToArray().Concat(uaPublicPoint).Concat(senderPublic).ToArray(); + var ikm = HKDF.Expand(HashAlgorithmName.SHA256, prkKey, 32, keyInfo); + var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, salt); + var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, "Content-Encoding: aes128gcm\0"u8.ToArray()); + var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, "Content-Encoding: nonce\0"u8.ToArray()); + + var record = body[86..]; + var padded = new byte[record.Length - 16]; + using var aes = new AesGcm(cek, 16); + aes.Decrypt(nonce, record[..^16], record[^16..], padded); + Assert.Equal(0x02, padded[^1]); + return padded[..^1]; + } +} diff --git a/src/Push/AutopilotMonitor.Push.Tests/TestSupport.cs b/src/Push/AutopilotMonitor.Push.Tests/TestSupport.cs new file mode 100644 index 000000000..1fe13b323 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/TestSupport.cs @@ -0,0 +1,133 @@ +using System.Buffers.Text; +using System.Net; +using System.Security.Cryptography; +using AutopilotMonitor.Push; + +namespace AutopilotMonitor.Push.Tests; + +internal static class TestKeys +{ + public static string Base64Url(byte[] bytes) => System.Buffers.Text.Base64Url.EncodeToString(bytes); + + public static byte[] Decode(string base64Url) => System.Buffers.Text.Base64Url.DecodeFromChars(base64Url.AsSpan()); + + /// A fresh user-agent key pair as a browser would create it: the 65-byte point plus the private key. + public static (byte[] Point, ECDiffieHellman Key) UserAgentKey() + { + var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + return (UncompressedPoint(key), key); + } + + public static byte[] UncompressedPoint(ECDiffieHellman key) + { + var parameters = key.ExportParameters(false); + var point = new byte[65]; + point[0] = 0x04; + VapidKey.LeftPad(parameters.Q.X!, point.AsSpan(1, 32)); + VapidKey.LeftPad(parameters.Q.Y!, point.AsSpan(33, 32)); + return point; + } + + public static WebPushSubscription Subscription(string endpoint = "https://web.push.apple.com/QGxxxxxxxxxxxxxxxxxxxx") + { + var (point, key) = UserAgentKey(); + key.Dispose(); + return new WebPushSubscription(new Uri(endpoint), point, RandomNumberGenerator.GetBytes(16)); + } +} + +internal sealed record CapturedRequest(Uri Uri, byte[] Body, IReadOnlyDictionary Headers); + +/// Scripted handler: one scripted answer per request, in order; records every request it saw. +internal sealed class FakeHandler : HttpMessageHandler +{ + private readonly Queue> _script = new(); + + public List Requests { get; } = new(); + + public FakeHandler Respond(HttpStatusCode status, string? body = null, TimeSpan? retryAfter = null) + { + _script.Enqueue(_ => + { + var response = new HttpResponseMessage(status) { Content = new StringContent(body ?? string.Empty) }; + if (retryAfter is { } delta) + { + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(delta); + } + + return response; + }); + return this; + } + + public FakeHandler Throw(Exception exception) + { + _script.Enqueue(_ => throw exception); + return this; + } + + public HttpClient Client() => new(this); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null ? Array.Empty() : await request.Content.ReadAsByteArrayAsync(cancellationToken); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var header in request.Headers) + { + headers[header.Key] = string.Join(",", header.Value); + } + + if (request.Content is not null) + { + foreach (var header in request.Content.Headers) + { + headers[header.Key] = string.Join(",", header.Value); + } + } + + Requests.Add(new CapturedRequest(request.RequestUri!, body, headers)); + cancellationToken.ThrowIfCancellationRequested(); + if (_script.Count == 0) + { + throw new InvalidOperationException("No scripted response left."); + } + + return _script.Dequeue()(request); + } +} + +/// Clock under test control; timers fire immediately and record the requested delay. +internal sealed class FakeTime : TimeProvider +{ + public DateTimeOffset UtcNow { get; set; } = new(2026, 10, 7, 12, 0, 0, TimeSpan.Zero); + + public List Delays { get; } = new(); + + public override DateTimeOffset GetUtcNow() => UtcNow; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + Delays.Add(dueTime); + callback(state); + return new CompletedTimer(); + } + + private sealed class CompletedTimer : ITimer + { + public bool Change(TimeSpan dueTime, TimeSpan period) => false; + + public void Dispose() + { + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} + +/// Stands in for the host's SsrfException; the sender matches the type by name. +internal sealed class SsrfException : Exception +{ + public SsrfException(string message) : base(message) + { + } +} diff --git a/src/Push/AutopilotMonitor.Push.Tests/VapidAuthorizationTests.cs b/src/Push/AutopilotMonitor.Push.Tests/VapidAuthorizationTests.cs new file mode 100644 index 000000000..de5eb3c5f --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/VapidAuthorizationTests.cs @@ -0,0 +1,137 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AutopilotMonitor.Push; + +namespace AutopilotMonitor.Push.Tests; + +public class VapidAuthorizationTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 7, 12, 0, 0, TimeSpan.Zero); + private static readonly Uri Endpoint = new("https://web.push.apple.com/QGxxxxxxxxxxxxxxxx?token=abc"); + private const string Subject = "mailto:push@contoso.invalid"; + + private static (string Token, string PublicKey) Split(string headerValue) + { + Assert.StartsWith("vapid t=", headerValue); + var parts = headerValue["vapid ".Length..].Split(", "); + Assert.Equal(2, parts.Length); + Assert.StartsWith("t=", parts[0]); + Assert.StartsWith("k=", parts[1]); + return (parts[0][2..], parts[1][2..]); + } + + private static JsonDocument DecodeSegment(string segment) => JsonDocument.Parse(TestKeys.Decode(segment)); + + [Fact] + public void Header_carries_a_three_part_ES256_JWT_and_the_public_key() + { + using var key = VapidKey.Generate(); + var sut = new VapidAuthorization(); + + var (token, publicKey) = Split(sut.BuildHeaderValue(Endpoint, key, Subject, Now)); + + Assert.Equal(key.PublicKeyBase64Url, publicKey); + var segments = token.Split('.'); + Assert.Equal(3, segments.Length); + + using var header = DecodeSegment(segments[0]); + Assert.Equal("JWT", header.RootElement.GetProperty("typ").GetString()); + Assert.Equal("ES256", header.RootElement.GetProperty("alg").GetString()); + Assert.Equal(2, header.RootElement.EnumerateObject().Count()); + + using var claims = DecodeSegment(segments[1]); + Assert.Equal("https://web.push.apple.com", claims.RootElement.GetProperty("aud").GetString()); + Assert.Equal(Now.AddHours(24).ToUnixTimeSeconds(), claims.RootElement.GetProperty("exp").GetInt64()); + Assert.Equal(Subject, claims.RootElement.GetProperty("sub").GetString()); + Assert.Equal(3, claims.RootElement.EnumerateObject().Count()); + + var signature = TestKeys.Decode(segments[2]); + Assert.Equal(64, signature.Length); + var point = key.PublicKeyBytes.ToArray(); + using var verifier = ECDsa.Create(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint { X = point[1..33], Y = point[33..] }, + }); + var signingInput = Encoding.ASCII.GetBytes(segments[0] + "." + segments[1]); + Assert.True(verifier.VerifyData(signingInput, signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)); + } + + [Theory] + [InlineData("https://fcm.googleapis.com/wp/abc", "https://fcm.googleapis.com")] + [InlineData("https://updates.push.services.mozilla.com/wpush/v2/abc?x=1#f", "https://updates.push.services.mozilla.com")] + [InlineData("https://wns2-par02p.notify.windows.com/w/?token=abc", "https://wns2-par02p.notify.windows.com")] + [InlineData("https://push.example.net:8443/push/abc", "https://push.example.net:8443")] + [InlineData("https://Push.Example.NET:443/push/abc", "https://push.example.net")] + public void Audience_is_the_origin_only(string endpoint, string expectedAudience) + { + using var key = VapidKey.Generate(); + var (token, _) = Split(new VapidAuthorization().BuildHeaderValue(new Uri(endpoint), key, Subject, Now)); + using var claims = DecodeSegment(token.Split('.')[1]); + Assert.Equal(expectedAudience, claims.RootElement.GetProperty("aud").GetString()); + } + + [Fact] + public void Token_is_reused_for_the_same_audience_and_kid_until_12_hours_remain_then_minted_again() + { + using var key = VapidKey.Generate(); + var sut = new VapidAuthorization(); + + var first = sut.BuildHeaderValue(Endpoint, key, Subject, Now); + var sameAudienceOtherPath = sut.BuildHeaderValue(new Uri("https://web.push.apple.com/other"), key, Subject, Now.AddHours(11).AddMinutes(59)); + Assert.Equal(first, sameAudienceOtherPath); + + var exactlyTwelveLeft = sut.BuildHeaderValue(Endpoint, key, Subject, Now.AddHours(12)); + Assert.Equal(first, exactlyTwelveLeft); + + var reminted = sut.BuildHeaderValue(Endpoint, key, Subject, Now.AddHours(12).AddSeconds(1)); + Assert.NotEqual(first, reminted); + using var claims = DecodeSegment(Split(reminted).Token.Split('.')[1]); + Assert.Equal(Now.AddHours(36).AddSeconds(1).ToUnixTimeSeconds(), claims.RootElement.GetProperty("exp").GetInt64()); + + Assert.Equal(reminted, sut.BuildHeaderValue(Endpoint, key, Subject, Now.AddHours(13))); + } + + [Fact] + public void Cache_is_keyed_by_audience_and_kid() + { + using var keyA = VapidKey.Generate(); + using var keyB = VapidKey.Generate(); + var sut = new VapidAuthorization(); + + var apple = sut.BuildHeaderValue(Endpoint, keyA, Subject, Now); + var fcm = sut.BuildHeaderValue(new Uri("https://fcm.googleapis.com/wp/abc"), keyA, Subject, Now); + var appleOtherKey = sut.BuildHeaderValue(Endpoint, keyB, Subject, Now); + + Assert.NotEqual(apple, fcm); + Assert.NotEqual(apple, appleOtherKey); + Assert.Equal(apple, sut.BuildHeaderValue(Endpoint, keyA, Subject, Now)); + } + + [Theory] + [InlineData("push@contoso.invalid")] + [InlineData("http://contoso.invalid")] + [InlineData("")] + public void Subject_must_be_mailto_or_https(string subject) + { + using var key = VapidKey.Generate(); + Assert.Throws(() => new VapidAuthorization().BuildHeaderValue(Endpoint, key, subject, Now)); + } + + [Fact] + public void Https_subject_is_accepted() + { + using var key = VapidKey.Generate(); + var (token, _) = Split(new VapidAuthorization().BuildHeaderValue(Endpoint, key, "https://contoso.invalid/contact", Now)); + using var claims = DecodeSegment(token.Split('.')[1]); + Assert.Equal("https://contoso.invalid/contact", claims.RootElement.GetProperty("sub").GetString()); + } + + [Fact] + public void Relative_endpoint_is_refused() + { + using var key = VapidKey.Generate(); + Assert.Throws(() => new VapidAuthorization().BuildHeaderValue(new Uri("/push/abc", UriKind.Relative), key, Subject, Now)); + } +} diff --git a/src/Push/AutopilotMonitor.Push.Tests/VapidKeyTests.cs b/src/Push/AutopilotMonitor.Push.Tests/VapidKeyTests.cs new file mode 100644 index 000000000..743043199 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/VapidKeyTests.cs @@ -0,0 +1,146 @@ +using System.Security.Cryptography; +using System.Text; +using AutopilotMonitor.Push; + +namespace AutopilotMonitor.Push.Tests; + +public class VapidKeyTests +{ + [Fact] + public void Generate_round_trips_through_export_and_parse() + { + using var generated = VapidKey.Generate(); + var export = generated.ExportBase64Url(); + using var parsed = VapidKey.Parse(export); + + Assert.Equal(96, TestKeys.Decode(export).Length); + Assert.Equal(generated.PublicKeyBase64Url, parsed.PublicKeyBase64Url); + Assert.Equal(generated.Kid, parsed.Kid); + Assert.Equal(export, parsed.ExportBase64Url()); + Assert.Equal(65, generated.PublicKeyBytes.Length); + Assert.Equal(0x04, generated.PublicKeyBytes.Span[0]); + } + + [Fact] + public void Kid_is_eight_chars_of_the_public_key_hash_and_stable() + { + using var key = VapidKey.Generate(); + var expected = TestKeys.Base64Url(SHA256.HashData(key.PublicKeyBytes.Span))[..8]; + + Assert.Equal(8, key.Kid.Length); + Assert.Equal(expected, key.Kid); + Assert.Equal(key.Kid, VapidKey.Parse(key.ExportBase64Url()).Kid); + } + + [Fact] + public void SignEs256_is_a_64_byte_P1363_signature_that_verifies_with_the_public_point() + { + using var key = VapidKey.Generate(); + var data = Encoding.ASCII.GetBytes("header.payload"); + var signature = key.SignEs256(data); + + Assert.Equal(64, signature.Length); + var point = key.PublicKeyBytes.ToArray(); + using var verifier = ECDsa.Create(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint { X = point[1..33], Y = point[33..] }, + }); + Assert.True(verifier.VerifyData(data, signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)); + } + + [Theory] + [InlineData("not base64url!")] + [InlineData("AAAA")] + [InlineData("")] + public void Parse_rejects_malformed_input_without_echoing_it(string input) + { + var ex = Assert.Throws(() => VapidKey.Parse(input)); + if (input.Length > 0) + { + Assert.DoesNotContain(input, ex.Message); + } + } + + [Fact] + public void Parse_rejects_a_public_point_that_does_not_belong_to_the_scalar() + { + using var a = VapidKey.Generate(); + using var b = VapidKey.Generate(); + var mixed = TestKeys.Decode(a.ExportBase64Url())[..32].Concat(TestKeys.Decode(b.ExportBase64Url())[32..]).ToArray(); + + var ex = Assert.Throws(() => VapidKey.Parse(TestKeys.Base64Url(mixed))); + Assert.DoesNotContain(TestKeys.Base64Url(mixed), ex.Message); + } + + [Fact] + public void Disposed_key_refuses_to_sign() + { + var key = VapidKey.Generate(); + key.Dispose(); + Assert.Throws(() => key.SignEs256(new byte[1])); + } +} + +public class VapidKeyRingTests +{ + [Fact] + public void FromSettings_resolves_active_first_then_retired_and_null_for_unknown() + { + using var active = VapidKey.Generate(); + using var retired1 = VapidKey.Generate(); + using var retired2 = VapidKey.Generate(); + + using var ring = VapidKeyRing.FromSettings( + active.ExportBase64Url(), + $" {retired1.ExportBase64Url()} ; {retired2.ExportBase64Url()};"); + + Assert.Equal(active.Kid, ring.Active.Kid); + Assert.Equal(2, ring.Retired.Count); + Assert.Same(ring.Active, ring.Resolve(active.Kid)); + Assert.Equal(retired1.Kid, ring.Resolve(retired1.Kid)!.Kid); + Assert.Equal(retired2.Kid, ring.Resolve(retired2.Kid)!.Kid); + Assert.Null(ring.Resolve("unknown1")); + Assert.Null(ring.Resolve(null)); + Assert.Null(ring.Resolve(string.Empty)); + } + + [Fact] + public void FromSettings_without_retired_keys_has_an_empty_retired_list() + { + using var active = VapidKey.Generate(); + using var ring = VapidKeyRing.FromSettings(active.ExportBase64Url(), null); + Assert.Empty(ring.Retired); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void FromSettings_rejects_a_missing_active_key(string? active) + { + Assert.Throws(() => VapidKeyRing.FromSettings(active, null)); + } + + [Fact] + public void FromSettings_rejects_an_invalid_active_key_without_echoing_it() + { + const string bogus = "bm90LWEta2V5"; + var ex = Assert.Throws(() => VapidKeyRing.FromSettings(bogus, null)); + Assert.StartsWith("The active VAPID key is invalid", ex.Message); + Assert.DoesNotContain(bogus, ex.Message); + } + + [Fact] + public void FromSettings_names_the_invalid_retired_entry_by_position_without_echoing_it() + { + using var active = VapidKey.Generate(); + using var retired = VapidKey.Generate(); + const string bogus = "bm90LWEta2V5"; + + var ex = Assert.Throws(() => VapidKeyRing.FromSettings(active.ExportBase64Url(), $"{retired.ExportBase64Url()};{bogus}")); + Assert.StartsWith("Retired VAPID key #2 is invalid", ex.Message); + Assert.DoesNotContain(bogus, ex.Message); + Assert.DoesNotContain(active.ExportBase64Url(), ex.Message); + } +} diff --git a/src/Push/AutopilotMonitor.Push.Tests/WebPushSenderTests.cs b/src/Push/AutopilotMonitor.Push.Tests/WebPushSenderTests.cs new file mode 100644 index 000000000..a039d4494 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/WebPushSenderTests.cs @@ -0,0 +1,291 @@ +using System.Net; +using AutopilotMonitor.Push; + +namespace AutopilotMonitor.Push.Tests; + +public class WebPushSenderTests +{ + private const string Subject = "mailto:push@contoso.invalid"; + private static readonly PushSendOptions DefaultOptions = new(TimeSpan.FromHours(4), PushUrgency.High, "c2Vzc2lvbi10b3BpYy0xMjM0NTY3ODkw"); + + private sealed class Harness : IDisposable + { + public FakeHandler Handler { get; } = new(); + public FakeTime Time { get; } = new(); + public VapidKey Key { get; } = VapidKey.Generate(); + public WebPushSubscription Subscription { get; } = TestKeys.Subscription(); + public byte[] Body { get; } = WebPushEncryptor.Encrypt("{\"id\":\"1\"}"u8, TestKeys.Subscription()); + + public WebPushSender Sender => new(Handler.Client(), new VapidAuthorization(), Time); + + public Task SendAsync(PushSendOptions? options = null, byte[]? body = null, CancellationToken ct = default) => + Sender.SendAsync(Subscription, body ?? Body, options ?? DefaultOptions, Key, Subject, ct); + + public void Dispose() => Key.Dispose(); + } + + [Fact] + public async Task Created_is_Delivered_and_the_request_carries_every_header() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.Created); + + var result = await h.SendAsync(); + + Assert.Equal(PushOutcome.Delivered, result.Outcome); + Assert.Equal(201, result.StatusCode); + Assert.Null(result.RetryAfter); + Assert.Null(result.Reason); + + var request = Assert.Single(h.Handler.Requests); + Assert.Equal(h.Subscription.Endpoint, request.Uri); + Assert.Equal(h.Body, request.Body); + Assert.Equal("14400", request.Headers["TTL"]); + Assert.Equal("high", request.Headers["Urgency"]); + Assert.Equal("c2Vzc2lvbi10b3BpYy0xMjM0NTY3ODkw", request.Headers["Topic"]); + Assert.Equal("aes128gcm", request.Headers["Content-Encoding"]); + Assert.Equal("application/octet-stream", request.Headers["Content-Type"]); + Assert.Equal(h.Body.Length.ToString(), request.Headers["Content-Length"]); + Assert.StartsWith("vapid t=", request.Headers["Authorization"]); + Assert.EndsWith(", k=" + h.Key.PublicKeyBase64Url, request.Headers["Authorization"]); + } + + [Fact] + public async Task Topic_header_is_omitted_when_no_topic_is_set() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.Created); + + await h.SendAsync(new PushSendOptions(TimeSpan.FromMinutes(5), PushUrgency.Low)); + + var request = Assert.Single(h.Handler.Requests); + Assert.False(request.Headers.ContainsKey("Topic")); + Assert.Equal("low", request.Headers["Urgency"]); + Assert.Equal("300", request.Headers["TTL"]); + } + + [Theory] + [InlineData(HttpStatusCode.OK, PushOutcome.Delivered)] + [InlineData(HttpStatusCode.NotFound, PushOutcome.Gone)] + [InlineData(HttpStatusCode.Gone, PushOutcome.Gone)] + [InlineData(HttpStatusCode.RequestEntityTooLarge, PushOutcome.PayloadTooLarge)] + [InlineData(HttpStatusCode.BadRequest, PushOutcome.ConfigError)] + [InlineData(HttpStatusCode.Unauthorized, PushOutcome.ConfigError)] + [InlineData(HttpStatusCode.Forbidden, PushOutcome.ConfigError)] + [InlineData(HttpStatusCode.MethodNotAllowed, PushOutcome.Failed)] + [InlineData((HttpStatusCode)418, PushOutcome.Failed)] + public async Task Non_retryable_statuses_are_classified_after_a_single_request(HttpStatusCode status, PushOutcome expected) + { + using var h = new Harness(); + h.Handler.Respond(status, "{\"reason\":\"x\"}"); + + var result = await h.SendAsync(); + + Assert.Equal(expected, result.Outcome); + Assert.Equal((int)status, result.StatusCode); + Assert.Single(h.Handler.Requests); + Assert.Empty(h.Time.Delays); + } + + [Fact] + public async Task Too_many_requests_is_RetryLater_with_the_Retry_After_and_no_in_process_retry() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.TooManyRequests, "{\"reason\":\"TooManyRequests\"}", TimeSpan.FromSeconds(120)); + + var result = await h.SendAsync(); + + Assert.Equal(PushOutcome.RetryLater, result.Outcome); + Assert.Equal(429, result.StatusCode); + Assert.Equal(TimeSpan.FromSeconds(120), result.RetryAfter); + Assert.Equal("{\"reason\":\"TooManyRequests\"}", result.Reason); + Assert.Single(h.Handler.Requests); + Assert.Empty(h.Time.Delays); + } + + [Fact] + public async Task Server_error_is_retried_once_after_2_seconds_and_the_retry_can_succeed() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.InternalServerError).Respond(HttpStatusCode.Created); + + var result = await h.SendAsync(); + + Assert.Equal(PushOutcome.Delivered, result.Outcome); + Assert.Equal(201, result.StatusCode); + Assert.Equal(2, h.Handler.Requests.Count); + Assert.Equal(new[] { TimeSpan.FromSeconds(2) }, h.Time.Delays); + } + + [Fact] + public async Task Server_error_honours_a_Retry_After_of_at_most_30_seconds() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.ServiceUnavailable, null, TimeSpan.FromSeconds(5)).Respond(HttpStatusCode.Created); + + await h.SendAsync(); + + Assert.Equal(new[] { TimeSpan.FromSeconds(5) }, h.Time.Delays); + } + + [Fact] + public async Task Server_error_with_a_long_Retry_After_falls_back_to_2_seconds() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.ServiceUnavailable, null, TimeSpan.FromSeconds(60)).Respond(HttpStatusCode.Created); + + await h.SendAsync(); + + Assert.Equal(new[] { TimeSpan.FromSeconds(2) }, h.Time.Delays); + } + + [Fact] + public async Task Two_server_errors_are_Failed_with_the_last_status() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.InternalServerError, "boom").Respond(HttpStatusCode.BadGateway, "still boom"); + + var result = await h.SendAsync(); + + Assert.Equal(PushOutcome.Failed, result.Outcome); + Assert.Equal(502, result.StatusCode); + Assert.Equal("still boom", result.Reason); + Assert.Equal(2, h.Handler.Requests.Count); + Assert.Single(h.Time.Delays); + } + + [Fact] + public async Task Ssrf_refusal_is_Failed_without_retry_and_without_the_endpoint() + { + using var h = new Harness(); + h.Handler.Throw(new HttpRequestException("blocked", new SsrfException("Destination resolves to a private address."))); + + var result = await h.SendAsync(); + + Assert.Equal(PushOutcome.Failed, result.Outcome); + Assert.Null(result.StatusCode); + Assert.Equal(WebPushSender.SsrfRefusedReason, result.Reason); + Assert.Single(h.Handler.Requests); + Assert.Empty(h.Time.Delays); + } + + [Fact] + public async Task Network_error_is_retried_once_then_Failed_with_a_fixed_reason() + { + using var h = new Harness(); + h.Handler + .Throw(new HttpRequestException(HttpRequestError.NameResolutionError, "No such host is known. (web.push.apple.com:443)")) + .Throw(new HttpRequestException(HttpRequestError.ConnectionError, "connection refused")); + + var result = await h.SendAsync(); + + Assert.Equal(PushOutcome.Failed, result.Outcome); + Assert.Null(result.StatusCode); + Assert.Equal("network_error:ConnectionError", result.Reason); + Assert.Equal(2, h.Handler.Requests.Count); + Assert.Equal(new[] { TimeSpan.FromSeconds(2) }, h.Time.Delays); + } + + [Fact] + public async Task Network_error_followed_by_success_is_Delivered() + { + using var h = new Harness(); + h.Handler.Throw(new HttpRequestException("reset")).Respond(HttpStatusCode.Created); + + var result = await h.SendAsync(); + + Assert.Equal(PushOutcome.Delivered, result.Outcome); + Assert.Equal(2, h.Handler.Requests.Count); + } + + [Fact] + public async Task Timeout_is_retried_once_then_Failed() + { + using var h = new Harness(); + h.Handler + .Throw(new TaskCanceledException("timeout", new TimeoutException())) + .Throw(new TaskCanceledException("timeout", new TimeoutException())); + + var result = await h.SendAsync(); + + Assert.Equal(PushOutcome.Failed, result.Outcome); + Assert.Equal("timeout", result.Reason); + Assert.Equal(2, h.Handler.Requests.Count); + } + + [Fact] + public async Task Caller_cancellation_propagates() + { + using var h = new Harness(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => h.SendAsync(ct: cts.Token)); + Assert.Empty(h.Time.Delays); + } + + [Fact] + public async Task Body_above_4096_bytes_is_refused_before_any_request() + { + using var h = new Harness(); + + var result = await h.SendAsync(body: new byte[4097]); + + Assert.Equal(PushOutcome.PayloadTooLarge, result.Outcome); + Assert.Null(result.StatusCode); + Assert.Equal("payload_exceeds_4096", result.Reason); + Assert.Empty(h.Handler.Requests); + } + + [Fact] + public async Task Body_of_exactly_4096_bytes_is_sent() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.Created); + + var result = await h.SendAsync(body: new byte[4096]); + + Assert.Equal(PushOutcome.Delivered, result.Outcome); + Assert.Single(h.Handler.Requests); + } + + [Fact] + public async Task Reason_strips_control_characters_and_is_capped_at_200_characters() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.BadRequest, "bad\r\n\tthing\u0001" + new string('x', 300)); + + var result = await h.SendAsync(); + + Assert.NotNull(result.Reason); + Assert.Equal(200, result.Reason.Length); + Assert.StartsWith("badthing", result.Reason); + Assert.DoesNotContain('\n', result.Reason); + } + + [Fact] + public async Task Empty_response_body_yields_no_reason() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.Gone, " "); + + var result = await h.SendAsync(); + + Assert.Equal(PushOutcome.Gone, result.Outcome); + Assert.Null(result.Reason); + } + + [Fact] + public async Task Authorization_token_is_reused_across_sends_to_the_same_service() + { + using var h = new Harness(); + h.Handler.Respond(HttpStatusCode.Created).Respond(HttpStatusCode.Created); + var sender = h.Sender; + + await sender.SendAsync(h.Subscription, h.Body, DefaultOptions, h.Key, Subject, CancellationToken.None); + h.Time.UtcNow = h.Time.UtcNow.AddHours(1); + await sender.SendAsync(h.Subscription, h.Body, DefaultOptions, h.Key, Subject, CancellationToken.None); + + Assert.Equal(h.Handler.Requests[0].Headers["Authorization"], h.Handler.Requests[1].Headers["Authorization"]); + } +} diff --git a/src/Push/AutopilotMonitor.Push.Tests/WebPushSubscriptionTests.cs b/src/Push/AutopilotMonitor.Push.Tests/WebPushSubscriptionTests.cs new file mode 100644 index 000000000..a8fc4fdab --- /dev/null +++ b/src/Push/AutopilotMonitor.Push.Tests/WebPushSubscriptionTests.cs @@ -0,0 +1,132 @@ +using System.Security.Cryptography; +using AutopilotMonitor.Push; + +namespace AutopilotMonitor.Push.Tests; + +public class WebPushSubscriptionTests +{ + private const string Endpoint = "https://fcm.googleapis.com/wp/dGVzdA"; + + private static (string P256dh, string Auth) ValidKeys() + { + var (point, key) = TestKeys.UserAgentKey(); + key.Dispose(); + return (TestKeys.Base64Url(point), TestKeys.Base64Url(RandomNumberGenerator.GetBytes(16))); + } + + [Fact] + public void Valid_input_parses() + { + var (p256dh, auth) = ValidKeys(); + + Assert.True(WebPushSubscription.TryParse(Endpoint, p256dh, auth, out var subscription, out var error)); + Assert.Null(error); + Assert.Equal(new Uri(Endpoint), subscription.Endpoint); + Assert.Equal(65, subscription.P256dh.Length); + Assert.Equal(0x04, subscription.P256dh.Span[0]); + Assert.Equal(16, subscription.Auth.Length); + Assert.Equal(TestKeys.Decode(p256dh), subscription.P256dh.ToArray()); + Assert.Equal(TestKeys.Decode(auth), subscription.Auth.ToArray()); + } + + [Fact] + public void Padded_base64_is_tolerated() + { + var (p256dh, auth) = ValidKeys(); + Assert.True(WebPushSubscription.TryParse(Endpoint, p256dh, auth + "==", out _, out _)); + } + + [Theory] + [InlineData(64)] + [InlineData(66)] + [InlineData(33)] + public void P256dh_with_wrong_length_is_rejected(int length) + { + var (_, auth) = ValidKeys(); + var bytes = new byte[length]; + bytes[0] = 0x04; + + Assert.False(WebPushSubscription.TryParse(Endpoint, TestKeys.Base64Url(bytes), auth, out var subscription, out var error)); + Assert.Null(subscription); + Assert.Contains("p256dh", error); + } + + [Fact] + public void P256dh_without_the_0x04_prefix_is_rejected() + { + var (p256dh, auth) = ValidKeys(); + var bytes = TestKeys.Decode(p256dh); + bytes[0] = 0x02; + + Assert.False(WebPushSubscription.TryParse(Endpoint, TestKeys.Base64Url(bytes), auth, out _, out var error)); + Assert.Contains("p256dh", error); + } + + [Fact] + public void P256dh_that_is_not_on_the_curve_is_rejected() + { + var (p256dh, auth) = ValidKeys(); + var bytes = TestKeys.Decode(p256dh); + bytes[64] ^= 0x01; + + Assert.False(WebPushSubscription.TryParse(Endpoint, TestKeys.Base64Url(bytes), auth, out _, out var error)); + Assert.Contains("p256dh", error); + } + + [Theory] + [InlineData("not*base64url")] + [InlineData("")] + [InlineData(null)] + public void P256dh_that_is_not_base64url_is_rejected(string? p256dh) + { + var (_, auth) = ValidKeys(); + Assert.False(WebPushSubscription.TryParse(Endpoint, p256dh, auth, out _, out var error)); + Assert.Contains("p256dh", error); + } + + [Theory] + [InlineData("not*base64url")] + [InlineData("")] + [InlineData(null)] + public void Auth_that_is_not_base64url_is_rejected(string? auth) + { + var (p256dh, _) = ValidKeys(); + Assert.False(WebPushSubscription.TryParse(Endpoint, p256dh, auth, out _, out var error)); + Assert.Contains("auth secret", error); + } + + [Theory] + [InlineData(15)] + [InlineData(17)] + [InlineData(32)] + public void Auth_with_wrong_length_is_rejected(int length) + { + var (p256dh, _) = ValidKeys(); + Assert.False(WebPushSubscription.TryParse(Endpoint, p256dh, TestKeys.Base64Url(new byte[length]), out _, out var error)); + Assert.Equal("The auth secret must be 16 bytes.", error); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("/relative/path")] + [InlineData("https://attacker.push.apple.com.evil.invalid/x")] + public void Endpoint_that_fails_the_policy_is_rejected(string? endpoint) + { + var (p256dh, auth) = ValidKeys(); + Assert.False(WebPushSubscription.TryParse(endpoint, p256dh, auth, out _, out var error)); + Assert.StartsWith("Endpoint", error); + if (!string.IsNullOrEmpty(endpoint)) + { + Assert.DoesNotContain(endpoint, error); + } + } + + [Fact] + public void Errors_never_echo_the_input() + { + const string bogus = "ZZZZ*bogus-key-material"; + Assert.False(WebPushSubscription.TryParse(Endpoint, bogus, bogus, out _, out var error)); + Assert.DoesNotContain(bogus, error); + } +} diff --git a/src/Push/AutopilotMonitor.Push/AutopilotMonitor.Push.csproj b/src/Push/AutopilotMonitor.Push/AutopilotMonitor.Push.csproj new file mode 100644 index 000000000..af50e38ae --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/AutopilotMonitor.Push.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + true + + + + + + + diff --git a/src/Push/AutopilotMonitor.Push/P256.cs b/src/Push/AutopilotMonitor.Push/P256.cs new file mode 100644 index 000000000..a43ebaee3 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/P256.cs @@ -0,0 +1,46 @@ +using System.Numerics; + +namespace AutopilotMonitor.Push; + +/// +/// The NIST P-256 curve equation, checked in arithmetic so an off-curve point from an anonymous +/// pairing body is refused on every platform: CNG imports such a point without complaint and +/// only OpenSSL rejects it, so the platform's import is not a usable gate. +/// +internal static class P256 +{ + /// Field order p = 2^256 - 2^224 + 2^192 + 2^96 - 1 (FIPS 186-4, D.1.2.3). + internal static readonly BigInteger Prime = + (BigInteger.One << 256) - (BigInteger.One << 224) + (BigInteger.One << 192) + (BigInteger.One << 96) - BigInteger.One; + + /// Curve coefficient b (FIPS 186-4, D.1.2.3); a = -3. + internal static readonly BigInteger B = new( + Convert.FromHexString("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B"), + isUnsigned: true, + isBigEndian: true); + + /// True when (x, y), given as 32-byte big-endian coordinates, satisfies y^2 = x^3 - 3x + b (mod p). + internal static bool IsOnCurve(ReadOnlySpan x, ReadOnlySpan y) + { + if (x.Length != 32 || y.Length != 32) + { + return false; + } + + var xi = new BigInteger(x, isUnsigned: true, isBigEndian: true); + var yi = new BigInteger(y, isUnsigned: true, isBigEndian: true); + if (xi >= Prime || yi >= Prime) + { + return false; + } + + var left = BigInteger.Remainder(yi * yi, Prime); + var right = BigInteger.Remainder(xi * xi * xi - 3 * xi + B, Prime); + if (right.Sign < 0) + { + right += Prime; + } + + return left == right; + } +} diff --git a/src/Push/AutopilotMonitor.Push/PushEndpointPolicy.cs b/src/Push/AutopilotMonitor.Push/PushEndpointPolicy.cs new file mode 100644 index 000000000..1bf6ee8b6 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/PushEndpointPolicy.cs @@ -0,0 +1,75 @@ +namespace AutopilotMonitor.Push; + +/// +/// Decides which push resource URLs the relay will ever send to. Applied when a device is +/// paired and when it re-subscribes; the SSRF connect gate is the second, independent layer. +/// +public static class PushEndpointPolicy +{ + /// Longest accepted absolute URL. + public const int MaxEndpointLength = 2048; + + /// + /// Known push services. An entry starting with a dot matches any host below that domain + /// (never the bare domain); an entry without a leading dot matches that host exactly. + /// + public static IReadOnlyList AllowedHostSuffixes { get; } = new[] + { + ".push.apple.com", + "fcm.googleapis.com", + "updates.push.services.mozilla.com", + ".notify.windows.com", + }; + + /// Returns null when the endpoint is acceptable, otherwise a generic reason that never echoes the URL. + public static string? Validate(Uri endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + if (!endpoint.IsAbsoluteUri) + { + return "Endpoint must be an absolute URL."; + } + + if (endpoint.AbsoluteUri.Length > MaxEndpointLength) + { + return "Endpoint is too long."; + } + + if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return "Endpoint must use https."; + } + + if (endpoint.UserInfo.Length > 0) + { + return "Endpoint must not contain credentials."; + } + + if (endpoint.HostNameType != UriHostNameType.Dns || endpoint.IsLoopback) + { + return "Endpoint host must be a public DNS name."; + } + + return IsAllowedHost(endpoint.Host) ? null : "Endpoint is not a known push service."; + } + + private static bool IsAllowedHost(string host) + { + foreach (var entry in AllowedHostSuffixes) + { + if (entry[0] == '.') + { + if (host.Length > entry.Length && host.EndsWith(entry, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + else if (string.Equals(host, entry, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Push/AutopilotMonitor.Push/PushSendOptions.cs b/src/Push/AutopilotMonitor.Push/PushSendOptions.cs new file mode 100644 index 000000000..776471a68 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/PushSendOptions.cs @@ -0,0 +1,86 @@ +namespace AutopilotMonitor.Push; + +/// RFC 8030 Urgency header values. +public enum PushUrgency +{ + /// Deliver on power and Wi-Fi only. + VeryLow, + + /// Deliver on power or Wi-Fi. + Low, + + /// Default delivery. + Normal, + + /// Deliver immediately. + High, +} + +/// Per-message delivery options: TTL (mandatory and positive), urgency and an optional replacement topic. +public sealed record PushSendOptions +{ + /// Longest accepted topic (RFC 8030 section 5.4). + public const int MaxTopicLength = 32; + + /// Validates and captures the options; throws on an invalid value. + public PushSendOptions(TimeSpan ttl, PushUrgency urgency = PushUrgency.Normal, string? topic = null) + { + if (ttl <= TimeSpan.Zero) + { + throw new ArgumentException("The TTL must be positive; push services reject zero.", nameof(ttl)); + } + + if (!Enum.IsDefined(urgency)) + { + throw new ArgumentException("Unknown urgency.", nameof(urgency)); + } + + if (topic is not null && !IsValidTopic(topic)) + { + throw new ArgumentException($"The topic must be 1 to {MaxTopicLength} base64url characters.", nameof(topic)); + } + + Ttl = ttl; + Urgency = urgency; + Topic = topic; + } + + /// How long the push service keeps the message for an offline device. + public TimeSpan Ttl { get; } + + /// Delivery urgency. + public PushUrgency Urgency { get; } + + /// Replacement topic: a later message with the same topic replaces an undelivered one. + public string? Topic { get; } + + /// The TTL in whole seconds as sent on the wire, rounded up and never below 1. + public long TtlSeconds => Math.Max(1, (long)Math.Ceiling(Ttl.TotalSeconds)); + + /// The Urgency header value. + public string UrgencyHeaderValue => Urgency switch + { + PushUrgency.VeryLow => "very-low", + PushUrgency.Low => "low", + PushUrgency.High => "high", + _ => "normal", + }; + + private static bool IsValidTopic(string topic) + { + if (topic.Length is 0 or > MaxTopicLength) + { + return false; + } + + foreach (var c in topic) + { + if (!(char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_')) + { + return false; + } + } + + return true; + } +} diff --git a/src/Push/AutopilotMonitor.Push/PushSendResult.cs b/src/Push/AutopilotMonitor.Push/PushSendResult.cs new file mode 100644 index 000000000..c96a034b8 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/PushSendResult.cs @@ -0,0 +1,30 @@ +namespace AutopilotMonitor.Push; + +/// How a push service answered one delivery attempt, reduced to what the caller must act on. +public enum PushOutcome +{ + /// 2xx: the push service accepted the message. + Delivered, + + /// 404 or 410: the subscription no longer exists; the device must re-subscribe. + Gone, + + /// 413, or a body above 4096 bytes refused before sending: a bug in the caller's payload budget. + PayloadTooLarge, + + /// 429: rate limited; the caller may try again after . + RetryLater, + + /// 400, 401 or 403: the request or the VAPID credentials are wrong; never retry. + ConfigError, + + /// Any other status, a network failure after the single retry, or a refused connection. + Failed, +} + +/// Result of . +/// The classification. +/// The last HTTP status, when a response was received. +/// The push service's Retry-After, when it sent one. +/// Up to 200 characters of the response body with control characters removed, or a fixed token for local failures. +public sealed record PushSendResult(PushOutcome Outcome, int? StatusCode, TimeSpan? RetryAfter, string? Reason); diff --git a/src/Push/AutopilotMonitor.Push/VapidAuthorization.cs b/src/Push/AutopilotMonitor.Push/VapidAuthorization.cs new file mode 100644 index 000000000..678484ee0 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/VapidAuthorization.cs @@ -0,0 +1,103 @@ +using System.Buffers.Text; +using System.Collections.Concurrent; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace AutopilotMonitor.Push; + +/// +/// Builds the RFC 8292 Authorization: vapid t=<jwt>, k=<public key> header and caches +/// the signed token per (push-service origin, key id). A token lives 24 hours and is reused +/// while more than 12 hours remain, so a cache entry is re-minted at most every 12 hours — well +/// inside Apple's "do not refresh more than hourly". Thread-safe; one instance per key ring. +/// +public sealed class VapidAuthorization +{ + /// Lifetime written into the exp claim; RFC 8292 caps it at 24 hours. + public static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24); + + /// A cached token is reused while at least this much of its lifetime remains. + public static readonly TimeSpan MinimumRemainingLifetime = TimeSpan.FromHours(12); + + private const string HeaderSegment = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9"; // base64url({"typ":"JWT","alg":"ES256"}) + + private static readonly JsonSerializerOptions ClaimsJson = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private readonly ConcurrentDictionary<(string Audience, string Kid), CachedToken> _cache = new(); + private readonly object _mintLock = new(); + + /// + /// Returns the header value for a push resource. must be a + /// mailto: or https:// URI; is the request time (injected + /// so the cache is testable). + /// + public string BuildHeaderValue(Uri endpoint, VapidKey key, string subject, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(subject); + if (!endpoint.IsAbsoluteUri) + { + throw new ArgumentException("The push endpoint must be an absolute URI.", nameof(endpoint)); + } + + if (!subject.StartsWith("mailto:", StringComparison.Ordinal) && !subject.StartsWith("https://", StringComparison.Ordinal)) + { + throw new ArgumentException("The VAPID subject must be a mailto: or https:// URI.", nameof(subject)); + } + + var audience = Origin(endpoint); + var cacheKey = (audience, key.Kid); + if (_cache.TryGetValue(cacheKey, out var cached) && cached.Expires - now >= MinimumRemainingLifetime) + { + return cached.HeaderValue; + } + + lock (_mintLock) + { + if (_cache.TryGetValue(cacheKey, out cached) && cached.Expires - now >= MinimumRemainingLifetime) + { + return cached.HeaderValue; + } + + var expires = now + TokenLifetime; + var token = Mint(audience, key, subject, expires); + var entry = new CachedToken($"vapid t={token}, k={key.PublicKeyBase64Url}", expires); + _cache[cacheKey] = entry; + Prune(now); + return entry.HeaderValue; + } + } + + /// The RFC 6454 origin of a push resource: scheme and host, plus the port when it is not the default. + internal static string Origin(Uri endpoint) => + endpoint.GetComponents(UriComponents.SchemeAndServer, UriFormat.UriEscaped); + + private static string Mint(string audience, VapidKey key, string subject, DateTimeOffset expires) + { + var claims = JsonSerializer.SerializeToUtf8Bytes( + new VapidClaims(audience, expires.ToUnixTimeSeconds(), subject), ClaimsJson); + var signingInput = HeaderSegment + "." + Base64Url.EncodeToString(claims); + var signature = key.SignEs256(Encoding.ASCII.GetBytes(signingInput)); + return signingInput + "." + Base64Url.EncodeToString(signature); + } + + private void Prune(DateTimeOffset now) + { + foreach (var pair in _cache) + { + if (pair.Value.Expires <= now) + { + _cache.TryRemove(pair.Key, out _); + } + } + } + + private sealed record CachedToken(string HeaderValue, DateTimeOffset Expires); + + private sealed record VapidClaims(string aud, long exp, string sub); +} diff --git a/src/Push/AutopilotMonitor.Push/VapidKey.cs b/src/Push/AutopilotMonitor.Push/VapidKey.cs new file mode 100644 index 000000000..1d33e85eb --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/VapidKey.cs @@ -0,0 +1,193 @@ +using System.Buffers.Text; +using System.Security.Cryptography; + +namespace AutopilotMonitor.Push; + +/// +/// A VAPID (RFC 8292) signing key: a P-256 private scalar together with its public point. +/// The stored form is base64url(d || X || Y), 96 bytes. The public point travels with the +/// scalar because .NET offers no scalar multiplication to recompute it, and a key whose +/// point does not belong to its scalar is rejected on parse (the push service would reject +/// every signature made with it). +/// +public sealed class VapidKey : IDisposable +{ + /// Length in bytes of the stored form: 32-byte scalar plus the two 32-byte coordinates. + public const int ExportLength = 96; + + /// Length in bytes of the uncompressed public point (0x04 || X || Y). + public const int PublicKeyLength = 65; + + /// Number of base64url characters of the public-key hash that form the key identifier. + public const int KidLength = 8; + + private const int CoordinateLength = 32; + private static readonly byte[] ProbeMessage = "vapid-key-probe"u8.ToArray(); + + private readonly ECDsa _ecdsa; + private readonly byte[] _export; + private readonly byte[] _publicKey; + private readonly object _signLock = new(); + private bool _disposed; + + private VapidKey(ECDsa ecdsa, byte[] export, byte[] publicKey) + { + _ecdsa = ecdsa; + _export = export; + _publicKey = publicKey; + PublicKeyBase64Url = Base64Url.EncodeToString(publicKey); + Kid = Base64Url.EncodeToString(SHA256.HashData(publicKey))[..KidLength]; + } + + /// The uncompressed public point, 65 bytes starting with 0x04. + public ReadOnlyMemory PublicKeyBytes => _publicKey; + + /// The public point in base64url, the value of the k parameter of the Authorization header. + public string PublicKeyBase64Url { get; } + + /// Key identifier: the first eight base64url characters of SHA-256 over the public point. + public string Kid { get; } + + /// Creates a fresh random P-256 key. + public static VapidKey Generate() + { + using var generated = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var parameters = generated.ExportParameters(true); + var export = new byte[ExportLength]; + LeftPad(parameters.D!, export.AsSpan(0, CoordinateLength)); + LeftPad(parameters.Q.X!, export.AsSpan(CoordinateLength, CoordinateLength)); + LeftPad(parameters.Q.Y!, export.AsSpan(2 * CoordinateLength, CoordinateLength)); + return FromExport(export); + } + + /// + /// Parses the stored form produced by . + /// Throws on malformed input; the message never contains the input. + /// + public static VapidKey Parse(string base64Url) + { + ArgumentNullException.ThrowIfNull(base64Url); + byte[] raw; + try + { + raw = Base64Url.DecodeFromChars(base64Url.AsSpan()); + } + catch (FormatException) + { + throw new ArgumentException("The VAPID key is not valid base64url.", nameof(base64Url)); + } + + if (raw.Length != ExportLength) + { + CryptographicOperations.ZeroMemory(raw); + throw new ArgumentException( + $"The VAPID key must decode to {ExportLength} bytes (private scalar followed by the uncompressed public point).", + nameof(base64Url)); + } + + return FromExport(raw); + } + + /// The stored form: base64url(d || X || Y). Contains the private key. + public string ExportBase64Url() + { + ThrowIfDisposed(); + return Base64Url.EncodeToString(_export); + } + + /// Signs with ES256 and returns the 64-byte r || s signature (JWS form). + public byte[] SignEs256(byte[] data) + { + ArgumentNullException.ThrowIfNull(data); + ThrowIfDisposed(); + lock (_signLock) + { + return _ecdsa.SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + CryptographicOperations.ZeroMemory(_export); + _ecdsa.Dispose(); + } + + private static VapidKey FromExport(byte[] export) + { + var parameters = new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + D = export[..CoordinateLength], + Q = new ECPoint + { + X = export[CoordinateLength..(2 * CoordinateLength)], + Y = export[(2 * CoordinateLength)..], + }, + }; + + ECDsa ecdsa; + try + { + parameters.Validate(); + ecdsa = ECDsa.Create(parameters); + } + catch (CryptographicException) + { + CryptographicOperations.ZeroMemory(export); + throw new ArgumentException("The VAPID key is not a valid P-256 key pair."); + } + finally + { + CryptographicOperations.ZeroMemory(parameters.D); + } + + // A point that does not belong to the scalar imports cleanly on some platforms and only + // fails at the push service (VapidPkHashMismatch); the probe surfaces it at parse time. + bool consistent; + try + { + var probe = ecdsa.SignData(ProbeMessage, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + consistent = ecdsa.VerifyData(ProbeMessage, probe, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + catch (CryptographicException) + { + consistent = false; + } + + if (!consistent) + { + ecdsa.Dispose(); + CryptographicOperations.ZeroMemory(export); + throw new ArgumentException("The VAPID public point does not belong to the private scalar."); + } + + var publicKey = new byte[PublicKeyLength]; + publicKey[0] = 0x04; + export.AsSpan(CoordinateLength).CopyTo(publicKey.AsSpan(1)); + return new VapidKey(ecdsa, export, publicKey); + } + + /// + /// Writes a big-endian field element into a fixed 32-byte slot. Exports may drop leading + /// zero bytes on some platforms; the wire forms (JWK-style point, JWS signature) never do. + /// + internal static void LeftPad(ReadOnlySpan value, Span destination) + { + if (value.Length > destination.Length) + { + throw new ArgumentException("The field element is longer than the curve's field size."); + } + + destination[..(destination.Length - value.Length)].Clear(); + value.CopyTo(destination[(destination.Length - value.Length)..]); + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); +} diff --git a/src/Push/AutopilotMonitor.Push/VapidKeyRing.cs b/src/Push/AutopilotMonitor.Push/VapidKeyRing.cs new file mode 100644 index 000000000..0bc80714d --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/VapidKeyRing.cs @@ -0,0 +1,112 @@ +namespace AutopilotMonitor.Push; + +/// +/// The active VAPID key plus the retired keys that older subscriptions were created with. +/// A subscription is signed with the key it was created with (RFC 8292 section 4.2), so a +/// rotation keeps the old key here until no subscription names its . +/// +public sealed class VapidKeyRing : IDisposable +{ + private const char RetiredSeparator = ';'; + + /// Creates a ring; the ring owns the keys and disposes them. + public VapidKeyRing(VapidKey active, IReadOnlyList? retired = null) + { + ArgumentNullException.ThrowIfNull(active); + Active = active; + Retired = retired ?? Array.Empty(); + } + + /// The key new subscriptions are created with. + public VapidKey Active { get; } + + /// Keys that are no longer handed out but still sign for the subscriptions created with them. + public IReadOnlyList Retired { get; } + + /// + /// Parses the app-setting form: the active key, and optionally retired keys separated by + /// semicolons. Throws on malformed input; the message never + /// contains key material. + /// + public static VapidKeyRing FromSettings(string? activeBase64Url, string? retiredSemicolonSeparated = null) + { + if (string.IsNullOrWhiteSpace(activeBase64Url)) + { + throw new ArgumentException("The active VAPID key is not set.", nameof(activeBase64Url)); + } + + VapidKey active; + try + { + active = VapidKey.Parse(activeBase64Url.Trim()); + } + catch (ArgumentException ex) + { + throw new ArgumentException($"The active VAPID key is invalid: {ex.Message}", nameof(activeBase64Url)); + } + + var retired = new List(); + try + { + var entries = (retiredSemicolonSeparated ?? string.Empty) + .Split(RetiredSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + for (var i = 0; i < entries.Length; i++) + { + try + { + retired.Add(VapidKey.Parse(entries[i])); + } + catch (ArgumentException ex) + { + throw new ArgumentException($"Retired VAPID key #{i + 1} is invalid: {ex.Message}", nameof(retiredSemicolonSeparated)); + } + } + } + catch + { + active.Dispose(); + foreach (var key in retired) + { + key.Dispose(); + } + + throw; + } + + return new VapidKeyRing(active, retired); + } + + /// Finds the key with the given identifier, the active key first; null when no key matches. + public VapidKey? Resolve(string? kid) + { + if (string.IsNullOrEmpty(kid)) + { + return null; + } + + if (string.Equals(Active.Kid, kid, StringComparison.Ordinal)) + { + return Active; + } + + foreach (var key in Retired) + { + if (string.Equals(key.Kid, kid, StringComparison.Ordinal)) + { + return key; + } + } + + return null; + } + + /// + public void Dispose() + { + Active.Dispose(); + foreach (var key in Retired) + { + key.Dispose(); + } + } +} diff --git a/src/Push/AutopilotMonitor.Push/WebPushEncryptor.cs b/src/Push/AutopilotMonitor.Push/WebPushEncryptor.cs new file mode 100644 index 000000000..c1c4e8e7d --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/WebPushEncryptor.cs @@ -0,0 +1,120 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace AutopilotMonitor.Push; + +/// +/// RFC 8291 message encryption: one aes128gcm (RFC 8188) record with a fresh salt and a +/// fresh ephemeral sender key per message. The output is the complete request body: +/// 86-byte header, ciphertext and 16-byte tag. +/// +public static class WebPushEncryptor +{ + /// Record size written into the header; the single record must fit into it. + public const int RecordSize = 4096; + + /// Header length: 16-byte salt, 4-byte record size, 1-byte key id length, 65-byte sender key. + public const int HeaderLength = 16 + 4 + 1 + VapidKey.PublicKeyLength; + + /// Tag length of AES-128-GCM. + public const int TagLength = 16; + + /// Longest plaintext that fits a 4096-byte body: 4096 - 86 (header) - 1 (padding delimiter) - 16 (tag). + public const int MaxPlaintextLength = RecordSize - HeaderLength - 1 - TagLength; + + private const int SaltLength = 16; + private const byte LastRecordDelimiter = 0x02; + + private static readonly byte[] KeyInfoPrefix = "WebPush: info\0"u8.ToArray(); + private static readonly byte[] CekInfo = "Content-Encoding: aes128gcm\0"u8.ToArray(); + private static readonly byte[] NonceInfo = "Content-Encoding: nonce\0"u8.ToArray(); + + /// Encrypts for the subscription; at most bytes. + public static byte[] Encrypt(ReadOnlySpan plaintext, WebPushSubscription subscription) + { + ArgumentNullException.ThrowIfNull(subscription); + var salt = RandomNumberGenerator.GetBytes(SaltLength); + using var senderKey = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + return Encrypt(plaintext, subscription, salt, senderKey); + } + + internal static byte[] Encrypt(ReadOnlySpan plaintext, WebPushSubscription subscription, byte[] salt, ECDiffieHellman senderKey) + { + if (plaintext.Length > MaxPlaintextLength) + { + throw new ArgumentException($"The push payload must not exceed {MaxPlaintextLength} bytes.", nameof(plaintext)); + } + + var keys = DeriveKeys(subscription, salt, senderKey); + + var body = new byte[HeaderLength + plaintext.Length + 1 + TagLength]; + salt.CopyTo(body, 0); + BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(SaltLength, 4), RecordSize); + body[SaltLength + 4] = VapidKey.PublicKeyLength; + keys.SenderPublicKey.CopyTo(body, SaltLength + 5); + + var padded = new byte[plaintext.Length + 1]; + plaintext.CopyTo(padded); + padded[^1] = LastRecordDelimiter; + + using var aes = new AesGcm(keys.Cek, TagLength); + aes.Encrypt( + keys.Nonce, + padded, + body.AsSpan(HeaderLength, padded.Length), + body.AsSpan(HeaderLength + padded.Length, TagLength)); + return body; + } + + /// Runs the RFC 8291 section 3.4 derivation and returns every intermediate for verification. + internal static WebPushKeyMaterial DeriveKeys(WebPushSubscription subscription, byte[] salt, ECDiffieHellman senderKey) + { + if (salt.Length != SaltLength) + { + throw new ArgumentException("The salt must be 16 bytes.", nameof(salt)); + } + + var senderPublic = UncompressedPoint(senderKey); + var uaPublic = subscription.P256dh.ToArray(); + var auth = subscription.Auth.ToArray(); + + byte[] ecdhSecret; + using (var uaKey = subscription.CreateUserAgentKey()) + { + ecdhSecret = senderKey.DeriveRawSecretAgreement(uaKey.PublicKey); + } + + var prkKey = HKDF.Extract(HashAlgorithmName.SHA256, ecdhSecret, auth); + var keyInfo = new byte[KeyInfoPrefix.Length + uaPublic.Length + senderPublic.Length]; + KeyInfoPrefix.CopyTo(keyInfo, 0); + uaPublic.CopyTo(keyInfo, KeyInfoPrefix.Length); + senderPublic.CopyTo(keyInfo, KeyInfoPrefix.Length + uaPublic.Length); + var ikm = HKDF.Expand(HashAlgorithmName.SHA256, prkKey, 32, keyInfo); + var prk = HKDF.Extract(HashAlgorithmName.SHA256, ikm, salt); + var cek = HKDF.Expand(HashAlgorithmName.SHA256, prk, 16, CekInfo); + var nonce = HKDF.Expand(HashAlgorithmName.SHA256, prk, 12, NonceInfo); + + return new WebPushKeyMaterial(senderPublic, ecdhSecret, prkKey, keyInfo, ikm, prk, cek, nonce); + } + + private static byte[] UncompressedPoint(ECDiffieHellman key) + { + var parameters = key.ExportParameters(false); + var point = new byte[VapidKey.PublicKeyLength]; + point[0] = 0x04; + VapidKey.LeftPad(parameters.Q.X!, point.AsSpan(1, 32)); + VapidKey.LeftPad(parameters.Q.Y!, point.AsSpan(33, 32)); + return point; + } +} + +/// The intermediates of the RFC 8291 derivation, in the order the RFC lists them. +internal readonly record struct WebPushKeyMaterial( + byte[] SenderPublicKey, + byte[] EcdhSecret, + byte[] PrkKey, + byte[] KeyInfo, + byte[] Ikm, + byte[] Prk, + byte[] Cek, + byte[] Nonce); diff --git a/src/Push/AutopilotMonitor.Push/WebPushSender.cs b/src/Push/AutopilotMonitor.Push/WebPushSender.cs new file mode 100644 index 000000000..e4a2b03a6 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/WebPushSender.cs @@ -0,0 +1,228 @@ +using System.Net; +using System.Text; + +namespace AutopilotMonitor.Push; + +/// +/// Posts an encrypted body to a push resource (RFC 8030) with VAPID authorization and classifies +/// the answer. One in-process retry for 5xx and network failures, none for anything else; the +/// caller's brings the connect gate and timeouts. Nothing here logs, +/// and no exception or ever carries the endpoint. +/// +public sealed class WebPushSender +{ + /// Largest body a push service must accept (RFC 8030 section 7.2); larger bodies are refused before sending. + public const int MaxBodyLength = 4096; + + /// Longest Retry-After honoured for the in-process retry; longer values fall back to . + public static readonly TimeSpan MaxHonouredRetryAfter = TimeSpan.FromSeconds(30); + + /// Delay before the single retry when the response named none. + public static readonly TimeSpan DefaultRetryDelay = TimeSpan.FromSeconds(2); + + /// Reason reported when the connect gate refused the destination. + public const string SsrfRefusedReason = "ssrf_refused"; + + private const int MaxReasonLength = 200; + private const int MaxReasonBytesRead = 1024; + private const string SsrfExceptionTypeName = "SsrfException"; + + private readonly HttpClient _httpClient; + private readonly VapidAuthorization _authorization; + private readonly TimeProvider _timeProvider; + + /// + /// Creates a sender over an the host configured (connect gate, no + /// redirects, timeout). defaults to a fresh token cache; + /// drives token expiry and the retry delay. + /// + public WebPushSender(HttpClient httpClient, VapidAuthorization? authorization = null, TimeProvider? timeProvider = null) + { + ArgumentNullException.ThrowIfNull(httpClient); + _httpClient = httpClient; + _authorization = authorization ?? new VapidAuthorization(); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + /// Sends one aes128gcm body to the subscription, signed with + /// (the key the subscription was created with) and . + /// + public async Task SendAsync( + WebPushSubscription subscription, + byte[] aes128gcmBody, + PushSendOptions options, + VapidKey key, + string subject, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(subscription); + ArgumentNullException.ThrowIfNull(aes128gcmBody); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(subject); + + if (aes128gcmBody.Length > MaxBodyLength) + { + return new PushSendResult(PushOutcome.PayloadTooLarge, null, null, "payload_exceeds_4096"); + } + + for (var attempt = 1; ; attempt++) + { + var isLastAttempt = attempt >= 2; + TimeSpan? retryDelay; + PushSendResult result; + try + { + using var request = BuildRequest(subscription, aes128gcmBody, options, key, subject); + using var response = await _httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + result = await ClassifyAsync(response, cancellationToken).ConfigureAwait(false); + if ((int)response.StatusCode < 500 || isLastAttempt) + { + return result; + } + + retryDelay = result.RetryAfter is { } retryAfter && retryAfter <= MaxHonouredRetryAfter && retryAfter >= TimeSpan.Zero + ? retryAfter + : DefaultRetryDelay; + } + catch (HttpRequestException ex) when (IsSsrfRefusal(ex)) + { + return new PushSendResult(PushOutcome.Failed, null, null, SsrfRefusedReason); + } + catch (HttpRequestException ex) + { + if (isLastAttempt) + { + return new PushSendResult(PushOutcome.Failed, null, null, $"network_error:{ex.HttpRequestError}"); + } + + retryDelay = DefaultRetryDelay; + } + catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) + { + if (isLastAttempt) + { + return new PushSendResult(PushOutcome.Failed, null, null, "timeout"); + } + + retryDelay = DefaultRetryDelay; + } + + await Task.Delay(retryDelay.Value, _timeProvider, cancellationToken).ConfigureAwait(false); + } + } + + private HttpRequestMessage BuildRequest(WebPushSubscription subscription, byte[] body, PushSendOptions options, VapidKey key, string subject) + { + var request = new HttpRequestMessage(HttpMethod.Post, subscription.Endpoint) + { + Content = new ByteArrayContent(body), + }; + request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + request.Content.Headers.ContentEncoding.Add("aes128gcm"); + request.Headers.TryAddWithoutValidation("TTL", options.TtlSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)); + request.Headers.TryAddWithoutValidation("Urgency", options.UrgencyHeaderValue); + if (options.Topic is not null) + { + request.Headers.TryAddWithoutValidation("Topic", options.Topic); + } + + var authorization = _authorization.BuildHeaderValue(subscription.Endpoint, key, subject, _timeProvider.GetUtcNow()); + request.Headers.TryAddWithoutValidation("Authorization", authorization); + return request; + } + + private async Task ClassifyAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var status = (int)response.StatusCode; + var retryAfter = ReadRetryAfter(response); + var reason = await ReadReasonAsync(response, cancellationToken).ConfigureAwait(false); + var outcome = status switch + { + >= 200 and < 300 => PushOutcome.Delivered, + 404 or 410 => PushOutcome.Gone, + 413 => PushOutcome.PayloadTooLarge, + 429 => PushOutcome.RetryLater, + 400 or 401 or 403 => PushOutcome.ConfigError, + _ => PushOutcome.Failed, + }; + return new PushSendResult(outcome, status, retryAfter, reason); + } + + private TimeSpan? ReadRetryAfter(HttpResponseMessage response) + { + var header = response.Headers.RetryAfter; + if (header is null) + { + return null; + } + + if (header.Delta is { } delta) + { + return delta; + } + + if (header.Date is { } date) + { + var remaining = date - _timeProvider.GetUtcNow(); + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + + return null; + } + + private static async Task ReadReasonAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.Content is null) + { + return null; + } + + var buffer = new byte[MaxReasonBytesRead]; + var read = 0; + await using (var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) + { + while (read < buffer.Length) + { + var n = await stream.ReadAsync(buffer.AsMemory(read), cancellationToken).ConfigureAwait(false); + if (n == 0) + { + break; + } + + read += n; + } + } + + if (read == 0) + { + return null; + } + + var text = Encoding.UTF8.GetString(buffer, 0, read); + var builder = new StringBuilder(Math.Min(text.Length, MaxReasonLength)); + foreach (var c in text) + { + if (char.IsControl(c)) + { + continue; + } + + builder.Append(c); + if (builder.Length == MaxReasonLength) + { + break; + } + } + + var reason = builder.ToString().Trim(); + return reason.Length == 0 ? null : reason; + } + + // The SSRF gate lives in the host; matched by name so this library stays dependency-free. + private static bool IsSsrfRefusal(HttpRequestException ex) => + ex.InnerException is { } inner && string.Equals(inner.GetType().Name, SsrfExceptionTypeName, StringComparison.Ordinal); +} diff --git a/src/Push/AutopilotMonitor.Push/WebPushSubscription.cs b/src/Push/AutopilotMonitor.Push/WebPushSubscription.cs new file mode 100644 index 000000000..883410913 --- /dev/null +++ b/src/Push/AutopilotMonitor.Push/WebPushSubscription.cs @@ -0,0 +1,119 @@ +using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; + +namespace AutopilotMonitor.Push; + +/// +/// A browser push subscription as handed over by PushManager.subscribe(): the push +/// resource URL, the user agent's P-256 public key and the 16-byte authentication secret. +/// +public sealed record WebPushSubscription +{ + /// Length of the uncompressed user-agent public point. + public const int P256dhLength = 65; + + /// Length of the authentication secret (RFC 8291 section 3.2). + public const int AuthLength = 16; + + internal WebPushSubscription(Uri endpoint, byte[] p256dh, byte[] auth) + { + Endpoint = endpoint; + P256dh = p256dh; + Auth = auth; + } + + /// The push resource URL. It is a credential: never log it or put it into a message. + public Uri Endpoint { get; } + + /// The user agent's public point, 65 bytes starting with 0x04. + public ReadOnlyMemory P256dh { get; } + + /// The 16-byte authentication secret. + public ReadOnlyMemory Auth { get; } + + /// + /// Parses the wire form (endpoint URL, base64url keys). The endpoint must pass + /// . is a generic, user-facing + /// sentence that never echoes the input. + /// + public static bool TryParse( + string? endpoint, + string? p256dhBase64Url, + string? authBase64Url, + [NotNullWhen(true)] out WebPushSubscription? subscription, + [NotNullWhen(false)] out string? error) + { + subscription = null; + + if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + { + error = "Endpoint must be an absolute URL."; + return false; + } + + error = PushEndpointPolicy.Validate(uri); + if (error is not null) + { + return false; + } + + if (!TryDecode(p256dhBase64Url, out var p256dh)) + { + error = "The p256dh key is not valid base64url."; + return false; + } + + if (p256dh.Length != P256dhLength || p256dh[0] != 0x04 || !P256.IsOnCurve(p256dh.AsSpan(1, 32), p256dh.AsSpan(33, 32))) + { + error = "The p256dh key must be a 65-byte uncompressed P-256 point."; + return false; + } + + if (!TryDecode(authBase64Url, out var auth)) + { + error = "The auth secret is not valid base64url."; + return false; + } + + if (auth.Length != AuthLength) + { + error = "The auth secret must be 16 bytes."; + return false; + } + + subscription = new WebPushSubscription(uri, p256dh, auth); + error = null; + return true; + } + + /// The user agent's public key as an ECDH public key; the caller disposes it. + internal ECDiffieHellman CreateUserAgentKey() + { + var point = P256dh.Span; + return ECDiffieHellman.Create(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint { X = point[1..33].ToArray(), Y = point[33..].ToArray() }, + }); + } + + private static bool TryDecode(string? value, [NotNullWhen(true)] out byte[]? bytes) + { + bytes = null; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + try + { + bytes = Base64Url.DecodeFromChars(value.AsSpan()); + return true; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index fdc9ac3f3..1752604ea 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -119,9 +119,79 @@ public static class Constants public static string PortalSessionUrl(string sessionId) => $"{PortalBaseUrl}/sessions?id={System.Uri.EscapeDataString(sessionId ?? string.Empty)}"; + /// + /// The pairing link shown as QR code and typed into the receiver app. The code travels in the + /// fragment (never reaches a server log); the trailing slash matches the static export. + /// + public static string PortalPushPairUrl(string code) => + $"{PortalBaseUrl}/push/pair/#p={System.Uri.EscapeDataString(code ?? string.Empty)}"; + + /// Deep link into the receiver app's history (the notification's navigate target). + public static string PortalPushHistoryUrl(string entryId) => + $"{PortalBaseUrl}/push/#e/{System.Uri.EscapeDataString(entryId ?? string.Empty)}"; + /// Public marketing/product website. public const string WebsiteBaseUrl = "https://www.autopilotmonitor.com"; + /// + /// Web Push channel (plan push-relay): the wire-visible shapes the receiver app and the + /// backend agree on. Everything else about push lives in the backend. + /// + public static class Push + { + /// Header carrying the device token on the receiver's own routes — never a query or path segment (both land in telemetry). + public const string DeviceTokenHeader = "X-Push-Device-Token"; + + /// Scope key of the platform (operator) partition in PushDevices/PushSessionWatches; a tenant scope uses its GUID. + public const string PlatformScope = "platform"; + + /// + /// Pairing codes: 11 Crockford-base32 characters = 55 bits. The alphabet has no I/L/O/U, so a + /// code is typeable without ambiguity and can never contain the substrings MSAL, + /// the auth hint and the host guard react to in a URL fragment ("code=", "state=", "error"). + /// + public const string CodeAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + public const int CodeLength = 11; + + /// A grant may be redeemed within this window; a redeemed-but-unconfirmed device expires with it. + public const int PairingGrantMinutes = 10; + + /// Failed redeem attempts after which a code is burnt (counted on the grant row). + public const int MaxRedeemFailures = 5; + + /// Caps on the free text the anonymous redeem body may carry. + public const int MaxLabelLength = 40; + public const int MaxAppVersionLength = 32; + + /// Closed list of receiver platforms the receiver reports; anything else is stored as "other". + public static readonly string[] Platforms = + { + "ios-homescreen", "android-chrome", "windows-chromium", "macos-safari", "firefox", "other", + }; + + /// + /// Device states. Only Active devices receive alerts. A revoked device has no state: the + /// wipe push goes out and the row is deleted. + /// + public static class DeviceStatus + { + public const string Pending = "Pending"; + public const string Active = "Active"; + public const string Paused = "Paused"; + public const string Stale = "Stale"; + } + + /// Pairing grant states as the PC-side poll reports them. + public static class PairingStatus + { + public const string Pending = "Pending"; + public const string Redeemed = "Redeemed"; + public const string Confirmed = "Confirmed"; + public const string Rejected = "Rejected"; + public const string Expired = "Expired"; + } + } + /// Published customer documentation. public const string DocsBaseUrl = "https://docs.autopilotmonitor.com"; @@ -315,6 +385,22 @@ public static class ApiErrorCodes // ── MCP quota (McpQuotaEnforcementMiddleware) ── public const string QuotaExceeded = "QuotaExceeded"; + // ── Web Push pairing + devices (Functions/Push) ── + /// 404 on the anonymous redeem routes: unknown, expired AND already-used codes share this one answer. + public const string PairingCodeInvalid = "PairingCodeInvalid"; + /// 409: a concurrent redeem of the same code won the one-shot write. + public const string PairingCodeUsed = "PairingCodeUsed"; + /// 400: endpoint not a known push service, or keys of the wrong shape. + public const string InvalidSubscription = "InvalidSubscription"; + /// 401: the device token does not match a device row. + public const string InvalidDeviceToken = "InvalidDeviceToken"; + /// 404: the device row is gone (revoked, expired, offboarded) — the receiver must re-pair. + public const string DeviceNotFound = "DeviceNotFound"; + /// 409: pairing or watching needs an enabled Push channel in this scope first. + public const string PushChannelRequired = "PushChannelRequired"; + /// 403: the caller holds no table-backed Admin/Operator (tenant) or GlobalAdmin (platform) role. + public const string PushNotEligible = "PushNotEligible"; + /// 413: an upload or download exceeds the configured size cap. public const string PayloadTooLarge = "PayloadTooLarge"; @@ -1581,6 +1667,27 @@ public static class TableNames // NOT in the critical-backup set. public const string SessionTenantLookup = "SessionTenantLookup"; + // Web Push channel (plan push-relay). PushDevices and PushPairingGrants are credentials + // (CredentialBearing: denied on the raw table surface, TableQueryFunction); none of the + // four is in the critical-table backup (re-pairing is the recovery path) and none + // carries anything the portal did not already show the owner. + // PushDevices PK = scope ("platform" or tenantId), RK = deviceId. One paired + // device: endpoint + keys (RFC 8291), owner (UPN/oid/home tenant), + // hashed device secret, VAPID kid, status, delivery + flood counters. + // PushOwners PK = owner's HOME tenant, RK = oid. LastSignInUtc stamped once per + // portal sign-in — the freshness source that retires devices of + // people who stopped signing in (roles are app-internal, never + // Entra-synchronized). Never pruned on its own. + // PushPairingGrants PK = "grant", RK = SHA-256(code). Ten-minute one-shot pairing + // codes; TenantId property for the offboarding property wipe; + // expired lazily on redeem and by the maintenance sweep. + // PushSessionWatches PK = tenantId, RK = "{sessionId}_{oid}". "Notify me when this + // session ends"; consumed at the terminal status. + public const string PushDevices = "PushDevices"; + public const string PushOwners = "PushOwners"; + public const string PushPairingGrants = "PushPairingGrants"; + public const string PushSessionWatches = "PushSessionWatches"; + /// /// Returns all table names for initialization /// @@ -1653,6 +1760,21 @@ public static class TableNames ConfigurationBackups, SessionAnnotations, SessionTenantLookup, + PushDevices, + PushOwners, + PushPairingGrants, + PushSessionWatches, + }; + + /// + /// Tables whose rows are credentials (push endpoints, key material, hashed secrets). + /// Denied on the raw table surface (GET global/raw/tables, MCP query_table) so a + /// Global Admin's AI assistant can never receive a send capability for someone's phone. + /// + public static readonly string[] CredentialBearing = new[] + { + PushDevices, + PushPairingGrants, }; } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IPushDeviceRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IPushDeviceRepository.cs new file mode 100644 index 000000000..01089ebdc --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IPushDeviceRepository.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for the Web Push channel: paired devices, pairing grants, owner sign-in stamps + /// and session watches (tables PushDevices, PushPairingGrants, PushOwners, + /// PushSessionWatches). Every row is a credential or binds one; the implementation + /// propagates storage exceptions — callers decide what is fail-soft. + /// + public interface IPushDeviceRepository + { + // --- Devices (PK = scope, RK = deviceId) --- + Task GetDeviceAsync(string scope, string deviceId); + Task> GetDevicesAsync(string scope); + /// Insert only (409 on an existing key surfaces as an exception). + Task AddDeviceAsync(PushDevice device); + /// Compare-and-swap merge: sees the current row and returns the changed properties, or null for "nothing to do". Returns true when a write landed. + Task MutateDeviceAsync(string scope, string deviceId, Func?> patch); + Task DeleteDeviceAsync(string scope, string deviceId); + + // --- Pairing grants (PK = "grant", RK = SHA-256(code), TenantId property = scope) --- + Task GetGrantAsync(string codeHash); + Task AddGrantAsync(PushPairingGrant grant); + /// + /// Conditional update on the ETag read with the grant: the one-shot redeem and the + /// confirm/reject transitions. Returns false when the row changed underneath (the loser). + /// + Task TryUpdateGrantAsync(PushPairingGrant grant); + Task DeleteGrantAsync(string codeHash); + /// Grants whose ExpiresUtc lies before (maintenance sweep). + Task> GetExpiredGrantsAsync(DateTime cutoffUtc); + + // --- Owners (PK = home tenant, RK = oid) --- + Task GetOwnerAsync(string homeTenantId, string objectId); + /// Upsert of the sign-in stamp — one write per portal sign-in. + Task StampOwnerSignInAsync(string homeTenantId, string objectId, string upn, DateTime signInUtc); + + // --- Session watches (PK = tenantId, RK = "{sessionId}_{oid}") --- + Task UpsertWatchAsync(PushSessionWatch watch); + Task DeleteWatchAsync(string tenantId, string sessionId, string objectId); + Task GetWatchAsync(string tenantId, string sessionId, string objectId); + /// Every watch on one session (the terminal-status fan-out), newest first. + Task> GetWatchesForSessionAsync(string tenantId, string sessionId); + + // --- Maintenance (cross-partition scans; never on a hot path) --- + /// Every device row of every scope — the pause/stale/expiry sweep. + Task> GetAllDevicesAsync(); + Task> GetExpiredWatchesAsync(DateTime cutoffUtc); + } + + /// One paired receiver. Endpoint, P256dh, Auth and DeviceSecretHash are credentials. + public class PushDevice + { + /// "platform" or the tenant id (lowercase). + public string Scope { get; set; } = string.Empty; + public string DeviceId { get; set; } = string.Empty; + public string OwnerUpn { get; set; } = string.Empty; + public string OwnerObjectId { get; set; } = string.Empty; + /// The owner's HOME tenant (JWT tid at pairing) — where the PushOwners stamp lives. + public string OwnerHomeTenantId { get; set; } = string.Empty; + /// "webpush" today; "apns" once the native app exists. + public string Kind { get; set; } = "webpush"; + public string Endpoint { get; set; } = string.Empty; + public string P256dh { get; set; } = string.Empty; + public string Auth { get; set; } = string.Empty; + /// Base64url(SHA-256(device secret)); the secret itself is never stored. + public string DeviceSecretHash { get; set; } = string.Empty; + public string VapidKid { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Platform { get; set; } = "other"; + public string AppVersion { get; set; } = string.Empty; + /// Constants.Push.DeviceStatus (Pending · Active · Paused · Stale). Revoked devices are deleted, never kept. + public string Status { get; set; } = string.Empty; + /// Why the device is Stale (gone · vapid_key_mismatch · invalid_subscription) or Paused (owner_inactive · delivery_failures). + public string? StatusReason { get; set; } + public DateTime PairedUtc { get; set; } + public DateTime? ConfirmedUtc { get; set; } + public DateTime? LastDeliveredUtc { get; set; } + public DateTime? LastOpenedUtc { get; set; } + public DateTime? StatusChangedUtc { get; set; } + public int? LastStatusCode { get; set; } + public int ConsecutiveFailures { get; set; } + /// Flood window (Korrektur K19): count of pushes since WindowStartUtc; SuppressedCount = dropped in that window. + public DateTime? WindowStartUtc { get; set; } + public int WindowCount { get; set; } + public int SuppressedCount { get; set; } + } + + /// A ten-minute one-shot pairing code, stored only as its hash. + public class PushPairingGrant + { + /// Base64url(SHA-256(code)) — the RowKey and the pairing id the PC polls with. + public string CodeHash { get; set; } = string.Empty; + /// Scope the device will belong to ("platform" or tenant id); the offboarding property wipe keys on it. + public string TenantId { get; set; } = string.Empty; + public string OwnerUpn { get; set; } = string.Empty; + public string OwnerObjectId { get; set; } = string.Empty; + public string OwnerHomeTenantId { get; set; } = string.Empty; + /// Constants.Push.PairingStatus. + public string Status { get; set; } = string.Empty; + public DateTime CreatedUtc { get; set; } + public DateTime ExpiresUtc { get; set; } + public DateTime? RedeemedUtc { get; set; } + /// The Pending device created on redeem. + public string? DeviceId { get; set; } + public int FailedAttempts { get; set; } + /// Storage concurrency token of the row as read; TryUpdateGrantAsync writes with it. + public string? ETag { get; set; } + } + + /// Last portal sign-in of one person — the push freshness source. + public class PushOwner + { + public string HomeTenantId { get; set; } = string.Empty; + public string ObjectId { get; set; } = string.Empty; + public string Upn { get; set; } = string.Empty; + public DateTime LastSignInUtc { get; set; } + } + + /// "Notify my devices when this session ends" — one row per (session, person), stored in the SESSION's tenant. + public class PushSessionWatch + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + public string OwnerObjectId { get; set; } = string.Empty; + public string OwnerUpn { get; set; } = string.Empty; + /// + /// Scope keys (';'-separated) whose devices of the watcher receive the push — the watcher's home + /// tenant and, for a Global Admin, "platform"; set at watch time from the scopes that held an + /// eligible, active device. Empty (legacy rows) means the session tenant. + /// + public string OwnerScopes { get; set; } = string.Empty; + public DateTime CreatedUtc { get; set; } + public DateTime ExpiresUtc { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs index 0547c00fe..f7a92a786 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs @@ -146,8 +146,21 @@ public static class OpsEventTypes public const string SlaConsecutiveFailures = "SlaConsecutiveFailures"; public const string SlaEvaluationCompleted = "SlaEvaluationCompleted"; - // ── Platform ── Platform infrastructure alerts relayed from Azure Monitor. + // ── Platform ── Platform infrastructure alerts relayed from Azure Monitor, push channel health. public const string AzureMonitorAlert = "AzureMonitorAlert"; + /// + /// The push sender got a configuration-class answer from a push service (400/401/403: + /// bad VAPID key, bad TTL) or a device's VAPID kid no longer matches the configured keys. + /// Error; at most one per hour per cause and instance (in-process cache). Details name + /// the service host, the status and the kid — never an endpoint. + /// + public const string PushDeliveryFailed = "PushDeliveryFailed"; + /// + /// A pairing or re-subscribe named an endpoint outside the push-service allow-list. Warning; + /// details carry the hostname only, so a new browser push service shows up without the + /// endpoint (a credential) leaving the backend. + /// + public const string PushEndpointRefused = "PushEndpointRefused"; /// Every declared type, declaration order (grouped by category). public static readonly IReadOnlyList All = new[] @@ -158,7 +171,7 @@ public static class OpsEventTypes OffboardingFeedbackReceived, FeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, FarewellEmailSent, FarewellEmailSkipped, FarewellEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled, SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable, TelemetryItemsRejected, SlaBreachNotification, SlaConsecutiveFailures, SlaEvaluationCompleted, - AzureMonitorAlert, + AzureMonitorAlert, PushDeliveryFailed, PushEndpointRefused, }; } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs index 6c0ae46e9..45c23acca 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs @@ -44,7 +44,8 @@ public class NotificationChannel /// /// Provider type (int form of ): /// 2=TeamsWorkflowWebhook, 10=Slack, 20=GenericJson, 30=Discord, - /// 40=Telegram (Global-Admin only — see the enum member). + /// 40=Telegram (Global-Admin only — see the enum member), 50=Push (paired devices, no + /// destination field; Global-Admin only until the customer release). /// public int ProviderType { get; set; } @@ -53,10 +54,30 @@ public class NotificationChannel /// is the endpoint URL; for it is the CHAT ID /// instead, because the endpoint (the bot) is platform-owned. Deliberately one field: the /// redaction and restore-by-id machinery already covers it, so Telegram needs no new - /// secret plumbing. + /// secret plumbing. Empty for (see + /// ). /// public string? Url { get; set; } + /// + /// True when the channel can be dispatched to: a Push channel always (its recipients are + /// the scope's paired devices, resolved at send time), every other provider only with a + /// non-blank . The ONE rule behind the "no destination configured" skips + /// in the dispatcher, the test endpoints and the What's-new digest — a provider-aware + /// copy at each site is exactly how one of them would keep dropping Push channels. + /// + public bool HasDestination() + => ProviderType == (int)WebhookProviderType.Push || !string.IsNullOrWhiteSpace(Url); + + /// + /// Providers a tenant admin may not create or retarget: they send through PLATFORM-owned + /// infrastructure (the Telegram bot; the push sender and its VAPID key). Enforced server-side + /// in TenantConfigValidation, mirrored by the gaOnly flag in the web ChannelEditor. + /// + public static bool IsGlobalAdminOnlyProvider(int providerType) + => providerType == (int)WebhookProviderType.Telegram + || providerType == (int)WebhookProviderType.Push; + /// /// Custom HTTP request headers (JSON object: { "Header-Name": "value", ... }), applied /// only when is GenericJson. Secret — redacted for readers. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs index b1b954c66..fa4b2cdd7 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs @@ -40,5 +40,16 @@ public enum WebhookProviderType /// dispatched by TelegramNotificationService instead of a renderer. /// Telegram = 40, + + /// + /// Web Push to devices the channel's recipients paired in the portal (RFC 8030/8291/8292, + /// end-to-end encrypted per device). Not a webhook: the channel has NO destination of its + /// own — Url stays empty — because the recipients are resolved at send time from + /// the paired devices of the scope's Admin/Operator members (tenant channel) or of the + /// Global Admins (platform channel). Global-Admin only while the customer release is + /// pending (same gate as Telegram, enforced in TenantConfigValidation). Value 50 stays + /// reserved for this provider even if it were ever retired. + /// + Push = 50, } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs new file mode 100644 index 000000000..d99ce54f3 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/PushApiModels.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + // Web Push pairing + devices (Functions/Push). Declaration order == wire order. + + /// POST push/pairings · POST global/push/pairings — a fresh pairing code for the caller's own person. + public class CreatePairingResponse : IApiResponse + { + /// Handle for the PC-side status poll and the confirm/reject calls. + public string PairingId { get; set; } = default!; + /// The 11-character code to type into the receiver app. + public string Code { get; set; } = default!; + /// The same code as the pairing link (QR payload): …/push/pair/#p=<code>. + public string Url { get; set; } = default!; + public DateTime ExpiresUtc { get; set; } + } + + /// GET push/pairings/{pairingId} — what the receiver has done with the code so far. + public class PairingStatusResponse : IApiResponse + { + /// Pending · Redeemed (a device waits for confirmation) · Confirmed · Rejected · Expired. + public string Status { get; set; } = default!; + /// The redeeming device, present from Redeemed on. + public PairingDeviceDto? Device { get; set; } + } + + public class PairingDeviceDto + { + public string DeviceId { get; set; } = default!; + public string Label { get; set; } = default!; + public string Platform { get; set; } = default!; + public DateTime RedeemedUtc { get; set; } + } + + /// POST push/pairings/{pairingId}/confirm — the device is Active from now on. + public class ConfirmPairingResponse : IApiResponse + { + public string DeviceId { get; set; } = default!; + } + + /// POST push/pair/begin — the receiver validates its code before asking for notification permission. + public class BeginPairRequest : IApiRequest + { + public string Code { get; set; } = default!; + } + + /// The VAPID application server key the receiver must subscribe with, and its identity. + public class BeginPairResponse : IApiResponse + { + public string Kid { get; set; } = default!; + /// Uncompressed P-256 point, base64url — pushManager.subscribe({applicationServerKey}). + public string VapidPublicKey { get; set; } = default!; + } + + /// POST push/pair — the receiver hands over its subscription; the device is created Pending. + public class RedeemPairRequest : IApiRequest + { + public string Code { get; set; } = default!; + public string Kid { get; set; } = default!; + public string Endpoint { get; set; } = default!; + public string P256dh { get; set; } = default!; + public string Auth { get; set; } = default!; + public string? Label { get; set; } + public string? Platform { get; set; } + public string? AppVersion { get; set; } + } + + public class RedeemPairResponse : IApiResponse + { + /// "{scope}.{deviceId}.{secret}" — the receiver's only credential; header X-Push-Device-Token. + public string DeviceToken { get; set; } = default!; + public string DeviceId { get; set; } = default!; + /// Always Pending here; the PC confirms. + public string Status { get; set; } = default!; + /// Suggested interval for the receiver's status poll while Pending. + public int PollSeconds { get; set; } + } + + /// GET/PUT push/device — the receiver's own view of its registration. + public class PushDeviceStatusResponse : IApiResponse + { + public string DeviceId { get; set; } = default!; + /// Pending · Active · Paused · Stale (a revoked device is gone: the token answers 401). + public string Status { get; set; } = default!; + public string Label { get; set; } = default!; + public string Platform { get; set; } = default!; + public string Kid { get; set; } = default!; + public DateTime PairedUtc { get; set; } + public DateTime? ConfirmedUtc { get; set; } + public DateTime? LastDeliveredUtc { get; set; } + /// The owner's UPN — this is the owner's own device. + public string OwnerUpn { get; set; } = default!; + /// Tenant display name, or "Platform operator" for the platform scope. + public string ScopeName { get; set; } = default!; + } + + /// PUT push/device — a changed or re-created subscription (same device, same owner). + public class ResubscribeRequest : IApiRequest + { + public string Endpoint { get; set; } = default!; + public string P256dh { get; set; } = default!; + public string Auth { get; set; } = default!; + public string Kid { get; set; } = default!; + } + + /// GET push/devices · GET global/push/devices — own devices (everyone) plus every device of the scope (admins). + public class PushDeviceListResponse : IApiResponse + { + public IReadOnlyList Devices { get; set; } = default!; + } + + public class PushDeviceDto + { + public string DeviceId { get; set; } = default!; + public string Label { get; set; } = default!; + public string Platform { get; set; } = default!; + public string Status { get; set; } = default!; + public string Kid { get; set; } = default!; + public DateTime PairedUtc { get; set; } + public DateTime? ConfirmedUtc { get; set; } + public DateTime? LastDeliveredUtc { get; set; } + public DateTime? LastOpenedUtc { get; set; } + public string OwnerUpn { get; set; } = default!; + /// True for the caller's own devices. + public bool IsOwn { get; set; } + } + + /// PUT sessions/{sessionId}/watch — "notify my devices when this session ends". + public class SessionWatchResponse : IApiResponse + { + public bool Watching { get; set; } + public DateTime? ExpiresUtc { get; set; } + } +} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index 8fada62ae..ac024e944 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -8,6 +8,10 @@ import { toggleChannelBinding } from "./opsChannelRouting"; import { AUTO_ACTION_MODES, describeAutoActionWarning, type AutoActionMode } from "./excessiveEventAutoAction"; import { SectionCardHeader } from "@/components/SectionCardHeader"; +/** Destination sentence of a platform Push channel (ChannelEditor shows it in place of the URL field). */ +const PUSH_DESTINATION_HINT_OPS = + "Delivers to the devices Global Administrators pair under Alerts › Push devices. The Payload option has no effect on Push."; + // All known ops event types grouped by category const OPS_EVENT_TYPES: Record = { Consent: [ @@ -251,6 +255,13 @@ const OPS_EVENT_TYPES: Record = { // notifications carry the same mapped severity, so a rule here delivers recovery pings too. // Dual-register per memory feedback_ops_event_types_dual_register. "AzureMonitorAlert", + // Web Push channel (plan push-relay K15/K16). DeliveryFailed = a push service answered with + // a configuration error (400/401/403: VAPID or endpoint policy), raised at most hourly and + // deduplicated over a tracker row; EndpointRefused = a redeem or re-subscribe named an + // endpoint outside the allowed push-service hosts (carries the hostname only, never the + // endpoint). Dual-register per memory feedback_ops_event_types_dual_register. + "PushDeliveryFailed", + "PushEndpointRefused", ], }; @@ -576,6 +587,7 @@ export function OpsAlertRulesSection({ testResult={testChannelResult?.channelId === channel.id ? testChannelResult : null} showTelegramProvider showEventToggles={false} + pushDestinationHint={PUSH_DESTINATION_HINT_OPS} /> ))} diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx index 7834085fe..05bf29414 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionAlerts.tsx @@ -4,6 +4,8 @@ import { useEffect } from "react"; import { useAdminConfig } from "../../AdminConfigContext"; import { OpsAlertRulesSection } from "../../components/OpsAlertRulesSection"; import { AdminNotifications } from "../../AdminNotifications"; +import { PushDevicesPanel } from "@/components/push/PushDevicesPanel"; +import { hasEnabledPushChannel } from "@/lib/pushPortal"; export function SectionAlerts() { const { @@ -43,6 +45,10 @@ export function SectionAlerts() { testingChannelId={testingOpsChannelId} testChannelResult={testOpsChannelResult} /> + {/* Platform Push devices (plan push-relay K4): only once the SAVED ops channel list carries an + enabled Push channel — opsNotificationChannels mirrors the stored config, the editor above + keeps its own draft. */} + {hasEnabledPushChannel(opsNotificationChannels) && } ); } diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx index 013cf08ac..e7b435476 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx @@ -35,6 +35,7 @@ const WEBHOOK_PROVIDERS: Record = { 20: 'Generic JSON', 30: 'Discord', 40: 'Telegram', + 50: 'Push', }; const SOURCE_LABELS: Record = { diff --git a/src/Web/autopilot-monitor-web/app/push/PushNav.tsx b/src/Web/autopilot-monitor-web/app/push/PushNav.tsx new file mode 100644 index 000000000..430156eac --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/push/PushNav.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { BrandMark } from "@/components/BrandMark"; + +const ITEMS = [ + { href: "/push", label: "History" }, + { href: "/push/status", label: "Status" }, +] as const; + +export function PushNav() { + const pathname = (usePathname() ?? "/push").replace(/\/$/, "") || "/push"; + return ( +
+
+ + + Autopilot Monitor Alerts + + +
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/push/layout.tsx b/src/Web/autopilot-monitor-web/app/push/layout.tsx new file mode 100644 index 000000000..ae09bca42 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/push/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { PushNav } from "./PushNav"; + +/** + * The push receiver: an installable, sign-in-free app under /push/ (K24). It stays inside the + * root layout — MSAL initialises but has no account and stays inert, and Navbar, GlobalSidebar + * and FeedbackBubble stand down for this prefix. The manifest path is deliberately relative: + * the receiver is installed from the portal host and its scope must stay on that origin. + */ +export const metadata: Metadata = { + title: { absolute: "Autopilot Monitor Alerts" }, + description: "Alerts from Autopilot Monitor on this device.", + manifest: "/push/manifest.webmanifest", + appleWebApp: { capable: true, title: "AM Alerts", statusBarStyle: "default" }, + icons: { apple: "/push/icon-180.png", icon: "/push/icon-192.png" }, + robots: { index: false, follow: false }, +}; + +export default function PushLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/push/page.tsx b/src/Web/autopilot-monitor-web/app/push/page.tsx new file mode 100644 index 000000000..a5fd2323e --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/push/page.tsx @@ -0,0 +1,240 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { + entryFragment, + META_KEYS, + parseFragment, + pruneStoredHistory, + readHistory, + type HistoryEntry, +} from "@/lib/push/pushCore"; +import { readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient"; +import { formatDateTime, formatRelativeTime } from "@/lib/push/pushFormat"; +import { useIsClient, useLocationHash } from "./pushEnvironment"; +import { DANGER_CHIP, DEVICE_STATUS_CHIP, NEUTRAL_CHIP, SEVERITY_ACCENT } from "./pushStyles"; + +interface WorkerMessage { + channel?: unknown; + type?: unknown; + id?: unknown; +} + +/** + * The local history, like a chat log (F12): newest first, 30 days / 200 entries, written by the + * service worker and read here. Every open reconciles the device with the server (K14). + */ +export default function PushHistoryPage() { + const router = useRouter(); + const isClient = useIsClient(); + const hash = useLocationHash(); + const [entries, setEntries] = useState(null); + const [device, setDevice] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + const [now, setNow] = useState(() => 0); + + const target = parseFragment(hash); + const focusId = target?.kind === "entry" ? target.id : null; + + // A new deep link opens its entry; a tap toggles from there (adjust-during-render). + const [expanded, setExpanded] = useState<{ focusId: string | null; id: string | null }>({ focusId: null, id: null }); + if (expanded.focusId !== focusId) setExpanded({ focusId, id: focusId }); + + // Load (prune on open), then keep in step with the worker and with the tab coming back. + useEffect(() => { + let cancelled = false; + const load = async () => { + let list: HistoryEntry[] = []; + try { + await pruneStoredHistory(); + list = await readHistory(); + } catch { + // No IndexedDB (private mode): an empty history is the honest state. + } + if (cancelled) return; + setEntries(list); + setNow(Date.now()); + }; + void load(); + + const onMessage = (event: MessageEvent) => { + if (event.data?.channel !== "am-push") return; + if (event.data.type === "open" && typeof event.data.id === "string") { + window.location.hash = entryFragment(event.data.id); + } + void load(); + if (event.data.type === "wiped") setReloadKey((k) => k + 1); + }; + const onVisible = () => { + if (document.visibilityState === "visible") { + void load(); + setReloadKey((k) => k + 1); + } + }; + const worker = "serviceWorker" in navigator ? navigator.serviceWorker : null; + worker?.addEventListener("message", onMessage); + document.addEventListener("visibilitychange", onVisible); + return () => { + cancelled = true; + worker?.removeEventListener("message", onMessage); + document.removeEventListener("visibilitychange", onVisible); + }; + }, []); + + // Reconcile with the server; an unpaired device holding a Safari hand-off cookie continues to pairing. + useEffect(() => { + let cancelled = false; + const run = async () => { + const result = await reconcileDevice(); + if (cancelled) return; + if (result.state === "unpaired" && readPairCookie()) { + router.replace("/push/pair"); + return; + } + setDevice(result); + }; + void run(); + return () => { + cancelled = true; + }; + }, [reloadKey, router]); + + // Bring the deep-linked entry into view once it is rendered. + useEffect(() => { + if (!focusId || !entries) return; + document.getElementById(`entry-${focusId}`)?.scrollIntoView({ block: "center" }); + }, [focusId, entries]); + + const toggle = (id: string) => setExpanded((prev) => ({ ...prev, id: prev.id === id ? null : id })); + + return ( +
+ + + {device?.state === "gone" && ( +
+

This device is no longer paired.

+

The alerts stop here until you pair it again from the portal.

+ + Pair this device again + +
+ )} + + {isClient && entries !== null && entries.length === 0 && ( +
+

No alerts yet.

+ {device?.state === "unpaired" ? ( +

+ Pair this device from the portal to receive alerts here.{" "} + + Pair this device + +

+ ) : ( +

New alerts appear here as they arrive.

+ )} +
+ )} + + {entries && entries.length > 0 && ( +
    + {entries.map((entry) => { + const open = expanded.id === entry.id; + return ( +
  • + + {open && ( +
    + {entry.facts.length > 0 && ( +
    + {entry.facts.map((fact) => ( + + ))} +
    + )} +
    + {entry.type} + {entry.scope} + {formatDateTime(entry.ts)} +
    + {entry.portalUrl && ( + + Open in the portal + + )} +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} + +function FactRow({ name, value }: { name: string; value: string }) { + return ( + <> +
{name}
+
{value}
+ + ); +} + +function DeviceChip({ device }: { device: ReconcileResult | null }) { + if (!device) return diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx index beea836e4..6d5644f32 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionNotifications.tsx @@ -1,12 +1,17 @@ "use client"; import { useGlobalAdminUi } from "@/hooks/useGlobalAdminUi"; +import { useAuth } from "@/contexts/AuthContext"; +import { PushDevicesPanel } from "@/components/push/PushDevicesPanel"; +import { hasEnabledPushChannel } from "@/lib/pushPortal"; import { useTenantConfig } from "../../TenantConfigContext"; import { TenantNotifications } from "../../TenantNotifications"; import NotificationsSection from "../../components/NotificationsSection"; export function SectionNotifications() { + const { user } = useAuth(); const { + config, canEditConfig, notificationChannels, setNotificationChannels, handleTestChannel, testingChannelId, testChannelResult, @@ -20,6 +25,14 @@ export function SectionNotifications() { // off (or presenting in demo mode) yields the real tenant-admin dropdown. const showTelegramProvider = useGlobalAdminUi(); + // Push devices (plan push-relay K4): the SAVED config decides, not the draft — a Push channel + // added but not yet saved has nothing to pair against on the server. Pairing is open to + // Admins and Operators of the tenant (a Global Admin without a member row is refused by the + // server with a hint), so the panel sits outside the read-only fieldset above. + const canPairDevices = + user?.isTenantAdmin === true || user?.role === "Admin" || user?.role === "Operator" || user?.isGlobalAdmin === true; + const showPushDevices = canPairDevices && hasEnabledPushChannel(config?.notificationChannelsJson); + return ( <> @@ -35,6 +48,7 @@ export function SectionNotifications() { readOnly={!canEditConfig} showTelegramProvider={showTelegramProvider} /> + {showPushDevices && } ); } diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index b00fe36ff..b9d790070 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -9,8 +9,9 @@ export interface NotificationChannel { /** Stable id (UUID, generated on creation). Rules reference channels by this id. */ id: string; name: string; - /** 2=Teams Workflow, 10=Slack, 20=Generic JSON, 30=Discord, 40=Telegram */ + /** 2=Teams Workflow, 10=Slack, 20=Generic JSON, 30=Discord, 40=Telegram, 50=Push (paired devices) */ providerType: number; + /** Webhook URL, or the chat id for Telegram; absent/empty for Push (the backend refuses a destination on it). */ url?: string; /** JSON object string { "Header-Name": "value" }; generic provider only. */ customHeadersJson?: string; diff --git a/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx b/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx index 22a3bf78e..8c1cefc6d 100644 --- a/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx +++ b/src/Web/autopilot-monitor-web/components/FeedbackBubble.tsx @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; +import { isPushAppPath } from "@/lib/push/pushAppPath"; import { api } from "@/lib/api"; import type { FeedbackEligibilityResponse, FeedbackRequest } from "@/utils/wire-types.generated"; import { fetchJson, fetchOk, jsonBody, nullOnApiError } from "@/lib/apiClient"; @@ -14,6 +16,9 @@ const FEEDBACK_MAX_CHARS = SHARED_MANIFEST.submissionLimits.feedbackTextMaxChars export default function FeedbackBubble() { const { isAuthenticated, user, getAccessToken } = useAuth(); + const pathname = usePathname(); + // The push receiver has its own shell; a signed-in admin opening it on the desktop gets no bubble. + const standDown = isPushAppPath(pathname); const [phase, setPhase] = useState("loading"); const [rating, setRating] = useState(0); const [hoveredStar, setHoveredStar] = useState(0); @@ -22,7 +27,7 @@ export default function FeedbackBubble() { // Check eligibility shortly after mount useEffect(() => { - if (!isAuthenticated || !user) return; + if (!isAuthenticated || !user || standDown) return; let cancelled = false; @@ -52,7 +57,7 @@ export default function FeedbackBubble() { // used to compete with the page's own data fetches. const timer = setTimeout(() => { void checkEligibility(); }, 1500); return () => { cancelled = true; clearTimeout(timer); }; - }, [isAuthenticated, user, getAccessToken]); + }, [isAuthenticated, user, getAccessToken, standDown]); const handleDismiss = useCallback(async () => { setPhase("hidden"); @@ -88,7 +93,7 @@ export default function FeedbackBubble() { }, [rating, comment, submitting, getAccessToken]); // Don't render anything while loading or hidden - if (phase === "loading" || phase === "hidden") return null; + if (standDown || phase === "loading" || phase === "hidden") return null; return (
diff --git a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx index 4bf0fddf5..2e9cfe2a2 100644 --- a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx +++ b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx @@ -12,6 +12,7 @@ import { deriveNavFlags, filterExpandableNavGroups, isNavGroupVisible } from ".. import { useAdminMode } from "../hooks/useAdminMode"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { DOCS_URL } from "@/utils/config"; +import { isPushAppPath } from "@/lib/push/pushAppPath"; // Sidebar pixel widths export const SIDEBAR_PX: Record = { @@ -209,8 +210,8 @@ export function GlobalSidebar({ children }: { children: ReactNode }) { } } - // Landing page: never show sidebar - if (pathname === "/") { + // Landing page and the push receiver (its own shell): never show sidebar + if (pathname === "/" || isPushAppPath(pathname)) { return <>{children}; } diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index 93e6cfe5e..75ce995ec 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -20,6 +20,7 @@ import { DOCS_PATHS } from "@/lib/docsPaths"; import { useWhatsNew } from "@/hooks/useWhatsNew"; import { WhatsNewCountBadge, WhatsNewIconBadge } from "./WhatsNewPanel"; import { openFeedbackDialog } from "@/lib/feedbackDialogStore"; +import { isPushAppPath } from "@/lib/push/pushAppPath"; export default function Navbar() { const { isAuthenticated, user, hasGlobalScope, logout } = useAuth(); @@ -74,8 +75,8 @@ export default function Navbar() { return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - // Don't render navbar on landing page (root route) - if (pathname === '/') { + // Don't render navbar on landing page (root route) or in the push receiver (its own shell) + if (pathname === '/' || isPushAppPath(pathname)) { return null; } diff --git a/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx b/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx index 6f05308eb..632bcef28 100644 --- a/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx +++ b/src/Web/autopilot-monitor-web/components/notifications/ChannelEditor.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { NotificationChannel } from "@/app/settings/types"; +import { PUSH_PROVIDER } from "@/lib/pushPortal"; /** * Shared channel editor card, used by BOTH the tenant Notifications section and the platform @@ -13,11 +14,15 @@ import { NotificationChannel } from "@/app/settings/types"; const GENERIC_PROVIDER = 20; const TELEGRAM_PROVIDER = 40; +/** Default destination sentence of a Push channel (tenant scope); the ops card passes its own. */ +export const PUSH_DESTINATION_HINT_TENANT = + "Delivers to the devices that Admins and Operators of this tenant pair under Notifications › Push devices. Preview: Global Administrators only."; + export const PROVIDERS: { value: number; label: string; placeholder: string; - /** Offered only to Global Admins (platform-owned transport). */ + /** Offered only to Global Admins (platform-owned transport: Telegram, Push). */ gaOnly?: boolean; }[] = [ { value: 2, label: "Microsoft Teams (Workflow Webhook)", placeholder: "https://prod-xx.westeurope.logic.azure.com:443/workflows/..." }, @@ -29,6 +34,10 @@ export const PROVIDERS: { // not a control). An already-configured Telegram channel stays visible and readable either // way, so saving unrelated changes cannot silently drop it. { value: TELEGRAM_PROVIDER, label: "Telegram (mobile push)", placeholder: "-1003785642894", gaOnly: true }, + // Web Push to the devices paired in this scope (plan push-relay). No destination: the + // recipients are the scope's paired devices, so the URL field gives way to one sentence. + // GA-only like Telegram until the customer release (K4: one gate until then). + { value: PUSH_PROVIDER, label: "Push to paired devices", placeholder: "", gaOnly: true }, ]; const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [ @@ -148,6 +157,7 @@ export function ChannelEditor({ testResult, showTelegramProvider = false, showEventToggles = true, + pushDestinationHint = PUSH_DESTINATION_HINT_TENANT, }: { channel: NotificationChannel; onChange: (next: NotificationChannel) => void; @@ -155,14 +165,18 @@ export function ChannelEditor({ onTest: () => void; testing: boolean; testResult: { success: boolean; message: string } | null; - /** Global Admin: may pick the platform-bot Telegram provider. */ + /** Global Admin: may pick the platform-owned providers (Telegram bot, Web Push). */ showTelegramProvider?: boolean; /** Tenant channels subscribe to event kinds here; ops channels are targeted by rules instead. */ showEventToggles?: boolean; + /** The sentence shown in place of the URL field for a Push channel (names the host's own devices panel). */ + pushDestinationHint?: string; }) { const placeholder = PROVIDERS.find((p) => p.value === channel.providerType)?.placeholder; - const isActive = channel.enabled && (channel.url ?? "").length > 0; const isTelegram = channel.providerType === TELEGRAM_PROVIDER; + const isPush = channel.providerType === PUSH_PROVIDER; + // A Push channel has no destination, so it is configured by being enabled alone. + const isActive = channel.enabled && (isPush || (channel.url ?? "").length > 0); // Keep the channel's own provider listed even when gated, so a GA-created Telegram channel // does not silently render as a different provider for a tenant admin. const providerOptions = PROVIDERS.filter( @@ -211,9 +225,12 @@ export function ChannelEditor({ // Custom headers and the signing secret are generic-only; clear them when leaving the // generic provider so a later switch back can't revive stale secrets and persist them // to a new endpoint. + // A Push channel must not carry a URL (the backend refuses one), so the destination + // is cleared on the way in; switching back to a webhook provider starts empty. onChange({ ...channel, providerType: next, + url: next === PUSH_PROVIDER ? "" : channel.url, customHeadersJson: next === GENERIC_PROVIDER ? channel.customHeadersJson : undefined, signingSecret: next === GENERIC_PROVIDER ? channel.signingSecret : undefined, }); @@ -228,7 +245,20 @@ export function ChannelEditor({
+ {/* Destination: a Push channel has none — one sentence says where it delivers. */} + {isPush && ( +
+

{pushDestinationHint}

+ {isActive && ( + + Active + + )} +
+ )} + {/* URL */} + {!isPush && (
+ )} {/* Custom Headers (generic provider only) */} {channel.providerType === GENERIC_PROVIDER && ( diff --git a/src/Web/autopilot-monitor-web/components/push/PairDeviceDialog.tsx b/src/Web/autopilot-monitor-web/components/push/PairDeviceDialog.tsx new file mode 100644 index 000000000..a35d494ab --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/push/PairDeviceDialog.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ModalPortal } from "@/components/ModalPortal"; +import { useAuth } from "@/contexts/AuthContext"; +import { useCopy } from "@/hooks/useCopy"; +import { useLatest } from "@/hooks/useLatest"; +import { apiErrorText } from "@/lib/apiClient"; +import { + formatCountdown, + formatPairingCode, + PAIRING_GRANT_SECONDS, + pairingSecondsLeft, + pairPageUrl, + platformLabel, + type PairingStatus, + type PushScope, +} from "@/lib/pushPortal"; +import { confirmPairing, createPairing, getPairingStatus, isPushChannelRequired, rejectPairing } from "@/lib/pushPortalApi"; +import type { CreatePairingResponse, PairingDeviceDto } from "@/utils/wire-types.generated"; + +const POLL_MS = 3000; + +interface PairDeviceDialogProps { + scope: PushScope; + onClose: () => void; + /** The device is Active now; the host refreshes its list and toasts. */ + onPaired: (device: PairingDeviceDto | null) => void; + /** POST pairings answered 409 PushChannelRequired — the host shows the hint and disables pairing. */ + onChannelRequired: () => void; +} + +/** + * Pairing from the PC side (plan push-relay K7/K8): one code per dialog, shown as QR image, as + * grouped text and as link; the phone redeems it, the dialog polls until a device waits for + * confirmation and the owner confirms or rejects it here. The code is valid for ten minutes + * and never leaves the fragment of the link, so nothing here reaches a server log. + * + * Expiry is the server's call: the poll runs until it reports Confirmed, Rejected or Expired. + * The countdown is anchored on the moment the code arrived on this clock (not on `expiresUtc`, + * which a PC clock ten minutes ahead would read as already past) and at zero only changes the + * label. + */ +export function PairDeviceDialog({ scope, onClose, onPaired, onChannelRequired }: PairDeviceDialogProps) { + const { getAccessToken } = useAuth(); + const { copied, copy } = useCopy(); + const [attempt, setAttempt] = useState(0); + const [pairing, setPairing] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [status, setStatus] = useState("Pending"); + const [device, setDevice] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [receivedAtMs, setReceivedAtMs] = useState(null); + + // The host's callbacks are read through refs so an inline arrow in the host cannot re-run the + // effects below (a re-run of the create effect would mint a second code). + const onPairedRef = useLatest(onPaired); + const onCloseRef = useLatest(onClose); + const onChannelRequiredRef = useLatest(onChannelRequired); + + // One pairing code per attempt; the QR image is rendered client-side from the link. + useEffect(() => { + let cancelled = false; + const run = async () => { + try { + const created = await createPairing(scope, getAccessToken); + if (cancelled) return; + const arrivedAt = Date.now(); + setPairing(created); + setReceivedAtMs(arrivedAt); + setNowMs(arrivedAt); + try { + const { toDataURL } = await import("qrcode"); + const dataUrl = await toDataURL(created.url, { margin: 1, width: 224 }); + if (!cancelled) setQrDataUrl(dataUrl); + } catch { + // The typed code and the link still work without the image. + } + } catch (err) { + if (cancelled) return; + if (isPushChannelRequired(err)) onChannelRequiredRef.current(); + setError(apiErrorText(err, "Could not start pairing.")); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [attempt, scope, getAccessToken, onChannelRequiredRef]); + + const pairingId = pairing?.pairingId ?? null; + const secondsLeft = receivedAtMs === null ? PAIRING_GRANT_SECONDS : pairingSecondsLeft(receivedAtMs, nowMs); + const polling = pairingId !== null && (status === "Pending" || status === "Redeemed"); + const countingDown = polling && secondsLeft > 0; + // The grant expires lazily on the server (GET pairings/{id} reports Expired past expiresUtc); + // the local zero only changes the wording until that answer arrives. + const expiryText = secondsLeft > 0 ? `Expires in ${formatCountdown(secondsLeft)}` : "The code may have expired"; + + // Poll the pairing while the phone can still act on it (Pending → Redeemed → Confirmed). + useEffect(() => { + if (!pairingId || !polling) return; + let cancelled = false; + let timer: ReturnType | undefined; + const tick = async () => { + try { + const result = await getPairingStatus(scope, pairingId, getAccessToken); + if (cancelled) return; + const next = result.status as PairingStatus; + setStatus(next); + if (result.device) setDevice(result.device); + if (next === "Confirmed") { + onPairedRef.current(result.device ?? null); + onCloseRef.current(); + return; + } + if (next === "Rejected" || next === "Expired") return; + } catch { + if (cancelled) return; + // Transient failure: the next tick tries again. + } + if (!cancelled) timer = setTimeout(() => void tick(), POLL_MS); + }; + timer = setTimeout(() => void tick(), POLL_MS); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [pairingId, polling, scope, getAccessToken, onPairedRef, onCloseRef]); + + // Countdown tick while the code is live and the label still moves. + useEffect(() => { + if (!countingDown) return; + const interval = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(interval); + }, [countingDown]); + + const confirm = async () => { + if (!pairingId) return; + setBusy(true); + setError(null); + try { + await confirmPairing(scope, pairingId, getAccessToken); + onPaired(device); + onClose(); + } catch (err) { + setError(apiErrorText(err, "Could not confirm the device.")); + setBusy(false); + } + }; + + const reject = async () => { + if (!pairingId) return; + setBusy(true); + setError(null); + try { + await rejectPairing(scope, pairingId, getAccessToken); + setStatus("Rejected"); + } catch (err) { + setError(apiErrorText(err, "Could not reject the device.")); + } finally { + setBusy(false); + } + }; + + const startAgain = () => { + setPairing(null); + setQrDataUrl(null); + setReceivedAtMs(null); + setStatus("Pending"); + setDevice(null); + setError(null); + setAttempt((a) => a + 1); + }; + + const close = () => { + if (!busy) onClose(); + }; + + const code = pairing ? formatPairingCode(pairing.code) : ""; + + return ( + +
+
e.stopPropagation()} + > +
+
+

Pair a device

+ +
+ + {!pairing && !error && ( +
+ + + + + Creating a pairing code… +
+ )} + + {pairing && status === "Pending" && ( + <> +
+ {qrDataUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- generated data: URI, not an asset + QR code of the pairing link + ) : ( +
+ QR code +
+ )} +
+ {code} + +
+
+ {pairing.url} + +
+

{expiryText}

+
+

+ On the phone: scan the code or open {pairPageUrl(pairing.url)} and type the code. + On iPhone add the page to the Home Screen first. +

+ + )} + + {pairing && status === "Redeemed" && ( +
+
+

+ {device?.label || "A device"} + {device ? ` (${platformLabel(device.platform)})` : ""} wants to pair. +

+

Confirm only if this is your device. {expiryText}.

+
+
+ + +
+
+ )} + + {pairing && (status === "Expired" || status === "Rejected") && ( +
+

+ {status === "Expired" ? "The code expired." : "The pairing was rejected."} +

+
+ + +
+
+ )} + + {error && ( +
+

{error}

+ {!pairing && ( +
+ + +
+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx b/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx new file mode 100644 index 000000000..4cbe165fc --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/push/PushDevicesPanel.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { SectionCardHeader } from "@/components/SectionCardHeader"; +import { PairDeviceDialog } from "@/components/push/PairDeviceDialog"; +import { useAuth } from "@/contexts/AuthContext"; +import { useNotifications } from "@/contexts/NotificationContext"; +import { apiErrorText } from "@/lib/apiClient"; +import { formatRelativeTime } from "@/lib/push/pushFormat"; +import { describePortalDeviceStatus, platformLabel, statusTone, type PushScope, type StatusTone } from "@/lib/pushPortal"; +import { listPushDevices, removePushDevice, testPushDevice } from "@/lib/pushPortalApi"; +import type { PairingDeviceDto, PushDeviceDto } from "@/utils/wire-types.generated"; + +/** Status chip colours: green = delivering, orange = unclear, gray = idle (no new families). */ +const CHIP_CLASSES: Record = { + green: "bg-green-100 text-green-800", + orange: "bg-orange-100 text-orange-800", + gray: "bg-gray-100 text-gray-700", +}; + +/** Heroicons outline "device-phone-mobile". */ +const PHONE_ICON = "M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"; + +const TOAST_KEY = "push-devices"; + +interface PushDevicesPanelProps { + /** `tenant` talks to push/…, `platform` to global/push/… — and picks the host's card styling. */ + scope: PushScope; +} + +/** + * The paired devices of a scope (plan push-relay): everyone sees their own, scope admins every + * device. Hosts render it only when the SAVED channel list carries an enabled Push channel (K4) + * and the caller may pair (Admin/Operator of the tenant, or a Global Admin on the platform card), + * and deliberately OUTSIDE any read-only fieldset: an Operator cannot edit channels but pairs. + */ +export function PushDevicesPanel({ scope }: PushDevicesPanelProps) { + const { getAccessToken } = useAuth(); + const { addNotification, notifyError } = useNotifications(); + const [devices, setDevices] = useState(null); + const [loadError, setLoadError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + const [nowMs, setNowMs] = useState(() => Date.now()); + const [pairingOpen, setPairingOpen] = useState(false); + const [channelRequired, setChannelRequired] = useState(false); + const [confirmRemoveId, setConfirmRemoveId] = useState(null); + const [removingId, setRemovingId] = useState(null); + const [testingId, setTestingId] = useState(null); + + useEffect(() => { + let cancelled = false; + const run = async () => { + try { + const result = await listPushDevices(scope, getAccessToken); + if (cancelled) return; + setDevices(result.devices); + setLoadError(null); + setNowMs(Date.now()); + } catch (err) { + if (cancelled) return; + setLoadError(apiErrorText(err, "Could not load the paired devices.")); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [scope, getAccessToken, reloadKey]); + + const reload = useCallback(() => setReloadKey((k) => k + 1), []); + + const handlePaired = useCallback( + (device: PairingDeviceDto | null) => { + addNotification("success", "Push devices", `${device?.label || "Device"} paired.`, TOAST_KEY); + reload(); + }, + [addNotification, reload], + ); + + const handleChannelRequired = useCallback(() => { + setChannelRequired(true); + setPairingOpen(false); + }, []); + + const sendTest = async (device: PushDeviceDto) => { + setTestingId(device.deviceId); + try { + const result = await testPushDevice(scope, device.deviceId, getAccessToken); + addNotification(result.success ? "success" : "error", "Push test", result.message, TOAST_KEY); + } catch (err) { + notifyError("Push test", err, TOAST_KEY); + } finally { + setTestingId(null); + } + }; + + const remove = async (device: PushDeviceDto) => { + setRemovingId(device.deviceId); + try { + await removePushDevice(scope, device.deviceId, getAccessToken); + setDevices((list) => (list ? list.filter((d) => d.deviceId !== device.deviceId) : list)); + setConfirmRemoveId(null); + addNotification("info", "Push devices", `${device.label || "Device"} removed.`, TOAST_KEY); + } catch (err) { + notifyError("Push devices", err, TOAST_KEY); + } finally { + setRemovingId(null); + } + }; + + const isPlatform = scope === "platform"; + const cardClass = isPlatform + ? "bg-gradient-to-br from-amber-50 to-orange-50 dark:from-gray-800 dark:to-gray-800 border-2 border-amber-300 dark:border-amber-700 rounded-lg shadow-lg" + : "bg-white rounded-lg shadow"; + const pairButtonClass = isPlatform + ? "bg-amber-600 hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600" + : "bg-sky-600 hover:bg-sky-700"; + + const pairButton = ( + + ); + + return ( +
+ +
+ {channelRequired && ( +

Enable a Push channel above first.

+ )} + {loadError && ( +

+ {loadError}{" "} + +

+ )} + {devices === null && !loadError &&

Loading devices…

} + {devices !== null && devices.length === 0 && ( +

No device paired yet.

+ )} + {devices !== null && devices.length > 0 && ( +
    + {devices.map((device) => { + const tone = statusTone(device.status); + const confirming = confirmRemoveId === device.deviceId; + const busy = removingId === device.deviceId || testingId === device.deviceId; + const canTest = device.status !== "Pending"; + return ( +
  • + {device.label || "Device"} + {platformLabel(device.platform)} + + {device.status} + + + paired {new Date(device.pairedUtc).toLocaleDateString()} + {device.lastDeliveredUtc ? ` · delivered ${formatRelativeTime(device.lastDeliveredUtc, nowMs)}` : ""} + {device.lastOpenedUtc ? ` · opened ${formatRelativeTime(device.lastOpenedUtc, nowMs)}` : ""} + {/* The owner is named only for someone else's device — a scope admin's view. */} + {!device.isOwn ? ` · ${device.ownerUpn}` : ""} + + + {confirming ? ( + <> + {`Remove ${device.label || "this device"}?`} + + + + ) : ( + <> + + + + )} + +
  • + ); + })} +
+ )} +
+ + {pairingOpen && ( + setPairingOpen(false)} + onPaired={handlePaired} + onChannelRequired={handleChannelRequired} + /> + )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/eslint.config.mjs b/src/Web/autopilot-monitor-web/eslint.config.mjs index 1466a53b8..4e487a2a8 100644 --- a/src/Web/autopilot-monitor-web/eslint.config.mjs +++ b/src/Web/autopilot-monitor-web/eslint.config.mjs @@ -43,6 +43,22 @@ const config = [ "@typescript-eslint/no-require-imports": "off", }, }, + { + // The push receiver's service worker and its shared core are ES modules + // served verbatim from public/; they run in the worker global scope. + files: ["public/push/**/*.js"], + languageOptions: { + sourceType: "module", + globals: { + self: "readonly", + indexedDB: "readonly", + fetch: "readonly", + URL: "readonly", + atob: "readonly", + btoa: "readonly", + }, + }, + }, ]; export default config; diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts index b3989ab2e..bf6c0b2dd 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts @@ -31,6 +31,7 @@ describe("api path ids are percent-encoded", () => { (id) => api.sessions.report(id), (id) => api.sessions.annotations(id), (id) => api.sessions.annotation(id, "lane"), + (id) => api.sessions.watch(id), ]; for (const build of builders) { const url = build(HOSTILE); @@ -52,6 +53,14 @@ describe("api path ids are percent-encoded", () => { expect(api.rules.gatherRule(HOSTILE)).toBe(`https://test.example/api/rules/gather/${ENCODED}`); }); + it("push ids stay inside their segment in both route families", async () => { + const { api } = await apiPromise; + expect(api.push.pairing("tenant", HOSTILE)).toBe(`https://test.example/api/push/pairings/${ENCODED}`); + expect(api.push.confirmPairing("platform", HOSTILE)).toBe(`https://test.example/api/global/push/pairings/${ENCODED}/confirm`); + expect(api.push.testDevice("platform", HOSTILE)).toBe(`https://test.example/api/global/push/devices/${ENCODED}/test`); + expect(api.sessions.watch(HOSTILE, "t")).toBe(`https://test.example/api/sessions/${ENCODED}/watch?tenantId=t`); + }); + it("no path builder lets an id escape its segment", async () => { const { api } = await apiPromise; type Builder = (...args: string[]) => string; diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/authHint.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/authHint.test.ts index 2381d0e7e..8e3fa964f 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/authHint.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/authHint.test.ts @@ -32,6 +32,11 @@ const cases: Case[] = [ { name: "signed-in browser on a public page: AuthGate is not there to lift the overlay", pathname: "/about/", hash: "", hostname: "www.autopilotmonitor.com", storage: { "msal.2.account.keys": '["abc-def"]' }, expected: false }, { name: "auth response hash on a non-root path", pathname: "/plans/", hash: "#code=abc&state=y", hostname: "www.autopilotmonitor.com", storage: {}, expected: false }, { name: "portal host, non-root path", pathname: "/dashboard/", hash: "", hostname: PORTAL_HOSTNAME, storage: {}, expected: false }, + // Push receiver fragments (K8): a pairing code or a history deep link is never an auth response, + // even on the root path where the hint applies. + { name: "push pairing fragment is not an auth response", hash: "#p=ABCDEFGHJKM", hostname: "www.autopilotmonitor.com", storage: {}, expected: false }, + { name: "push history deep link is not an auth response", hash: "#e/123", hostname: "www.autopilotmonitor.com", storage: {}, expected: false }, + { name: "push pairing fragment on the pair page", pathname: "/push/pair/", hash: "#p=ABCDEFGHJKM", hostname: "www.autopilotmonitor.com", storage: {}, expected: false }, ]; function runInlineScript(c: Case): boolean { diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/hostRouting.guard.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/hostRouting.guard.test.ts index 9c39809bc..444b542eb 100644 --- a/src/Web/autopilot-monitor-web/lib/__tests__/hostRouting.guard.test.ts +++ b/src/Web/autopilot-monitor-web/lib/__tests__/hostRouting.guard.test.ts @@ -149,6 +149,18 @@ describe("hostRouting public/portal guard", () => { expect(isPublicPath("/aboutx")).toBe(false); }); + it("the push receiver is portal surface and never public (K24)", () => { + // /push/ has no sign-in, but it pairs against the portal origin: its service worker scope, + // manifest id and hand-off cookie are bound to that host. Listing it as public would + // install the receiver on www and leave every pairing link on the wrong origin. + for (const r of ["/push", "/push/", "/push/pair", "/push/pair/", "/push/status/", "/push/sw.js", "/push/manifest.webmanifest"]) { + expect(isPublicPath(r), `${r} must be portal`).toBe(false); + } + expect(pageByRoute.get("/push")?.isProtected, "/push renders no ProtectedRoute (it has no sign-in)").toBe(false); + expect(pageByRoute.get("/push/pair")?.isProtected).toBe(false); + expect(pageByRoute.get("/push/status")?.isProtected).toBe(false); + }); + /** * Pins the HostRoutingGuard safety rules (prod incident 2026-07-30: sign-ins * landed back on the www landing page). The guard's effect can run before @@ -189,6 +201,18 @@ describe("hostRouting public/portal guard", () => { ).toBe("to-portal"); }); + it("the push receiver stays on portal, signed in or not (K24)", () => { + // Anonymous on the portal host: the receiver renders where it is. + expect(decideHostBounce({ ...portal, ...anon, pathname: "/push/" })).toBeNull(); + expect(decideHostBounce({ ...portal, ...anon, pathname: "/push/pair/", isAuthLoading: true })).toBeNull(); + // A pairing link that reaches www is sent to portal, with its #p= fragment intact (the guard keeps the hash). + expect(decideHostBounce({ ...www, ...anon, pathname: "/push/pair/" })).toBe("to-portal"); + // The pairing fragment is not an auth response: it must not suppress the www → portal bounce. + expect( + decideHostBounce({ ...www, ...anon, pathname: "/push/pair/", hasAuthResponse: /[#&](code|state|error)=/.test("#p=7Q3MK9T2XH4") }), + ).toBe("to-portal"); + }); + it("right host, right surface: no bounce", () => { expect(decideHostBounce({ ...www, ...anon, pathname: "/" })).toBeNull(); expect(decideHostBounce({ ...portal, ...anon, pathname: "/dashboard" })).toBeNull(); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/pushCore.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/pushCore.test.ts new file mode 100644 index 000000000..b7b3ec9a6 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/pushCore.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, it } from "vitest"; +import { + APP_VERSION, + base64UrlToUint8Array, + bufferToBase64Url, + cleanLabel, + defaultLabel, + detectPlatform, + DEVICE_TOKEN_HEADER, + entryFragment, + GENERIC_BODY, + GENERIC_TITLE, + HISTORY_MAX_AGE_MS, + HISTORY_MAX_ENTRIES, + isIosDevice, + isValidPairingCode, + isWipeCommand, + LABEL_MAX_CHARS, + normalizePairingCode, + normalizePayload, + PAIRING_CODE_ALPHABET, + PAIRING_CODE_LENGTH, + parseFragment, + PLATFORMS, + pruneHistory, + pushApiRequest, + toNotificationOptions, + type HistoryEntry, +} from "../push/pushCore"; + +/** + * The push receiver's pure core (public/push/sw-core.js, re-exported by lib/push/pushCore.ts). + * It is shared by the service worker and the pages, so the contract is pinned here once: + * payload normalisation with the generic fallback, both push event shapes, the history + * retention rule, the pairing-code grammar (K8), platform detection (K13) and the URL + * fragment grammar. + */ + +const NOW = Date.parse("2026-10-07T12:00:00Z"); + +const FULL_PAYLOAD = { + web_push: 8030, + mutable: true, + notification: { + title: "Enrollment failed", + body: "DESKTOP-1234 failed in Device Setup", + navigate: "https://portal.example.invalid/push/#e/11111111-1111-1111-1111-111111111111", + tag: "abc", + data: { + v: 1, + id: "11111111-1111-1111-1111-111111111111", + type: "enrollment_failed", + ts: "2026-10-07T11:58:00Z", + severity: "error", + facts: [ + { name: "Device", value: "DESKTOP-1234" }, + { name: "Serial", value: "****5678" }, + ], + portalUrl: "https://portal.example.invalid/sessions?id=22222222-2222-2222-2222-222222222222", + scope: "tenant", + }, + }, +}; + +describe("normalizePayload", () => { + it("reads the full payload (event.data.json())", () => { + const entry = normalizePayload(FULL_PAYLOAD, NOW); + expect(entry).toEqual({ + id: "11111111-1111-1111-1111-111111111111", + ts: "2026-10-07T11:58:00.000Z", + title: "Enrollment failed", + body: "DESKTOP-1234 failed in Device Setup", + type: "enrollment_failed", + severity: "error", + facts: [ + { name: "Device", value: "DESKTOP-1234" }, + { name: "Serial", value: "****5678" }, + ], + portalUrl: "https://portal.example.invalid/sessions?id=22222222-2222-2222-2222-222222222222", + navigate: "https://portal.example.invalid/push/#e/11111111-1111-1111-1111-111111111111", + tag: "abc", + scope: "tenant", + generic: false, + }); + }); + + it("reads the bare notification shape Safari hands the worker (event.notification / proposedNotification)", () => { + // A Notification instance is not a plain object: property access is all that may be relied on. + class FakeNotification { + title = FULL_PAYLOAD.notification.title; + body = FULL_PAYLOAD.notification.body; + tag = FULL_PAYLOAD.notification.tag; + data = FULL_PAYLOAD.notification.data; + } + const fromSafari = normalizePayload(new FakeNotification(), NOW); + const fromJson = normalizePayload(FULL_PAYLOAD, NOW); + expect(fromSafari).toEqual({ ...fromJson, navigate: null }); + }); + + it("accepts a JSON string (event.data.text())", () => { + expect(normalizePayload(JSON.stringify(FULL_PAYLOAD), NOW).id).toBe(FULL_PAYLOAD.notification.data.id); + }); + + it.each([ + ["null", null], + ["undefined", undefined], + ["garbage string", "not json at all"], + ["number", 42], + ["empty object", {}], + ["notification without text", { notification: { data: { id: "x" } } }], + ])("falls back to the generic notification for %s — never a silent push", (_name, input) => { + const entry = normalizePayload(input, NOW); + expect(entry.generic).toBe(true); + expect(entry.title).toBe(GENERIC_TITLE); + expect(entry.body).toBe(GENERIC_BODY); + expect(entry.severity).toBe("info"); + expect(entry.ts).toBe(new Date(NOW).toISOString()); + expect(entry.id.length).toBeGreaterThan(0); + }); + + it("keeps the payload id when only the text is missing, and mints a local one otherwise", () => { + expect(normalizePayload({ notification: { data: { id: "x" } } }, NOW).id).toBe("x"); + expect(normalizePayload({ notification: { title: "t" } }, NOW).id).toBe(`local-${NOW}`); + }); + + it("strips control characters, caps lengths and drops malformed facts and links", () => { + const entry = normalizePayload( + { + notification: { + title: "A\u0007B\u001b[31m C" + "x".repeat(200), + body: "line1\nline2", + navigate: "javascript:alert(1)", + data: { + id: "ok", + severity: "critical", + ts: "not a date", + facts: [ + { name: "Fine", value: "v" }, + { name: "", value: "no name" }, + { name: "NoValue" }, + "string", + { name: "Long", value: "y".repeat(100) }, + ], + portalUrl: "ftp://portal.example.invalid/x", + scope: 7, + }, + }, + }, + NOW, + ); + expect(entry.title.startsWith("A B [31m C")).toBe(true); + expect(entry.title.length).toBe(120); + expect(entry.body).toBe("line1 line2"); + expect(entry.navigate).toBeNull(); + expect(entry.portalUrl).toBeNull(); + expect(entry.severity).toBe("info"); + expect(entry.ts).toBe(new Date(NOW).toISOString()); + expect(entry.facts).toEqual([ + { name: "Fine", value: "v" }, + { name: "Long", value: "y".repeat(64) }, + ]); + expect(entry.scope).toBe("tenant"); + expect(entry.generic).toBe(false); + }); + + it("recognises the wipe command by type only", () => { + expect(isWipeCommand(normalizePayload({ notification: { title: "Unpaired", data: { type: "push_revoked" } } }, NOW))).toBe(true); + expect(isWipeCommand(normalizePayload(FULL_PAYLOAD, NOW))).toBe(false); + }); + + it("carries the whole entry into the notification so a click can re-persist it", () => { + const entry = normalizePayload(FULL_PAYLOAD, NOW); + const options = toNotificationOptions(entry); + expect(options.tag).toBe("abc"); + expect(options.body).toBe(entry.body); + expect(options.data).toEqual({ id: entry.id, entry }); + expect(toNotificationOptions({ ...entry, tag: null }).tag).toBeUndefined(); + }); +}); + +describe("pruneHistory", () => { + const entry = (id: string, ts: string): HistoryEntry => ({ + ...normalizePayload(FULL_PAYLOAD, NOW), + id, + ts, + }); + + it("drops entries older than 30 days, keeps the newest 200, newest first, and drops unreadable timestamps", () => { + const fresh = Array.from({ length: 250 }, (_, i) => entry(`f${i}`, new Date(NOW - i * 60_000).toISOString())); + const old = entry("old", new Date(NOW - HISTORY_MAX_AGE_MS - 1).toISOString()); + const edge = entry("edge", new Date(NOW - HISTORY_MAX_AGE_MS).toISOString()); + const broken = entry("broken", "yesterday-ish"); + const result = pruneHistory([old, broken, ...fresh.slice().reverse(), edge], NOW); + expect(result.length).toBe(HISTORY_MAX_ENTRIES); + expect(result[0].id).toBe("f0"); + expect(result[199].id).toBe("f199"); + expect(result.find((e) => e.id === "old" || e.id === "broken" || e.id === "edge")).toBeUndefined(); + // The edge entry survives when there is room. + expect(pruneHistory([edge, old], NOW).map((e) => e.id)).toEqual(["edge"]); + }); + + it("does not mutate its input", () => { + const input = [entry("b", "2026-10-06T00:00:00Z"), entry("a", "2026-10-07T00:00:00Z")]; + const copy = [...input]; + pruneHistory(input, NOW); + expect(input).toEqual(copy); + }); +}); + +describe("pairing code grammar (K8)", () => { + it("uses Crockford base32 without I, L, O, U", () => { + expect(PAIRING_CODE_ALPHABET).toBe("0123456789ABCDEFGHJKMNPQRSTVWXYZ"); + expect(PAIRING_CODE_ALPHABET.length).toBe(32); + for (const letter of "ILOU") expect(PAIRING_CODE_ALPHABET).not.toContain(letter); + expect(PAIRING_CODE_LENGTH).toBe(11); + }); + + it("normalises typing: case, spaces, hyphens and the look-alikes O→0, I/L→1", () => { + expect(normalizePairingCode("7q3m-k9t2-xh4")).toBe("7Q3MK9T2XH4"); + expect(normalizePairingCode(" 7Q3 MK9 T2X H4 ")).toBe("7Q3MK9T2XH4"); + expect(normalizePairingCode("OQ3MK9T2XHI")).toBe("0Q3MK9T2XH1"); + expect(normalizePairingCode("lQ3MK9T2XHo")).toBe("1Q3MK9T2XH0"); + }); + + it("rejects wrong length, excluded letters and non-strings", () => { + expect(normalizePairingCode("7Q3MK9T2XH")).toBeNull(); + expect(normalizePairingCode("7Q3MK9T2XH44")).toBeNull(); + expect(normalizePairingCode("7Q3MK9T2XHU")).toBeNull(); + expect(normalizePairingCode("")).toBeNull(); + expect(normalizePairingCode(null)).toBeNull(); + expect(normalizePairingCode(undefined)).toBeNull(); + expect(isValidPairingCode("7Q3MK9T2XH4")).toBe(true); + expect(isValidPairingCode("7q3mk9t2xh4")).toBe(false); + }); + + it("can never spell the substrings MSAL, authHint and HostRoutingGuard react to", () => { + // `code=` and `state=` need "=", `error` needs an O: none of them is expressible in the alphabet. + const alphabet = new Set(PAIRING_CODE_ALPHABET); + for (const needle of ["code=", "state=", "error"]) { + const expressible = [...needle.toUpperCase()].every((ch) => alphabet.has(ch)); + expect(expressible, `${needle} must not be expressible in a pairing code`).toBe(false); + } + expect(/[#&](code|state|error)=/.test("#p=7Q3MK9T2XH4")).toBe(false); + }); +}); + +describe("parseFragment", () => { + it("reads #p= with normalisation and #e/", () => { + expect(parseFragment("#p=7q3m-k9t2-xh4")).toEqual({ kind: "pair", code: "7Q3MK9T2XH4" }); + expect(parseFragment("p=7Q3MK9T2XH4")).toEqual({ kind: "pair", code: "7Q3MK9T2XH4" }); + expect(parseFragment("#p=7Q3M%2DK9T2XH4")).toEqual({ kind: "pair", code: "7Q3MK9T2XH4" }); + expect(parseFragment("#e/11111111-1111-1111-1111-111111111111")).toEqual({ + kind: "entry", + id: "11111111-1111-1111-1111-111111111111", + }); + expect(entryFragment("abc")).toBe("#e/abc"); + }); + + it("yields null for anything else", () => { + expect(parseFragment("")).toBeNull(); + expect(parseFragment("#")).toBeNull(); + expect(parseFragment("#p=")).toBeNull(); + expect(parseFragment("#p=INVALID")).toBeNull(); + expect(parseFragment("#e/")).toBeNull(); + expect(parseFragment("#e/../x")).toBeNull(); + expect(parseFragment("#code=abc&state=x")).toBeNull(); + expect(parseFragment("#story")).toBeNull(); + }); +}); + +describe("platform detection (K13)", () => { + const IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Mobile/15E148 Safari/604.1"; + const IPAD_DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15"; + const MAC_SAFARI = IPAD_DESKTOP_UA; + const ANDROID_CHROME = "Mozilla/5.0 (Linux; Android 15; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36"; + const WINDOWS_EDGE = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0"; + const WINDOWS_FIREFOX = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0"; + const MAC_CHROME = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"; + + it("maps browsers onto the closed list", () => { + expect(detectPlatform({ userAgent: IPHONE })).toBe("ios-homescreen"); + expect(detectPlatform({ userAgent: IPAD_DESKTOP_UA, maxTouchPoints: 5 })).toBe("ios-homescreen"); + expect(detectPlatform({ userAgent: MAC_SAFARI, maxTouchPoints: 0 })).toBe("macos-safari"); + expect(detectPlatform({ userAgent: ANDROID_CHROME })).toBe("android-chrome"); + expect(detectPlatform({ userAgent: WINDOWS_EDGE })).toBe("windows-chromium"); + expect(detectPlatform({ userAgent: WINDOWS_FIREFOX })).toBe("firefox"); + expect(detectPlatform({ userAgent: MAC_CHROME })).toBe("other"); + expect(detectPlatform({ userAgent: "" })).toBe("other"); + }); + + it("every result is a member of the backend's list", () => { + for (const ua of [IPHONE, MAC_SAFARI, ANDROID_CHROME, WINDOWS_EDGE, WINDOWS_FIREFOX, MAC_CHROME, ""]) { + expect(PLATFORMS).toContain(detectPlatform({ userAgent: ua })); + } + expect(PLATFORMS).toEqual(["ios-homescreen", "android-chrome", "windows-chromium", "macos-safari", "firefox", "other"]); + }); + + it("iPadOS is iOS even with the desktop UA", () => { + expect(isIosDevice({ userAgent: IPAD_DESKTOP_UA, maxTouchPoints: 5 })).toBe(true); + expect(isIosDevice({ userAgent: IPAD_DESKTOP_UA, maxTouchPoints: 0 })).toBe(false); + expect(isIosDevice({ userAgent: IPAD_DESKTOP_UA })).toBe(false); + }); + + it("offers a label per platform within the backend cap", () => { + for (const platform of PLATFORMS) { + const label = defaultLabel(platform); + expect(label.length).toBeGreaterThan(0); + expect(label.length).toBeLessThanOrEqual(LABEL_MAX_CHARS); + } + expect(cleanLabel(" My\u0000 phone " + "z".repeat(60)).length).toBe(LABEL_MAX_CHARS); + expect(APP_VERSION.length).toBeLessThanOrEqual(32); + }); +}); + +describe("key encoding", () => { + it("round-trips base64url without padding", () => { + const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]); + const encoded = bufferToBase64Url(bytes.buffer); + expect(encoded).not.toMatch(/[+/=]/); + expect([...base64UrlToUint8Array(encoded)]).toEqual([...bytes]); + expect(bufferToBase64Url(null)).toBe(""); + }); +}); + +describe("pushApiRequest", () => { + it("puts the device token in its header only and never in the URL (K11)", () => { + const token = "part.11111111-1111-1111-1111-111111111111.secret"; + const { url, init } = pushApiRequest("https://api.example.invalid/", "/api/push/device", { + method: "PUT", + token, + body: { endpoint: "https://push.example.invalid/x" }, + }); + expect(url).toBe("https://api.example.invalid/api/push/device"); + expect(url).not.toContain("secret"); + const headers = init.headers as Record; + expect(headers[DEVICE_TOKEN_HEADER]).toBe(token); + expect(DEVICE_TOKEN_HEADER).toBe("X-Push-Device-Token"); + expect(headers["Content-Type"]).toBe("application/json"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body as string)).toEqual({ endpoint: "https://push.example.invalid/x" }); + }); + + it("sends no token header and no body for an anonymous GET", () => { + const { init } = pushApiRequest("https://api.example.invalid", "/api/push/pair/begin"); + const headers = init.headers as Record; + expect(headers[DEVICE_TOKEN_HEADER]).toBeUndefined(); + expect(headers["Content-Type"]).toBeUndefined(); + expect(init.body).toBeUndefined(); + expect(init.method).toBe("GET"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/pushFormat.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/pushFormat.test.ts new file mode 100644 index 000000000..4bf46aff4 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/pushFormat.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { formatDateTime, formatRelativeTime } from "../push/pushFormat"; + +const NOW = Date.parse("2026-10-07T12:00:00Z"); + +describe("push history formatting", () => { + it("renders relative times by the portal's steps", () => { + expect(formatRelativeTime("2026-10-07T11:59:40Z", NOW)).toBe("just now"); + expect(formatRelativeTime("2026-10-07T11:55:00Z", NOW)).toBe("5m ago"); + expect(formatRelativeTime("2026-10-07T09:00:00Z", NOW)).toBe("3h ago"); + expect(formatRelativeTime("2026-10-05T12:00:00Z", NOW)).toBe("2d ago"); + expect(formatRelativeTime("2026-09-01T12:00:00Z", NOW)).toBe(new Date("2026-09-01T12:00:00Z").toLocaleDateString()); + expect(formatRelativeTime("nope", NOW)).toBe(""); + }); + + it("renders a dash for missing or unreadable timestamps", () => { + expect(formatDateTime(null)).toBe("—"); + expect(formatDateTime(undefined)).toBe("—"); + expect(formatDateTime("nope")).toBe("—"); + expect(formatDateTime("2026-10-07T12:00:00Z")).toBe(new Date("2026-10-07T12:00:00Z").toLocaleString()); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/pushPortal.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/pushPortal.test.ts new file mode 100644 index 000000000..f50aa9707 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/pushPortal.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { + describePortalDeviceStatus, + formatCountdown, + formatPairingCode, + hasEnabledPushChannel, + isPushProvider, + PAIRING_GRANT_SECONDS, + pairPageUrl, + pairingSecondsLeft, + platformLabel, + PUSH_PROVIDER, + statusTone, +} from "../pushPortal"; + +describe("formatPairingCode", () => { + it("groups an 11-character code as 4-4-3", () => { + expect(formatPairingCode("ABCDEFGHJKM")).toBe("ABCD-EFGH-JKM"); + }); + + it("upper-cases and ignores existing dashes and whitespace", () => { + expect(formatPairingCode("abcd-efgh jkm")).toBe("ABCD-EFGH-JKM"); + expect(formatPairingCode("ABCD-EFGH-JKM")).toBe("ABCD-EFGH-JKM"); + }); + + it("handles short and empty input without a trailing dash", () => { + expect(formatPairingCode("")).toBe(""); + expect(formatPairingCode("ABCD")).toBe("ABCD"); + expect(formatPairingCode("ABCDE")).toBe("ABCD-E"); + }); +}); + +describe("platformLabel", () => { + it("names every platform of the wire's closed list", () => { + expect(platformLabel("ios-homescreen")).toBe("iPhone / iPad"); + expect(platformLabel("android-chrome")).toBe("Android"); + expect(platformLabel("windows-chromium")).toBe("Windows"); + expect(platformLabel("macos-safari")).toBe("Mac"); + expect(platformLabel("firefox")).toBe("Firefox"); + }); + + it("falls back to Other for unknown or free-text values", () => { + expect(platformLabel("other")).toBe("Other"); + expect(platformLabel("")).toBe("Other"); + expect(platformLabel("