mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5934ae9fc4 | |||
| 3d10011460 | |||
| edad842a0b | |||
| be239b74b8 | |||
| 4fee61bf68 | |||
| e8b8a8de0c | |||
| c287cb3eae | |||
| 182ea2e220 | |||
| a6e8fe320f | |||
| e5b69325a2 | |||
| ee1566ae20 | |||
| a675c70e2b | |||
| b45a7ae2ff | |||
| b2f6b7bd7f | |||
| bd064b1164 | |||
| a3f98abcb5 | |||
| fb4ba57a7a | |||
| 51200702c9 | |||
| d093be3e8d | |||
| 4868a4448d | |||
| 03a8bf92c9 | |||
| 7265052936 | |||
| 0c01f3ed76 | |||
| caa6952ae7 | |||
| 6bc503f670 | |||
| 87c5446cde | |||
| cb9b34a7aa | |||
| 2bb3dcd69e | |||
| 36aeec1b45 | |||
| 033781d083 | |||
| 8a733be369 | |||
| 91251ac42a | |||
| 2a03bebc58 | |||
| 2e6fa5ac74 | |||
| a61a50cd53 | |||
| 1271658b16 | |||
| 5485f75620 | |||
| 609fcbacaf | |||
| 7b7d35f428 | |||
| a5a1039d22 | |||
| 9d50ff8901 | |||
| 0ce59b700c | |||
| 309e15bd33 | |||
| 28da10e9fb | |||
| 36aafb655f | |||
| 24ea5c76c8 | |||
| f5117e2290 | |||
| 0cd8baa85d | |||
| cd009071db | |||
| f13b823387 | |||
| 2517e8eb28 | |||
| d378fabc01 | |||
| be0e37008a | |||
| 04fae4fe86 | |||
| 2b29019b59 | |||
| aa578def0e | |||
| 7eceed16e1 | |||
| 27454d5744 | |||
| 46b8503207 | |||
| 50bb09eb92 | |||
| aaf9b74426 | |||
| e31a5d2875 | |||
| 5dc17377f6 | |||
| e2e12055bf | |||
| 134ea71d63 | |||
| 2f9a404323 | |||
| 964a548e81 | |||
| 5d5a893557 | |||
| d4e4170813 | |||
| 6d313d2832 | |||
| 0618df3c56 | |||
| c6fda24843 | |||
| 32bfa8b5b7 | |||
| c7bfc1e2f1 | |||
| 2fb533ab92 | |||
| b0b38c1cfa | |||
| 34c8715c5a | |||
| 5e240d7b81 | |||
| a621a00252 |
+1
-1
Submodule .claude/commands updated: d05399a386...500773e336
@@ -65,7 +65,7 @@ updates:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
# Validator tooling for rules/ (ajv-cli); combine-rules.yml installs it from this
|
||||
# Validator tooling for rules/ (ajv for validate.js); combine-rules.yml installs it from this
|
||||
# lockfile with contents:write in hand, so the pins must not go stale.
|
||||
- package-ecosystem: npm
|
||||
directory: /rules/scripts
|
||||
|
||||
@@ -26,7 +26,10 @@ name: Workflow gates
|
||||
#
|
||||
# The pins are not maintained by hand — .github/dependabot.yml opens a monthly grouped
|
||||
# PR that rewrites both the SHA and the trailing version comment. The comment is
|
||||
# therefore required too: it is what makes a pinned line reviewable.
|
||||
# therefore required too: it is what makes a pinned line reviewable. It names the exact
|
||||
# release tag (# v7.0.1), never the floating major (# v7): a major tag moves with every
|
||||
# upstream release, after which the comment no longer names the pinned commit and
|
||||
# zizmor's ref-version-mismatch flags every line that carries it.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -45,7 +48,7 @@ jobs:
|
||||
name: Verify action pins
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -59,13 +62,15 @@ jobs:
|
||||
# nothing to pin.
|
||||
case "$ref" in ./*|\$/*) continue ;; esac
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*[^[:space:]] ]]; then
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+([[:space:]]|$) ]]; then
|
||||
printf 'ok %s:%s %s\n' "$file" "$line" "$ref"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]*$ ]]; then
|
||||
echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@<sha> # vN' so the line stays reviewable."
|
||||
echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@<sha> # vX.Y.Z' so the line stays reviewable."
|
||||
elif [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+# ]]; then
|
||||
echo "::error file=$file,line=$line::The version comment must name the exact release tag the SHA resolves to ('# vX.Y.Z'), not a floating major: '$ref'. Find it with: gh api repos/<owner>/<action>/git/matching-refs/tags/vN"
|
||||
else
|
||||
echo "::error file=$file,line=$line::Action '$ref' is not pinned to a commit SHA. Tags are mutable -- resolve it with: gh api repos/<owner>/<action>/git/ref/tags/<tag>"
|
||||
fi
|
||||
@@ -89,7 +94,7 @@ jobs:
|
||||
security-events: write # SARIF upload: findings land in the Code scanning tab next to CodeQL
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -464,7 +464,7 @@ jobs:
|
||||
# Skipped on runs that publish nothing -- no log entry for bytes nobody receives.
|
||||
- name: Attest build provenance
|
||||
if: inputs.upload_blob || inputs.create_release
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: |
|
||||
${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
@@ -472,13 +472,13 @@ jobs:
|
||||
staging/agent/AutopilotMonitor.SummaryDialog.exe
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
|
||||
path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload version metadata
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -240,12 +240,12 @@ jobs:
|
||||
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (MSI)
|
||||
if: inputs.upload_blob
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
|
||||
+14
-11
@@ -98,11 +98,11 @@ jobs:
|
||||
# windows-latest is required: the agent test projects target net48.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -124,6 +124,9 @@ jobs:
|
||||
- name: Summary Dialog tests
|
||||
run: dotnet test src/Agent/AutopilotMonitor.SummaryDialog.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
|
||||
|
||||
- name: Push tests
|
||||
run: dotnet test src/Push/AutopilotMonitor.Push.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
|
||||
|
||||
web:
|
||||
name: Web (typecheck + tests)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -131,11 +134,11 @@ jobs:
|
||||
run:
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -167,11 +170,11 @@ jobs:
|
||||
run:
|
||||
working-directory: src/McpServer/autopilot-monitor-mcp
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -189,7 +192,7 @@ jobs:
|
||||
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
|
||||
# is honored by vector-search-provider.ts.
|
||||
- name: Cache embedding model
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ github.workspace }}/.hf-cache
|
||||
key: hf-model-Xenova-all-MiniLM-L6-v2
|
||||
@@ -203,11 +206,11 @@ jobs:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -226,11 +229,11 @@ jobs:
|
||||
name: Generated wire types match shared-manifests.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
@@ -104,11 +104,11 @@ jobs:
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
if: matrix.language == 'csharp'
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -24,49 +24,26 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Lockfile and no lifecycle scripts: this job holds contents:write and pushes to
|
||||
# main, so an install-time script from a hijacked package would run with a token that
|
||||
# can rewrite the repository. rules/scripts/package-lock.json pins ajv-cli, ajv-formats
|
||||
# can rewrite the repository. rules/scripts/package-lock.json pins ajv, ajv-formats
|
||||
# and every transitive package (Dependabot keeps it current); ajv is pure JS and
|
||||
# needs no install hooks.
|
||||
- name: Install ajv-cli from the lockfile
|
||||
- name: Install the rules validator from the lockfile
|
||||
working-directory: rules/scripts
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Validate Gather Rules
|
||||
run: |
|
||||
for f in rules/gather/*.json; do
|
||||
echo "Validating $f..."
|
||||
ajv validate -s rules/schema/gather-rule.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
- name: Validate Analyze Rules
|
||||
run: |
|
||||
for f in rules/analyze/*.json; do
|
||||
echo "Validating $f..."
|
||||
ajv validate -s rules/schema/analyze-rule.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
- name: Validate IME Log Patterns
|
||||
run: |
|
||||
for f in rules/ime-log-patterns/*.json; do
|
||||
echo "Validating $f..."
|
||||
ajv validate -s rules/schema/ime-log-pattern.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
# guardrails.json values are baked verbatim into generated TypeScript that is
|
||||
# auto-committed to main and shipped to the portal - the schema forbids control
|
||||
# and line-terminator characters so a "data" PR can never become code.
|
||||
- name: Validate Guardrails
|
||||
run: ajv validate -s rules/schema/guardrails.schema.json -d rules/guardrails.json --spec=draft2020 -c ajv-formats
|
||||
# Gather, analyze and IME log pattern files plus guardrails.json, each against its
|
||||
# schema; the guardrails schema is what keeps a "data" PR from becoming code in the
|
||||
# generated TypeScript (see validate.js).
|
||||
- name: Validate rules and guardrails against their schemas
|
||||
run: node rules/scripts/validate.js
|
||||
|
||||
- name: Combine rules into dist/
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Build and Deploy Backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
|
||||
|
||||
- name: Deploy to Azure Functions
|
||||
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1
|
||||
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1.5.7
|
||||
with:
|
||||
app-name: ${{ env.FUNCTION_APP }}
|
||||
package: ./publish
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
name: Build and Deploy MCP Server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
# re-run actually rolls a new revision). /health reports the SHA captured
|
||||
# below so that staleness is checkable.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json
|
||||
# records which docs commit is live.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -140,12 +140,12 @@ jobs:
|
||||
# commit, workflow file) in a public transparency log.
|
||||
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (portal export)
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the storage writes (secret-less)
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
# when someone wants to look at the file.
|
||||
- name: Upload signed scripts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: signed-scripts
|
||||
path: ${{ runner.temp }}/signed-scripts
|
||||
|
||||
@@ -31,6 +31,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.C
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.SummaryDialog.Tests", "src\Agent\AutopilotMonitor.SummaryDialog.Tests\AutopilotMonitor.SummaryDialog.Tests.csproj", "{4F1234C1-9387-4D2C-93E0-713698A2DFCB}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Push", "Push", "{09266D87-DDB8-2F61-A22F-10D1DD295F4F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Push", "src\Push\AutopilotMonitor.Push\AutopilotMonitor.Push.csproj", "{2C3E71E4-0049-45A5-839D-F4171B47D5F3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Push.Tests", "src\Push\AutopilotMonitor.Push.Tests\AutopilotMonitor.Push.Tests.csproj", "{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -161,6 +167,30 @@ Global
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -179,5 +209,8 @@ Global
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{09266D87-DDB8-2F61-A22F-10D1DD295F4F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3} = {09266D87-DDB8-2F61-A22F-10D1DD295F4F}
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302} = {09266D87-DDB8-2F61-A22F-10D1DD295F4F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
+1
-1
Submodule infra updated: be0165a223...f93e475b78
+1
-1
Submodule internal updated: 11c68982fa...be4b922266
@@ -5,7 +5,7 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -17,7 +17,7 @@
|
||||
"confidenceThreshold": 50,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "clock_ahead",
|
||||
"signal": "clock_behind",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
@@ -26,7 +26,7 @@
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "clock_behind",
|
||||
"signal": "clock_ahead",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
|
||||
+3
-3
@@ -1654,7 +1654,7 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -1666,7 +1666,7 @@
|
||||
"confidenceThreshold": 50,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "clock_ahead",
|
||||
"signal": "clock_behind",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
@@ -1675,7 +1675,7 @@
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "clock_behind",
|
||||
"signal": "clock_ahead",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# ajv-cli declares ts-node as a peer dependency it never needs for JSON schemas; do not
|
||||
# pull ts-node + typescript into a job that pushes to main.
|
||||
legacy-peer-deps=true
|
||||
Generated
+1
-226
@@ -6,7 +6,7 @@
|
||||
"": {
|
||||
"name": "autopilot-monitor-rules-tooling",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
}
|
||||
},
|
||||
@@ -27,33 +27,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-cli": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-cli/-/ajv-cli-5.0.0.tgz",
|
||||
"integrity": "sha512-LY4m6dUv44HTyhV+u2z5uX4EhPYTM38Iv1jdgDJJJCyOOuqB8KtZEGjPZ2T+sh5ZIJrXUfgErYx/j3gLd3+PlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0",
|
||||
"fast-json-patch": "^2.0.0",
|
||||
"glob": "^7.1.0",
|
||||
"js-yaml": "^3.14.0",
|
||||
"json-schema-migrate": "^2.0.0",
|
||||
"json5": "^2.1.3",
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"ajv": "dist/index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ts-node": ">=9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ts-node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
@@ -72,55 +45,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -128,13 +52,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-patch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz",
|
||||
"integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
@@ -152,78 +69,6 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.15.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
|
||||
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-migrate": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz",
|
||||
"integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -231,62 +76,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -296,20 +85,6 @@
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"private": true,
|
||||
"description": "Validator tooling for rules/ - combine-rules.yml installs from the lockfile so nothing unpinned reaches a job that pushes to main.",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-json-patch": "3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validates every rule file and rules/guardrails.json against its JSON Schema
|
||||
* (draft 2020-12). combine-rules.yml runs this before combine.js; any invalid
|
||||
* file fails the run with exit code 1.
|
||||
*
|
||||
* Run: node rules/scripts/validate.js (after `npm ci --ignore-scripts` in rules/scripts)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv2020 = require('ajv/dist/2020');
|
||||
const addFormats = require('ajv-formats');
|
||||
|
||||
const rulesRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const jsonFilesIn = (dir) =>
|
||||
fs.readdirSync(path.join(rulesRoot, dir)).filter(f => f.endsWith('.json')).sort().map(f => `${dir}/${f}`);
|
||||
|
||||
const sets = [
|
||||
{ schema: 'gather-rule.schema.json', files: jsonFilesIn('gather') },
|
||||
{ schema: 'analyze-rule.schema.json', files: jsonFilesIn('analyze') },
|
||||
{ schema: 'ime-log-pattern.schema.json', files: jsonFilesIn('ime-log-patterns') },
|
||||
// guardrails.json values are baked verbatim into generated TypeScript that is
|
||||
// auto-committed to main and shipped to the portal - the schema forbids control
|
||||
// and line-terminator characters so a "data" PR can never become code.
|
||||
{ schema: 'guardrails.schema.json', files: ['guardrails.json'] }
|
||||
];
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true });
|
||||
addFormats(ajv);
|
||||
|
||||
let invalid = 0;
|
||||
|
||||
for (const set of sets) {
|
||||
const validate = ajv.compile(JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', set.schema), 'utf8')));
|
||||
|
||||
for (const file of set.files) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(path.join(rulesRoot, file), 'utf8'));
|
||||
} catch (err) {
|
||||
invalid++;
|
||||
console.error(`INVALID rules/${file}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validate(data)) {
|
||||
invalid++;
|
||||
console.error(`INVALID rules/${file}`);
|
||||
for (const e of validate.errors) {
|
||||
console.error(` ${e.instancePath || '(root)'} ${e.message} ${JSON.stringify(e.params)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${set.schema}: ${set.files.length} files checked`);
|
||||
}
|
||||
|
||||
if (invalid > 0) {
|
||||
console.error(`ERROR: ${invalid} file(s) failed schema validation`);
|
||||
process.exit(1);
|
||||
}
|
||||
+1
-1
@@ -144,7 +144,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
Assert.Equal(4, svc.LastFetchAttempts);
|
||||
}
|
||||
|
||||
// ── Network-link gate (tenant aebdce78 audit 2026-08-24) ────────────
|
||||
// ── Network-link gate (kiosk-tenant audit 2026-08-24) ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FetchConfig_InitialFetch_WaitsForNetworkLink()
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Uninstall-key strings are read the way Windows shows them: up to the first NUL. Installers
|
||||
/// that pad REG_SZ data with NULs otherwise leak them into the normalized vendor/name, which
|
||||
/// the backend turns into a table key.
|
||||
/// </summary>
|
||||
public sealed class SoftwareInventoryRegistryTextTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Contoso Widget", "Contoso Widget")]
|
||||
[InlineData("Contoso Widget\0\0\0\0\0", "Contoso Widget")]
|
||||
[InlineData("Contoso\0stale buffer", "Contoso")]
|
||||
[InlineData("\0\0\0", "")]
|
||||
[InlineData("", "")]
|
||||
public void CutAtNul_KeepsTheTextBeforeTheFirstNul(string raw, string expected)
|
||||
{
|
||||
Assert.Equal(expected, SoftwareInventoryAnalyzer.CutAtNul(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutAtNul_Null_StaysNull()
|
||||
{
|
||||
Assert.Null(SoftwareInventoryAnalyzer.CutAtNul(null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -174,7 +174,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
[Fact]
|
||||
public void Reevaluate_does_not_re_emit_starved_warnings()
|
||||
{
|
||||
// sits-d Cloud-PC fix (2026-08-19): the synthesis re-check now runs on EVERY IME
|
||||
// Cloud-PC fix (2026-08-19): the synthesis re-check now runs on EVERY IME
|
||||
// app-state transition. It must not turn the one-shot starvation warning into a
|
||||
// per-transition stream — the re-check path suppresses the emission entirely
|
||||
// (the per-appId dedupe would swallow the duplicates, but walking the probe
|
||||
@@ -198,7 +198,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
[Fact]
|
||||
public void Starved_apps_that_settle_later_still_complete_the_enrollment()
|
||||
{
|
||||
// The real sits-d shape: the ESP exits with apps still starved (warning emitted),
|
||||
// The real field shape: the ESP exits with apps still starved (warning emitted),
|
||||
// the apps then all settle, and the re-check must open the gate — the warning
|
||||
// already on the timeline is history, not a permanent veto.
|
||||
using var f = new Fixture();
|
||||
|
||||
+2
-2
@@ -172,8 +172,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// sits-d Cloud-PC fix (2026-08-19) — the synthesis is no longer edge-only.
|
||||
// Sessions 8110e262 / a89aac2d: the ESP page exited while 138 required user-ESP apps
|
||||
// Cloud-PC fix (2026-08-19) — the synthesis is no longer edge-only.
|
||||
// Two field sessions: the ESP page exited while 138 required user-ESP apps
|
||||
// were still in flight, they all reached a terminal state (0 failed) minutes later,
|
||||
// and because nothing re-checked, the enrollment never completed.
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
+4
-3
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
@@ -173,16 +174,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
public void UpdatePageRecords_ArePageStartsStopsAndNames_OfTheUpdatePageOnly()
|
||||
{
|
||||
var start = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStarted, NdupStarted, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStarted, start!.CxhEvent);
|
||||
Assert.Equal(OobeUpdateVocabulary.PageStarted, start!.CxhEvent);
|
||||
Assert.Equal("OobeNDUP", start.Page);
|
||||
|
||||
var stop = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppStopped, PageStopped, lastPage: "RebootNDUP");
|
||||
Assert.Equal(OobeUpdatePageRecord.PageStopped, stop!.CxhEvent);
|
||||
Assert.Equal(OobeUpdateVocabulary.PageStopped, stop!.CxhEvent);
|
||||
Assert.Equal("RebootNDUP", stop.Page);
|
||||
Assert.Equal("success", stop.Result);
|
||||
|
||||
var name = ShellCoreTracker.ParseUpdatePageRecord(ShellCoreTracker.EventId_ShellCore_WebAppEventName, ScanStartedName, lastPage: null);
|
||||
Assert.Equal(OobeUpdatePageRecord.EventName, name!.CxhEvent);
|
||||
Assert.Equal(OobeUpdateVocabulary.EventName, name!.CxhEvent);
|
||||
Assert.Equal("ExpeditedUpdate_startWUScanStarted", name.Name);
|
||||
Assert.Null(name.Value);
|
||||
|
||||
|
||||
+17
-16
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
@@ -56,7 +57,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void Page_IsReported_WithItsEventTime_AndFields()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"),
|
||||
new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStopped, 62405, page: "OobeNDUP", result: "success"),
|
||||
At.AddMinutes(-3), isBackfill: true);
|
||||
|
||||
var page = Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage));
|
||||
@@ -75,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void EventName_CarriesItsSafeValue()
|
||||
{
|
||||
Build().ReportPage(
|
||||
new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62407, name: "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", value: "true"),
|
||||
new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62407, name: "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded", value: "true"),
|
||||
At, isBackfill: false);
|
||||
|
||||
var data = Data(Assert.Single(ByType(Constants.EventTypes.OobeUpdatePage)));
|
||||
@@ -89,9 +90,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_progress"), At.AddSeconds(i), isBackfill: false);
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: "ExpeditedUpdate_progress"), At.AddSeconds(i), isBackfill: false);
|
||||
}
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "OobeNDUP", result: "success"), At.AddMinutes(1), isBackfill: false);
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStopped, 62405, page: "OobeNDUP", result: "success"), At.AddMinutes(1), isBackfill: false);
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey + 1, pages.Count);
|
||||
@@ -106,14 +107,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun + 20; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At.AddSeconds(i), isBackfill: false);
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At.AddSeconds(i), isBackfill: false);
|
||||
}
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun + 1, pages.Count);
|
||||
// A name missing after the marker proves nothing — the rules check for it.
|
||||
var marker = Data(pages.Last());
|
||||
Assert.Equal(OobeUpdatePageRecord.NamesCapped, marker["cxhEvent"]);
|
||||
Assert.Equal(OobeUpdateVocabulary.NamesCapped, marker["cxhEvent"]);
|
||||
Assert.Equal(false, marker["backfill"]);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun, marker["limit"]);
|
||||
Assert.Equal(At.AddSeconds(OobeUpdateTelemetry.MaxPageEventsPerRun), pages.Last().OccurredAtUtc); // the first name over the budget
|
||||
@@ -126,13 +127,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxBackfillPageEventsPerRun + 5; i++)
|
||||
{
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_old{i}"), At.AddMinutes(-30), isBackfill: true);
|
||||
telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: $"ExpeditedUpdate_old{i}"), At.AddMinutes(-30), isBackfill: true);
|
||||
}
|
||||
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_live"), At, isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: "ExpeditedUpdate_live"), At, isBackfill: false));
|
||||
|
||||
var markers = ByType(Constants.EventTypes.OobeUpdatePage)
|
||||
.Where(p => (string)Data(p)["cxhEvent"] == OobeUpdatePageRecord.NamesCapped).ToList();
|
||||
.Where(p => (string)Data(p)["cxhEvent"] == OobeUpdateVocabulary.NamesCapped).ToList();
|
||||
var marker = Assert.Single(markers);
|
||||
Assert.Equal(true, Data(marker)["backfill"]);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxBackfillPageEventsPerRun, Data(marker)["limit"]);
|
||||
@@ -145,17 +146,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
var telemetry = Build();
|
||||
for (var i = 0; i < OobeUpdateTelemetry.MaxPageEventsPerRun; i++)
|
||||
{
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: $"ExpeditedUpdate_step{i}"), At, isBackfill: false));
|
||||
}
|
||||
|
||||
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_late"), At, isBackfill: false));
|
||||
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, 62406, name: "ExpeditedUpdate_later"), At, isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "RebootNDUP"), At.AddMinutes(1), isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, 62405, page: "RebootNDUP", result: "success"), At.AddMinutes(2), isBackfill: false));
|
||||
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: "ExpeditedUpdate_late"), At, isBackfill: false));
|
||||
Assert.False(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, 62406, name: "ExpeditedUpdate_later"), At, isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStarted, 62404, page: "RebootNDUP"), At.AddMinutes(1), isBackfill: false));
|
||||
Assert.True(telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStopped, 62405, page: "RebootNDUP", result: "success"), At.AddMinutes(2), isBackfill: false));
|
||||
|
||||
var pages = ByType(Constants.EventTypes.OobeUpdatePage);
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerRun + 3, pages.Count);
|
||||
Assert.Equal(new object[] { OobeUpdatePageRecord.NamesCapped, "page_started", "page_stopped" },
|
||||
Assert.Equal(new object[] { OobeUpdateVocabulary.NamesCapped, "page_started", "page_stopped" },
|
||||
pages.Skip(OobeUpdateTelemetry.MaxPageEventsPerRun).Select(p => Data(p)["cxhEvent"]).ToArray());
|
||||
}
|
||||
|
||||
@@ -165,7 +166,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// Outside the run budget, but a page that restarts in a loop still cannot flood.
|
||||
var telemetry = Build();
|
||||
var reported = Enumerable.Range(0, 10)
|
||||
.Count(i => telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, 62404, page: "OobeNDUP"), At.AddMinutes(i), isBackfill: false));
|
||||
.Count(i => telemetry.ReportPage(new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStarted, 62404, page: "OobeNDUP"), At.AddMinutes(i), isBackfill: false));
|
||||
|
||||
Assert.Equal(OobeUpdateTelemetry.MaxPageEventsPerKey, reported);
|
||||
}
|
||||
|
||||
+83
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -49,6 +50,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
private static EmergencyReporter Reporter(FakeApiClient client)
|
||||
=> new EmergencyReporter(client, "session", "tenant", "0.0.0", logger: null);
|
||||
|
||||
private static EmergencyReporter Reporter(FakeApiClient client, VirtualClock clock)
|
||||
=> new EmergencyReporter(client, "session", "tenant", "0.0.0", logger: null, clock: clock);
|
||||
|
||||
private static VirtualClock Clock() => new VirtualClock(new DateTime(2026, 10, 9, 10, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
[Fact]
|
||||
public async Task Session_unknown_report_is_not_suppressed_by_the_cooldown_of_an_earlier_report()
|
||||
{
|
||||
// Terminal report, sent at most once per process right before the termination — a
|
||||
// routine report one minute earlier must not cost it. Every other type keeps the cooldown.
|
||||
var clock = Clock();
|
||||
var client = new FakeApiClient();
|
||||
client.Results.Enqueue(true);
|
||||
client.Results.Enqueue(true);
|
||||
client.Results.Enqueue(true);
|
||||
var reporter = Reporter(client, clock);
|
||||
|
||||
await reporter.TrySendAsync(AgentErrorType.IngestFailed, "m", httpStatusCode: 503);
|
||||
clock.Advance(TimeSpan.FromMinutes(1));
|
||||
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
|
||||
|
||||
Assert.Equal(2, client.Calls);
|
||||
Assert.Equal(AgentErrorType.TelemetryRejectedSessionUnknown, client.Reports[1].ErrorType);
|
||||
|
||||
// Control: a third report of another type inside the window is still held back.
|
||||
clock.Advance(TimeSpan.FromMinutes(1));
|
||||
await reporter.TrySendAsync(AgentErrorType.ConfigFetchFailed, "m");
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_session_unknown_report_within_the_window_is_still_suppressed()
|
||||
{
|
||||
var clock = Clock();
|
||||
var client = new FakeApiClient();
|
||||
client.Results.Enqueue(true);
|
||||
client.Results.Enqueue(true);
|
||||
var reporter = Reporter(client, clock);
|
||||
|
||||
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
|
||||
clock.Advance(TimeSpan.FromMinutes(1));
|
||||
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
|
||||
|
||||
Assert.Equal(1, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_unknown_report_still_counts_against_the_per_session_maximum()
|
||||
{
|
||||
// Five delivered reports (ten minutes apart, distinct keys) exhaust the budget; the
|
||||
// exemption waives only the cooldown, not the maximum.
|
||||
var clock = Clock();
|
||||
var client = new FakeApiClient();
|
||||
for (var i = 0; i < 6; i++) client.Results.Enqueue(true);
|
||||
var reporter = Reporter(client, clock);
|
||||
|
||||
foreach (var status in new[] { 500, 502, 503, 504, 429 })
|
||||
{
|
||||
await reporter.TrySendAsync(AgentErrorType.IngestFailed, "m", httpStatusCode: status);
|
||||
clock.Advance(TimeSpan.FromMinutes(10));
|
||||
}
|
||||
Assert.Equal(5, client.Calls);
|
||||
|
||||
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
|
||||
|
||||
Assert.Equal(5, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_send_rolls_back_reservation_so_a_later_call_retries()
|
||||
{
|
||||
@@ -142,6 +211,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
|
||||
Assert.Equal(2, client.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_unknown_report_carries_error_type_and_http_410()
|
||||
{
|
||||
var client = new FakeApiClient();
|
||||
client.Results.Enqueue(true);
|
||||
var reporter = Reporter(client);
|
||||
|
||||
await reporter.TrySendAsync(AgentErrorType.TelemetryRejectedSessionUnknown, "m", httpStatusCode: 410);
|
||||
|
||||
var report = Assert.Single(client.Reports);
|
||||
Assert.Equal(AgentErrorType.TelemetryRejectedSessionUnknown, report.ErrorType);
|
||||
Assert.Equal(410, report.HttpStatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_age_is_carried_in_the_report_and_absent_by_default()
|
||||
{
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
[Fact]
|
||||
public void Ime_app_state_callback_is_chained_into_the_user_apps_settled_recheck()
|
||||
{
|
||||
// sits-d Cloud-PC fix (2026-08-19): the AccountSetup synthesis is re-evaluated on every
|
||||
// Cloud-PC fix (2026-08-19): the AccountSetup synthesis is re-evaluated on every
|
||||
// terminal IME app transition, which only works while the factory keeps this chain
|
||||
// wired. It is five lines that a refactor can drop silently and whose absence costs a
|
||||
// whole class of sessions their completion — so pin it here.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Contract tests for <see cref="AgentBootstrap.RetrySelfUpdateAfterEnrollment"/> —
|
||||
/// the post-cert one-shot self-update retry (field case sits-d.cloud, 2026-08-09):
|
||||
/// the post-cert one-shot self-update retry (field case 2026-08-09):
|
||||
/// guard on <see cref="SelfUpdater.LastVersionCheckSucceeded"/>, the relaxed
|
||||
/// timeouts/trigger it passes to the updater, and the swallow-everything exception
|
||||
/// contract (bootstrap must continue on any updater failure). Uses the
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal("FromCache", data["outcome"]);
|
||||
}
|
||||
|
||||
// ── Post-registration recovery (Phase 6.5, tenant aebdce78 audit 2026-08-24) ─
|
||||
// ── Post-registration recovery (Phase 6.5, kiosk-tenant audit 2026-08-24) ─
|
||||
|
||||
[Fact]
|
||||
public void EmitFetchFailedIfAny_AfterRecovery_MarksRecoveredAndDemotesToInfo()
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Host wiring for <c>TelemetryUploadOrchestrator.SessionGone</c>
|
||||
/// (HTTP 410 on the current session's telemetry): exactly one
|
||||
/// <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> emergency report carrying the
|
||||
/// status, then a synthetic forced <c>terminate_session</c> tagged <c>origin=session_gone</c>
|
||||
/// — and nothing at all once a shutdown is already in progress.
|
||||
/// </summary>
|
||||
public sealed class ServerControlPlaneSessionGoneTests
|
||||
{
|
||||
private sealed class RecordingEmergencyReporter : EmergencyReporter
|
||||
{
|
||||
private readonly List<string> _sequence;
|
||||
public List<(AgentErrorType ErrorType, string Message, int? HttpStatusCode)> Reports { get; } =
|
||||
new List<(AgentErrorType, string, int?)>();
|
||||
|
||||
public RecordingEmergencyReporter(List<string> sequence)
|
||||
: base(apiClient: null!, sessionId: "S1", tenantId: "T1", agentVersion: "0.0.0", logger: null!)
|
||||
{
|
||||
_sequence = sequence;
|
||||
}
|
||||
|
||||
public override Task TrySendAsync(
|
||||
AgentErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null,
|
||||
long? sequenceNumber = null,
|
||||
int attempts = 1,
|
||||
TimeSpan? perAttemptTimeout = null,
|
||||
TimeSpan? retryDelay = null,
|
||||
double? sessionAgeHours = null,
|
||||
RegistrationFailureSummary? priorRegistrationFailure = null)
|
||||
{
|
||||
Reports.Add((errorType, message, httpStatusCode));
|
||||
_sequence.Add("report");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingDispatcher : ServerActionDispatcher
|
||||
{
|
||||
private readonly List<string> _sequence;
|
||||
public List<ServerAction> Dispatched { get; } = new List<ServerAction>();
|
||||
|
||||
public RecordingDispatcher(AgentConfiguration config, AgentLogger logger, InformationalEventPost post, List<string> sequence)
|
||||
: base(
|
||||
configuration: config,
|
||||
logger: logger,
|
||||
rotateConfigAsync: () => Task.FromResult(true),
|
||||
uploadDiagnosticsAsync: _ => Task.FromResult<DiagnosticsUploadResult>(null!),
|
||||
onTerminateRequested: _ => Task.CompletedTask,
|
||||
post: post)
|
||||
{
|
||||
_sequence = sequence;
|
||||
}
|
||||
|
||||
public override Task DispatchAsync(List<ServerAction> actions)
|
||||
{
|
||||
Dispatched.AddRange(actions);
|
||||
_sequence.Add("dispatch");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Rig : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public AgentLogger Logger { get; }
|
||||
public List<string> Sequence { get; } = new List<string>();
|
||||
public RecordingEmergencyReporter Reporter { get; }
|
||||
public RecordingDispatcher Dispatcher { get; }
|
||||
public ManualResetEventSlim Shutdown { get; } = new ManualResetEventSlim(false);
|
||||
public ManualResetEventSlim ShutdownComplete { get; } = new ManualResetEventSlim(false);
|
||||
|
||||
public Rig()
|
||||
{
|
||||
Logger = new AgentLogger(Path.Combine(Tmp.Path, "logs"), AgentLogLevel.Info);
|
||||
var config = new AgentConfiguration { SessionId = "S1", TenantId = "T1", ApiBaseUrl = "http://localhost" };
|
||||
var post = new InformationalEventPost(
|
||||
new FakeSignalIngressSink(),
|
||||
new VirtualClock(new DateTime(2026, 10, 9, 10, 0, 0, DateTimeKind.Utc)));
|
||||
Reporter = new RecordingEmergencyReporter(Sequence);
|
||||
Dispatcher = new RecordingDispatcher(config, Logger, post, Sequence);
|
||||
}
|
||||
|
||||
public void Handle(string reason = "session gone: http 410") =>
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleSessionGone(
|
||||
reason, Reporter, Dispatcher, Shutdown, ShutdownComplete, Logger);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Shutdown.Dispose();
|
||||
ShutdownComplete.Dispose();
|
||||
Tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reports_once_with_410_then_dispatches_forced_terminate_tagged_session_gone()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
|
||||
rig.Handle();
|
||||
|
||||
Assert.Equal(new[] { "report", "dispatch" }, rig.Sequence);
|
||||
|
||||
var report = Assert.Single(rig.Reporter.Reports);
|
||||
Assert.Equal(AgentErrorType.TelemetryRejectedSessionUnknown, report.ErrorType);
|
||||
Assert.Equal(410, report.HttpStatusCode);
|
||||
Assert.Contains("410", report.Message);
|
||||
|
||||
var action = Assert.Single(rig.Dispatcher.Dispatched);
|
||||
Assert.Equal(ServerActionTypes.TerminateSession, action.Type);
|
||||
Assert.Contains("410", action.Reason);
|
||||
Assert.NotNull(action.Params);
|
||||
var parameters = action.Params!;
|
||||
Assert.Equal("true", parameters["forceSelfDestruct"]);
|
||||
Assert.Equal("0", parameters["gracePeriodSeconds"]);
|
||||
Assert.Equal(TerminationOrigins.SessionGone, parameters["origin"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
public void Does_nothing_when_a_shutdown_is_already_in_progress(bool shutdownSet, bool shutdownCompleteSet)
|
||||
{
|
||||
using var rig = new Rig();
|
||||
if (shutdownSet) rig.Shutdown.Set();
|
||||
if (shutdownCompleteSet) rig.ShutdownComplete.Set();
|
||||
|
||||
rig.Handle();
|
||||
|
||||
Assert.Empty(rig.Reporter.Reports);
|
||||
Assert.Empty(rig.Dispatcher.Dispatched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOrigin_returns_the_origin_param_or_null()
|
||||
{
|
||||
Assert.Equal(TerminationOrigins.SessionGone,
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(
|
||||
new Dictionary<string, string> { ["origin"] = TerminationOrigins.SessionGone }));
|
||||
Assert.Equal(TerminationOrigins.KillSignal,
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(
|
||||
new Dictionary<string, string> { ["origin"] = TerminationOrigins.KillSignal, ["forceSelfDestruct"] = "true" }));
|
||||
Assert.Null(global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(
|
||||
new Dictionary<string, string> { ["adminOutcome"] = "Failed" }));
|
||||
Assert.Null(global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(
|
||||
new Dictionary<string, string> { ["origin"] = string.Empty }));
|
||||
Assert.Null(global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.ReadOrigin(null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -310,6 +310,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
new EnrollmentTerminatedEventArgs(
|
||||
reason, outcome, stage.ToString(), at ?? EndUtc);
|
||||
|
||||
/// <summary>
|
||||
/// The args <c>ServerControlPlane.OnTerminateRequested</c> synthesises for a
|
||||
/// <c>terminate_session</c> action tagged with <paramref name="origin"/>.
|
||||
/// </summary>
|
||||
private static EnrollmentTerminatedEventArgs ServerArgs(string origin) =>
|
||||
new EnrollmentTerminatedEventArgs(
|
||||
EnrollmentTerminationReason.DecisionTerminalStage,
|
||||
EnrollmentTerminationOutcome.Failed,
|
||||
SessionStage.Failed.ToString(),
|
||||
EndUtc,
|
||||
details: "Server-requested termination",
|
||||
origin: origin);
|
||||
|
||||
[Fact]
|
||||
public void Handle_writes_final_status_json_in_state_directory()
|
||||
{
|
||||
@@ -458,6 +471,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.Equal(0, rig.CleanupService.Invocations);
|
||||
}
|
||||
|
||||
// ============================================================= origin=session_gone (HTTP 410)
|
||||
|
||||
[Fact]
|
||||
public void Handle_session_gone_skips_spool_drain_and_diagnostics_but_still_cleans_up()
|
||||
{
|
||||
// The backend no longer knows the session: every drain would only burn its budget on
|
||||
// 410s and a diagnostics package would describe a deleted session. Marker, cleanup
|
||||
// and the (forced) self-destruct still run.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
|
||||
rig.SpoolDrainPeriodOverride = TimeSpan.FromSeconds(5);
|
||||
var spoolPolls = 0;
|
||||
rig.PendingItemCountAccessor = () => { spoolPolls++; return 5; }; // never drains — a poll would burn the budget
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: true);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!, ServerArgs(TerminationOrigins.SessionGone));
|
||||
|
||||
Assert.Equal(0, spoolPolls);
|
||||
Assert.Equal(0, rig.DiagnosticsUploads);
|
||||
Assert.DoesNotContain(Constants.EventTypes.DiagnosticsCollecting, rig.EmittedEventTypes);
|
||||
Assert.Contains(Constants.EventTypes.AgentShuttingDown, rig.EmittedEventTypes);
|
||||
Assert.True(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
|
||||
Assert.Equal(1, rig.CleanupService.Invocations);
|
||||
Assert.Equal(1, rig.ShutdownSignalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_kill_signal_origin_still_drains_spool_and_uploads_diagnostics()
|
||||
{
|
||||
// The administrator kill signal keeps its full sequence — only session_gone skips.
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
|
||||
rig.SpoolDrainPeriodOverride = TimeSpan.FromSeconds(5);
|
||||
var spoolPolls = 0;
|
||||
rig.PendingItemCountAccessor = () => { spoolPolls++; return 0; };
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: true);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!, ServerArgs(TerminationOrigins.KillSignal));
|
||||
|
||||
Assert.True(spoolPolls > 0);
|
||||
Assert.Equal(1, rig.DiagnosticsUploads);
|
||||
Assert.Contains(Constants.EventTypes.DiagnosticsCollecting, rig.EmittedEventTypes);
|
||||
Assert.Equal(1, rig.CleanupService.Invocations);
|
||||
Assert.Equal(1, rig.ShutdownSignalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_signals_shutdown_exactly_once_even_on_error()
|
||||
{
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
[Fact]
|
||||
public void Succeeded_outcome_is_an_info_about_tail_only_coverage_without_alarm_framing()
|
||||
{
|
||||
// The 659c3a90 shape: healthy enrollment, bootstrap merely ran late in the ESP queue.
|
||||
// The field shape: healthy enrollment, bootstrap merely ran late in the ESP queue.
|
||||
Describe(EnrollmentTerminationOutcome.Succeeded, "in_progress",
|
||||
out var severity, out _, out var note);
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
/// 401/403 classification in <see cref="BackendApiClient"/>: a backend JSON 403 is a real
|
||||
/// device-authorization verdict, while an HTML body means the response came from the Azure
|
||||
/// platform / an edge proxy (stopped or retired app) and must NOT be reported as
|
||||
/// "device is not authorized". Field case sits-d.cloud 2026-08-09: a pre-cutover agent hit
|
||||
/// "device is not authorized". Field case 2026-08-09: a pre-cutover agent hit
|
||||
/// the stopped legacy Function App and support chased a non-existent authorization problem.
|
||||
/// </summary>
|
||||
public sealed class BackendApiClientAuthClassificationTests
|
||||
|
||||
+35
@@ -275,9 +275,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.False(result.IsSessionGone);
|
||||
Assert.Contains(((int)status).ToString(), result.ErrorReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gone_410_maps_to_SessionGone()
|
||||
{
|
||||
// 410 = the backend no longer knows this session. Not transient (no retry), not auth,
|
||||
// not poison, not a size problem — a distinct terminal kind the drain reacts to.
|
||||
using var rig = new Rig();
|
||||
rig.Handler.QueueStatus(HttpStatusCode.Gone, body: "session unknown");
|
||||
|
||||
var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.IsSessionGone);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.False(result.IsAuthFailure);
|
||||
Assert.False(result.IsPoison);
|
||||
Assert.False(result.RequiresSplit);
|
||||
Assert.Contains("410", result.ErrorReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gone_410_with_poison_body_is_still_SessionGone_never_poison()
|
||||
{
|
||||
// A poison body on 410 must not be honoured: dropping items + emitting
|
||||
// telemetry_upload_poisoned would only produce the next 410 (emit/reject loop).
|
||||
using var rig = new Rig();
|
||||
rig.Handler.QueueStatus(HttpStatusCode.Gone, body: "{\"poison\":true,\"rejectedRowKeys\":[\"rk1\"],\"reason\":\"x\"}");
|
||||
|
||||
var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSessionGone);
|
||||
Assert.False(result.IsPoison);
|
||||
Assert.Null(result.PoisonRowKeys);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.RequestTimeout)] // 408
|
||||
[InlineData((HttpStatusCode)429)] // TooManyRequests
|
||||
|
||||
+6
@@ -68,6 +68,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
return this;
|
||||
}
|
||||
|
||||
public FakeBackendTelemetryUploader QueueSessionGone(string reason = "session gone: http 410")
|
||||
{
|
||||
_script.Enqueue(_ => UploadResult.SessionGone(reason));
|
||||
return this;
|
||||
}
|
||||
|
||||
public FakeBackendTelemetryUploader QueueThrow(Exception ex)
|
||||
{
|
||||
_script.Enqueue(_ => throw ex);
|
||||
|
||||
@@ -10,10 +10,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
public sealed class TelemetrySpoolTests
|
||||
{
|
||||
private static TelemetryItemDraft EventDraft(string rowKey = "row-1") =>
|
||||
private const string OwnKey = "tenant_session";
|
||||
private const string ForeignKey = "tenant_old-session";
|
||||
|
||||
private static TelemetryItemDraft EventDraft(string rowKey = "row-1") => EventDraft(rowKey, OwnKey);
|
||||
|
||||
private static TelemetryItemDraft EventDraft(string rowKey, string partitionKey) =>
|
||||
new TelemetryItemDraft(
|
||||
kind: TelemetryItemKind.Event,
|
||||
partitionKey: "tenant_session",
|
||||
partitionKey: partitionKey,
|
||||
rowKey: rowKey,
|
||||
payloadJson: "{\"EventType\":\"agent_started\"}",
|
||||
isSessionScoped: true);
|
||||
@@ -195,6 +200,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Equal(1, next.TelemetryItemId);
|
||||
}
|
||||
|
||||
// ============================================================ Startup partition-key filter
|
||||
|
||||
[Fact]
|
||||
public void Ctor_drops_leading_pending_items_of_another_session_and_persists_cursor()
|
||||
{
|
||||
// Same spool directory, new session id: the previous session's unsent items would only
|
||||
// draw a 410 that must not end the new session. They are dropped on startup; the own
|
||||
// item behind them stays pending.
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("old-a", ForeignKey));
|
||||
s1.Enqueue(EventDraft("old-b", ForeignKey));
|
||||
s1.Enqueue(EventDraft("own", OwnKey));
|
||||
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock(), expectedPartitionKey: OwnKey);
|
||||
|
||||
Assert.Equal(1, s2.LastUploadedItemId);
|
||||
Assert.Equal(2, s2.LastAssignedItemId);
|
||||
Assert.Equal(1, s2.PendingItemCount);
|
||||
Assert.Equal("own", Assert.Single(s2.Peek(10)).RowKey);
|
||||
|
||||
// Cursor persisted: a later start without the filter sees the same tail with the same bytes.
|
||||
var s3 = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(1, s3.LastUploadedItemId);
|
||||
Assert.Equal(1, s3.PendingItemCount);
|
||||
Assert.Equal(s3.PendingBytes, s2.PendingBytes);
|
||||
|
||||
var next = s2.Enqueue(EventDraft("next", OwnKey));
|
||||
Assert.Equal(3, next.TelemetryItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_drops_nothing_when_a_foreign_item_follows_an_own_item()
|
||||
{
|
||||
// Impossible by construction (one process = one session); if it happens the order is
|
||||
// not trustworthy, so the filter keeps everything instead of guessing.
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("old-a", ForeignKey));
|
||||
s1.Enqueue(EventDraft("own", OwnKey));
|
||||
s1.Enqueue(EventDraft("old-b", ForeignKey));
|
||||
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock(), expectedPartitionKey: OwnKey);
|
||||
|
||||
Assert.Equal(-1, s2.LastUploadedItemId);
|
||||
Assert.Equal(3, s2.PendingItemCount);
|
||||
Assert.Equal(s1.PendingBytes, s2.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_keeps_pending_items_that_carry_the_expected_key()
|
||||
{
|
||||
// WhiteGlove Part 2 reuses the Part-1 session id: its leftovers carry the expected key.
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("a", OwnKey));
|
||||
s1.Enqueue(EventDraft("b", OwnKey));
|
||||
s1.Enqueue(EventDraft("c", OwnKey));
|
||||
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock(), expectedPartitionKey: OwnKey);
|
||||
|
||||
Assert.Equal(-1, s2.LastUploadedItemId);
|
||||
Assert.Equal(3, s2.PendingItemCount);
|
||||
Assert.Equal(s1.PendingBytes, s2.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_without_expected_key_keeps_foreign_items()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("old-a", ForeignKey));
|
||||
s1.Enqueue(EventDraft("old-b", ForeignKey));
|
||||
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock());
|
||||
|
||||
Assert.Equal(-1, s2.LastUploadedItemId);
|
||||
Assert.Equal(2, s2.PendingItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_with_RequiresImmediateFlush_raises_ImmediateFlushRequested()
|
||||
{
|
||||
|
||||
+118
-2
@@ -10,10 +10,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
public sealed class TelemetryUploadOrchestratorTests
|
||||
{
|
||||
private static TelemetryItemDraft Draft(string rowKey) =>
|
||||
private const string OwnKey = "tenant_session";
|
||||
|
||||
private static TelemetryItemDraft Draft(string rowKey) => Draft(rowKey, OwnKey);
|
||||
|
||||
private static TelemetryItemDraft Draft(string rowKey, string partitionKey) =>
|
||||
new TelemetryItemDraft(
|
||||
kind: TelemetryItemKind.Event,
|
||||
partitionKey: "tenant_session",
|
||||
partitionKey: partitionKey,
|
||||
rowKey: rowKey,
|
||||
payloadJson: $"{{\"rk\":\"{rowKey}\"}}",
|
||||
isSessionScoped: true);
|
||||
@@ -157,6 +161,118 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Equal(1, blockedCount);
|
||||
}
|
||||
|
||||
// ============================================================ HTTP 410 — session gone
|
||||
|
||||
[Fact]
|
||||
public async Task SessionGone_for_current_session_stops_drain_retains_cursor_and_raises_once()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
spool.Enqueue(Draft("a"));
|
||||
spool.Enqueue(Draft("b"));
|
||||
|
||||
var uploader = new FakeBackendTelemetryUploader().QueueSessionGone().QueueSessionGone();
|
||||
using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock(), currentPartitionKey: OwnKey);
|
||||
var gone = new System.Collections.Generic.List<string>();
|
||||
int blockedCount = 0;
|
||||
PoisonReport? poison = null;
|
||||
sut.SessionGone += (_, reason) => gone.Add(reason);
|
||||
sut.UploadBlocked += (_, __) => blockedCount++;
|
||||
sut.BatchPoisoned += (_, p) => poison = p;
|
||||
|
||||
var first = await sut.DrainAllAsync();
|
||||
|
||||
Assert.False(first.Success);
|
||||
Assert.Equal(1, first.FailedBatches);
|
||||
Assert.Equal(0, first.UploadedItems);
|
||||
Assert.Equal(1, uploader.CallCount); // no retries — nothing can make a 410 succeed
|
||||
Assert.Equal(-1, sut.LastUploadedItemId); // retained — items stay on disk for the diag ZIP
|
||||
Assert.Single(gone);
|
||||
Assert.Contains("410", gone[0]);
|
||||
Assert.Equal(0, blockedCount); // not a "blocked" pipeline
|
||||
Assert.Null(poison);
|
||||
|
||||
// One-shot: the next drain hits the 410 again but does not raise again.
|
||||
var second = await sut.DrainAllAsync();
|
||||
Assert.False(second.Success);
|
||||
Assert.Equal(2, uploader.CallCount);
|
||||
Assert.Equal(-1, sut.LastUploadedItemId);
|
||||
Assert.Single(gone);
|
||||
Assert.Equal(0, blockedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionGone_for_leading_foreign_items_drops_only_them_and_continues()
|
||||
{
|
||||
// Leftovers of a previous session id at the head of the spool (defence in depth — the
|
||||
// spool filters these at startup). A 410 for them must not end the current session:
|
||||
// drop exactly the leading foreign run, then carry on with the current session's items.
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
spool.Enqueue(Draft("old-a", "tenant_old-session"));
|
||||
spool.Enqueue(Draft("old-b", "tenant_old-session"));
|
||||
spool.Enqueue(Draft("new", OwnKey));
|
||||
|
||||
var uploader = new FakeBackendTelemetryUploader().QueueSessionGone().QueueOk();
|
||||
using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock(), currentPartitionKey: OwnKey);
|
||||
int goneCount = 0, blockedCount = 0;
|
||||
sut.SessionGone += (_, __) => goneCount++;
|
||||
sut.UploadBlocked += (_, __) => blockedCount++;
|
||||
|
||||
var result = await sut.DrainAllAsync();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(0, result.FailedBatches);
|
||||
Assert.Equal(1, result.UploadedItems);
|
||||
Assert.Equal(2, uploader.CallCount);
|
||||
Assert.Equal(3, uploader.Received[0].Count); // first batch carried all three
|
||||
Assert.Equal("new", Assert.Single(uploader.Received[1]).RowKey); // retry carried only the own item
|
||||
Assert.Equal(2, sut.LastUploadedItemId);
|
||||
Assert.Equal(0, goneCount);
|
||||
Assert.Equal(0, blockedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionGone_is_not_latched_before_a_handler_is_subscribed()
|
||||
{
|
||||
// The host subscribes after EnrollmentOrchestrator.Start; a 410 on the very first drain
|
||||
// must still reach it on the next drain instead of being swallowed by the one-shot guard.
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
spool.Enqueue(Draft("only"));
|
||||
|
||||
var uploader = new FakeBackendTelemetryUploader().QueueSessionGone().QueueSessionGone();
|
||||
using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock(), currentPartitionKey: OwnKey);
|
||||
|
||||
await sut.DrainAllAsync(); // nobody listening yet
|
||||
|
||||
int goneCount = 0;
|
||||
sut.SessionGone += (_, __) => goneCount++;
|
||||
await sut.DrainAllAsync();
|
||||
|
||||
Assert.Equal(1, goneCount);
|
||||
Assert.Equal(-1, sut.LastUploadedItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionGone_without_a_current_key_treats_every_batch_as_own()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
spool.Enqueue(Draft("x", "tenant_whatever"));
|
||||
|
||||
var uploader = new FakeBackendTelemetryUploader().QueueSessionGone();
|
||||
using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock());
|
||||
int goneCount = 0;
|
||||
sut.SessionGone += (_, __) => goneCount++;
|
||||
|
||||
var result = await sut.DrainAllAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(1, goneCount);
|
||||
Assert.Equal(-1, sut.LastUploadedItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TooLarge_splits_batch_and_uploads_everything()
|
||||
{
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// Upper bound for the pre-fetch network-link wait on the initial startup fetch.
|
||||
/// Deliberately longer than the registration gate's 15s: the fetch runs FIRST after a
|
||||
/// boot-time relaunch, when Wi-Fi may still be associating (802.1X, OOBE), and a fetch
|
||||
/// burned into a dead link strands the whole session on built-in defaults — tenant aebdce78
|
||||
/// burned into a dead link strands the whole session on built-in defaults — kiosk-tenant
|
||||
/// audit 2026-08-24: ~50% of sessions lost their tenant config exactly this way.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan InitialFetchNetworkLinkWaitMax = TimeSpan.FromSeconds(60);
|
||||
|
||||
+3
-3
@@ -50,12 +50,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
// Session caa6cf50 gate-starvation fix (2026-06-11) — fire-once guard for the
|
||||
// user-ESP-apps-settled AccountSetup synthesis (see MaybeSynthesizeAccountSetupComplete).
|
||||
// Interlocked rather than a plain bool: since the sits-d Cloud-PC fix the synthesis is
|
||||
// Interlocked rather than a plain bool: since the Cloud-PC fix the synthesis is
|
||||
// ALSO re-evaluated from the IME app-state-change thread, so two threads can reach the
|
||||
// guard concurrently (Shell-Core watcher thread + IME log thread).
|
||||
private int _userAppsSettledSynthesisFired;
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19) — set once a CONFIRMED post-AccountSetup Shell-Core
|
||||
// Cloud-PC fix (2026-08-19) — set once a CONFIRMED post-AccountSetup Shell-Core
|
||||
// exit has been observed (live OR backfill). The synthesis needs two facts: the ESP exit
|
||||
// is an EDGE that happens once, the settled user-ESP apps are a LEVEL that may only be
|
||||
// reached minutes later. Recording the edge lets ReevaluateUserAppsSettledSynthesis
|
||||
@@ -374,7 +374,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
=> _shellCoreTracker?.BackfillUpdatePageTelemetry(lookbackMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// sits-d Cloud-PC fix (2026-08-19) — re-check the user-apps-settled AccountSetup
|
||||
/// Cloud-PC fix (2026-08-19) — re-check the user-apps-settled AccountSetup
|
||||
/// synthesis after the ESP exit has already been observed. Wired to the IME tracker's
|
||||
/// app-state-change callback in <c>DefaultComponentFactory</c>.
|
||||
/// <para>
|
||||
|
||||
+9
-16
@@ -14,16 +14,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
internal sealed class OobeUpdatePageRecord
|
||||
{
|
||||
public const string PageStarted = "page_started";
|
||||
public const string PageStopped = "page_stopped";
|
||||
public const string EventName = "event_name";
|
||||
|
||||
/// <summary>
|
||||
/// Not a record: the <c>cxhEvent</c> of the marker <see cref="OobeUpdateTelemetry"/> sends
|
||||
/// once per budget when the event names of a run start staying in agent.log.
|
||||
/// </summary>
|
||||
public const string NamesCapped = "names_capped";
|
||||
|
||||
public OobeUpdatePageRecord(string cxhEvent, int windowsEventId, string page = null, string result = null, string name = null, string value = null)
|
||||
{
|
||||
CxhEvent = cxhEvent;
|
||||
@@ -34,7 +24,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary><see cref="PageStarted"/>, <see cref="PageStopped"/> or <see cref="EventName"/>.</summary>
|
||||
/// <summary>
|
||||
/// <see cref="OobeUpdateVocabulary.PageStarted"/>, <see cref="OobeUpdateVocabulary.PageStopped"/> or
|
||||
/// <see cref="OobeUpdateVocabulary.EventName"/> (the shared vocabulary the backend and the portal read).
|
||||
/// </summary>
|
||||
public string CxhEvent { get; }
|
||||
public int WindowsEventId { get; }
|
||||
|
||||
@@ -116,7 +109,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <see cref="MaxPageEventsPerKey"/> per page/name — a page that logs the same name in a
|
||||
/// loop cannot flood the session. Event names also share a budget per run: live ones
|
||||
/// <see cref="MaxPageEventsPerRun"/>, backfilled ones <see cref="MaxBackfillPageEventsPerRun"/>;
|
||||
/// the first name over a budget sends the <see cref="OobeUpdatePageRecord.NamesCapped"/>
|
||||
/// the first name over a budget sends the <see cref="OobeUpdateVocabulary.NamesCapped"/>
|
||||
/// marker. A page start or stop is outside the budgets, because the time attribution reads
|
||||
/// the update's span from them. True when the record was reported.
|
||||
/// </summary>
|
||||
@@ -124,7 +117,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (record == null) return false;
|
||||
|
||||
var isEventName = record.CxhEvent == OobeUpdatePageRecord.EventName;
|
||||
var isEventName = record.CxhEvent == OobeUpdateVocabulary.EventName;
|
||||
var key = $"{record.CxhEvent}:{record.Page ?? record.Name}";
|
||||
var budget = isBackfill ? MaxBackfillPageEventsPerRun : MaxPageEventsPerRun;
|
||||
var occurrence = 0;
|
||||
@@ -189,7 +182,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
$"OOBE update page: {budget} {kind}event names reached, later ones of this agent run are not sent",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "cxhEvent", OobeUpdatePageRecord.NamesCapped },
|
||||
{ "cxhEvent", OobeUpdateVocabulary.NamesCapped },
|
||||
{ "eventTime", occurredAtUtc.ToString("o") },
|
||||
{ "backfill", isBackfill },
|
||||
{ "limit", budget },
|
||||
@@ -293,9 +286,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
switch (record.CxhEvent)
|
||||
{
|
||||
case OobeUpdatePageRecord.PageStarted:
|
||||
case OobeUpdateVocabulary.PageStarted:
|
||||
return $"OOBE update page started: {record.Page}";
|
||||
case OobeUpdatePageRecord.PageStopped:
|
||||
case OobeUpdateVocabulary.PageStopped:
|
||||
return $"OOBE update page stopped: {record.Page} (result: {record.Result})";
|
||||
default:
|
||||
return record.Value == null
|
||||
|
||||
+6
-6
@@ -224,27 +224,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_InitiatingChanges:
|
||||
step = "initiating";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.InstallStarted
|
||||
step = OobeUpdateVocabulary.StepInitiating;
|
||||
osStep = IsState(targetState, OobeUpdateVocabulary.InstalledState) ? OsUpdateSteps.InstallStarted
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.StagingStarted
|
||||
: null;
|
||||
message = $"Servicing started for {pkg}: {(string.IsNullOrEmpty(initialState) ? "?" : initialState)} → {target}";
|
||||
break;
|
||||
case EventId_StateReached:
|
||||
step = "state_reached";
|
||||
osStep = IsState(targetState, "Installed") ? OsUpdateSteps.Installed
|
||||
step = OobeUpdateVocabulary.StepStateReached;
|
||||
osStep = IsState(targetState, OobeUpdateVocabulary.InstalledState) ? OsUpdateSteps.Installed
|
||||
: IsState(targetState, "Staged") ? OsUpdateSteps.Staged
|
||||
: null;
|
||||
message = $"Servicing finished for {pkg}: now {target}";
|
||||
break;
|
||||
case EventId_RebootRequired:
|
||||
step = "reboot_required";
|
||||
step = OobeUpdateVocabulary.StepRebootRequired;
|
||||
osStep = OsUpdateSteps.RebootRequired;
|
||||
message = $"Servicing needs a restart to finish {pkg} → {target}";
|
||||
break;
|
||||
case EventId_ChangeFailed:
|
||||
case EventId_PartiallyInstalled:
|
||||
step = "failed";
|
||||
step = OobeUpdateVocabulary.StepFailed;
|
||||
osStep = OsUpdateSteps.Failed;
|
||||
severity = EventSeverity.Warning;
|
||||
message = $"Servicing FAILED for {pkg} → {target}" +
|
||||
|
||||
+5
-5
@@ -776,10 +776,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_pageWatermark.MarkEmitted(recordId);
|
||||
|
||||
// The registry is read now — only a live page boundary is the page's moment.
|
||||
if (!isBackfill && record.CxhEvent != OobeUpdatePageRecord.EventName)
|
||||
if (!isBackfill && record.CxhEvent != OobeUpdateVocabulary.EventName)
|
||||
{
|
||||
_oobeUpdate.ReportState(
|
||||
record.CxhEvent == OobeUpdatePageRecord.PageStarted
|
||||
record.CxhEvent == OobeUpdateVocabulary.PageStarted
|
||||
? OobeUpdateTelemetry.Moments.UpdatePageStarted
|
||||
: OobeUpdateTelemetry.Moments.UpdatePageStopped,
|
||||
record.Page);
|
||||
@@ -879,11 +879,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted:
|
||||
return IsUpdatePageToken(fields.Primary)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStarted, eventId, page: fields.Primary)
|
||||
? new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStarted, eventId, page: fields.Primary)
|
||||
: null;
|
||||
case EventId_ShellCore_WebAppStopped:
|
||||
return IsUpdatePageToken(lastPage)
|
||||
? new OobeUpdatePageRecord(OobeUpdatePageRecord.PageStopped, eventId,
|
||||
? new OobeUpdatePageRecord(OobeUpdateVocabulary.PageStopped, eventId,
|
||||
page: lastPage, result: fields.Primary ?? "?")
|
||||
: null;
|
||||
case EventId_ShellCore_WebAppEventName:
|
||||
@@ -891,7 +891,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (!IsUpdatePageToken(fields.Primary)) return null;
|
||||
var value = eventId == EventId_ShellCore_WebAppEvent ? SafeValue(fields.Value) : null;
|
||||
return new OobeUpdatePageRecord(OobeUpdatePageRecord.EventName, eventId, name: fields.Primary, value: value);
|
||||
return new OobeUpdatePageRecord(OobeUpdateVocabulary.EventName, eventId, name: fields.Primary, value: value);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// - Explicit "Required app failed" lines from the ESP required-app enforcer
|
||||
//
|
||||
// TODO(stall-probe-imelog-scan): Also scan IntuneManagementExtension.log (not just
|
||||
// AppWorkload.log). Learning from the Oriflame session b4b5d37e-a993-453e-b16b-9b75098022e4
|
||||
// AppWorkload.log). Learning from a field session
|
||||
// (MS Intune AAD token service outage 2026-04-08): the ESP blocker was
|
||||
// "Failed to get AAD token. errorCode = 3399548929" (= 0xCAA90001 AADSTS_SERVER_ERROR)
|
||||
// and this string lives in IntuneManagementExtension.log, NOT AppWorkload.log. Add a third
|
||||
|
||||
+23
-7
@@ -254,9 +254,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
if (subKey == null)
|
||||
continue;
|
||||
|
||||
var displayName = subKey.GetValue("DisplayName")?.ToString();
|
||||
var displayName = ReadRegistryText(subKey, "DisplayName");
|
||||
var systemComponent = subKey.GetValue("SystemComponent");
|
||||
var parentKeyName = subKey.GetValue("ParentKeyName")?.ToString();
|
||||
var parentKeyName = ReadRegistryText(subKey, "ParentKeyName");
|
||||
|
||||
if (ShouldExclude(displayName, systemComponent, parentKeyName))
|
||||
continue;
|
||||
@@ -264,11 +264,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
results.Add(new SoftwareEntry
|
||||
{
|
||||
DisplayName = displayName,
|
||||
DisplayVersion = subKey.GetValue("DisplayVersion")?.ToString(),
|
||||
Publisher = subKey.GetValue("Publisher")?.ToString(),
|
||||
InstallDate = subKey.GetValue("InstallDate")?.ToString(),
|
||||
InstallLocation = subKey.GetValue("InstallLocation")?.ToString(),
|
||||
UninstallString = subKey.GetValue("UninstallString")?.ToString(),
|
||||
DisplayVersion = ReadRegistryText(subKey, "DisplayVersion"),
|
||||
Publisher = ReadRegistryText(subKey, "Publisher"),
|
||||
InstallDate = ReadRegistryText(subKey, "InstallDate"),
|
||||
InstallLocation = ReadRegistryText(subKey, "InstallLocation"),
|
||||
UninstallString = ReadRegistryText(subKey, "UninstallString"),
|
||||
IsWindowsInstaller = Convert.ToInt32(subKey.GetValue("WindowsInstaller") ?? 0) == 1,
|
||||
RegistrySource = source
|
||||
});
|
||||
@@ -287,6 +287,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadRegistryText(RegistryKey key, string name)
|
||||
=> CutAtNul(key.GetValue(name)?.ToString());
|
||||
|
||||
/// <summary>
|
||||
/// A registry string the way Windows shows it: up to the first NUL. Some installers write
|
||||
/// REG_SZ data padded with NULs, and .NET Framework drops only one terminator — the rest
|
||||
/// would ride into the normalized identity and, server-side, into a table key.
|
||||
/// </summary>
|
||||
internal static string CutAtNul(string value)
|
||||
{
|
||||
if (value == null)
|
||||
return null;
|
||||
var nul = value.IndexOf('\0');
|
||||
return nul < 0 ? value : value.Substring(0, nul);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HKU per-user profile enumeration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -284,7 +284,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// Throws <see cref="BackendAuthException"/> for 401/403 responses so callers can
|
||||
/// distinguish authentication failures from transient server errors.
|
||||
/// <para>
|
||||
/// Field case (sits-d.cloud, 2026-08-09): a pre-cutover agent build pointed at a
|
||||
/// Field case (2026-08-09): a pre-cutover agent build pointed at a
|
||||
/// retired Function App; Azure answers for a stopped app with a platform-level 403
|
||||
/// carrying an HTML error page ("Web App - Unavailable"). Our backend never returns
|
||||
/// HTML on 401/403 — an HTML body therefore means the response did not come from the
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
@@ -14,6 +15,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
|
||||
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
|
||||
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
|
||||
/// (the terminal <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/> report is exempt)
|
||||
///
|
||||
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
|
||||
/// </summary>
|
||||
@@ -32,6 +34,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
private readonly string _tenantId;
|
||||
private readonly string _agentVersion;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly IClock _clock;
|
||||
|
||||
private readonly HashSet<string> _reportedKeys = new HashSet<string>();
|
||||
private readonly object _antiFloodLock = new object();
|
||||
@@ -43,13 +46,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
string agentVersion,
|
||||
AgentLogger logger)
|
||||
AgentLogger logger,
|
||||
IClock clock = null)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_agentVersion = agentVersion;
|
||||
_logger = logger;
|
||||
_clock = clock ?? SystemClock.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -100,6 +105,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
// All anti-flood checks are protected by a lock to prevent race conditions when
|
||||
// TrySendAsync is called concurrently (fire-and-forget from multiple threads).
|
||||
var key = $"{errorType}:{httpStatusCode}";
|
||||
var now = _clock.UtcNow;
|
||||
int currentReportCount;
|
||||
DateTime? previousLastReportTime;
|
||||
|
||||
@@ -117,8 +123,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
return;
|
||||
}
|
||||
|
||||
if (_lastReportTime != null &&
|
||||
(DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
|
||||
// The session-unknown report is terminal and sent at most once per process (the
|
||||
// termination follows immediately): a routine report minutes earlier must not cost
|
||||
// the last word about the session. Only the cooldown is waived; key dedup and the
|
||||
// per-session maximum still apply.
|
||||
var exemptFromCooldown = errorType == AgentErrorType.TelemetryRejectedSessionUnknown;
|
||||
if (!exemptFromCooldown && _lastReportTime != null &&
|
||||
(now - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
|
||||
{
|
||||
_logger?.Debug($"[EmergencyChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}");
|
||||
return;
|
||||
@@ -127,7 +138,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
previousLastReportTime = _lastReportTime;
|
||||
_reportedKeys.Add(key);
|
||||
_reportCount++;
|
||||
_lastReportTime = DateTime.UtcNow;
|
||||
_lastReportTime = now;
|
||||
currentReportCount = _reportCount;
|
||||
}
|
||||
|
||||
@@ -145,7 +156,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
SessionAgeHours = sessionAgeHours,
|
||||
PriorRegistrationFailure = priorRegistrationFailure,
|
||||
AgentVersion = _agentVersion,
|
||||
Timestamp = DateTime.UtcNow
|
||||
Timestamp = now
|
||||
};
|
||||
|
||||
var delivered = false;
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// signal once per second for at most <paramref name="maxWait"/>. It cannot prove backend
|
||||
/// reachability — the callers' retry loops handle that — it only keeps retry budgets from
|
||||
/// being burned into a link that is provably still down (BootTrigger relaunch after a
|
||||
/// mid-enrollment reboot, Wi-Fi still associating; tenant aebdce78 audits 2026-08-23/24).
|
||||
/// mid-enrollment reboot, Wi-Fi still associating; kiosk-tenant audits 2026-08-23/24).
|
||||
/// Free on the normal path — a live link returns immediately. Probe errors end the wait,
|
||||
/// never the caller's operation.
|
||||
/// </summary>
|
||||
|
||||
@@ -258,7 +258,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// on esp_exiting events long after Start.
|
||||
ImeLogHost? imeLogHostRef = null;
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19), narrowed after review: the Shell-Core ESP-exit
|
||||
// Cloud-PC fix (2026-08-19), narrowed after review: the Shell-Core ESP-exit
|
||||
// replay is restart recovery, so it stays OFF on a first run and the happy path keeps
|
||||
// its pre-fix behaviour exactly. See ResolveEspExitBackfillLookbackMinutes.
|
||||
var espExitBackfillLookbackMinutes = ResolveEspExitBackfillLookbackMinutes(
|
||||
@@ -453,7 +453,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
imeLogHost.Tracker.OnUserTokenAcquired = lineUtc => aadJoinHost.NotifyUserTokenAcquired(lineUtc);
|
||||
imeLogHost.Tracker.OnTokenFailureLine = (code, lineUtc) => aadJoinHost.NotifyTokenFailureLine(code, lineUtc);
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19): the user-apps-settled AccountSetup synthesis used
|
||||
// Cloud-PC fix (2026-08-19): the user-apps-settled AccountSetup synthesis used
|
||||
// to run ONLY on the EspExited edge. On a tenant with a large required-app set the ESP
|
||||
// page exits while apps are still in flight, the single attempt misses, and nothing
|
||||
// ever re-checks — sessions 8110e262 / a89aac2d reached 138/138 apps with 0 failed and
|
||||
|
||||
@@ -371,8 +371,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// 3) Telemetry-Transport. Batch size flows from AgentConfiguration.MaxBatchSize
|
||||
// via Program.cs (P1 fix: previously the remote-config knob was merged but
|
||||
// never applied, so tenants saw drainInterval/MaxBatchSize have no effect).
|
||||
_spool = new TelemetrySpool(_transportDirectory, _clock, _logger);
|
||||
_transport = new TelemetryUploadOrchestrator(_spool, _uploader, _clock, batchSize: _uploadBatchSize);
|
||||
// The partition key names the registered session: the spool drops leftovers of a
|
||||
// previous session id on startup, and the orchestrator tells a 410 for this session
|
||||
// (SessionGone → host terminates) from a 410 for such leftovers (dropped).
|
||||
var partitionKey = TelemetryPartitionKey.ForSession(_tenantId, _sessionId);
|
||||
_spool = new TelemetrySpool(_transportDirectory, _clock, _logger, expectedPartitionKey: partitionKey);
|
||||
_transport = new TelemetryUploadOrchestrator(
|
||||
_spool, _uploader, _clock, batchSize: _uploadBatchSize, currentPartitionKey: partitionKey, logger: _logger);
|
||||
|
||||
// TRACE-H1 — surface skipped oversized items + blocked-pipeline conditions as timeline
|
||||
// events. Raised after the drain guard releases, so posting back through the ingress
|
||||
|
||||
+25
-1
@@ -73,13 +73,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public string? StopReason { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The <c>origin</c> param of a server-requested <c>terminate_session</c> action
|
||||
/// (<see cref="TerminationOrigins"/>); null for every termination the agent decided
|
||||
/// itself. <see cref="TerminationOrigins.SessionGone"/> makes the termination handler
|
||||
/// skip the spool drain and the diagnostics upload — the backend no longer knows the
|
||||
/// session, so both could only fail.
|
||||
/// </summary>
|
||||
public string? Origin { get; }
|
||||
|
||||
public EnrollmentTerminatedEventArgs(
|
||||
EnrollmentTerminationReason reason,
|
||||
EnrollmentTerminationOutcome outcome,
|
||||
string? stageName,
|
||||
DateTime terminatedAtUtc,
|
||||
string? details = null,
|
||||
string? stopReason = null)
|
||||
string? stopReason = null,
|
||||
string? origin = null)
|
||||
{
|
||||
Reason = reason;
|
||||
Outcome = outcome;
|
||||
@@ -87,6 +97,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
TerminatedAtUtc = terminatedAtUtc;
|
||||
Details = details;
|
||||
StopReason = stopReason;
|
||||
Origin = origin;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Values of the <c>origin</c> param on a synthesised <c>terminate_session</c>
|
||||
/// <c>ServerAction</c>, carried into <see cref="EnrollmentTerminatedEventArgs.Origin"/>.
|
||||
/// </summary>
|
||||
public static class TerminationOrigins
|
||||
{
|
||||
/// <summary>Administrator kill signal (<c>DeviceKillSignal</c> on a 2xx telemetry response).</summary>
|
||||
public const string KillSignal = "kill_signal";
|
||||
|
||||
/// <summary>The backend answered HTTP 410 for the current session's telemetry.</summary>
|
||||
public const string SessionGone = "session_gone";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// the Shell-Core watcher only ever saw events written AFTER <see cref="Start"/>. Any
|
||||
/// agent restart therefore lost an ESP exit (62407) or Hello-wizard start (62404) that
|
||||
/// happened while the agent was down — and after a mid-ESP reboot that is exactly the
|
||||
/// window the completion signal lands in (sits-d Cloud PCs, 2026-08-19: five sessions
|
||||
/// window the completion signal lands in (Cloud PCs, 2026-08-19: five sessions
|
||||
/// hung in AccountSetup until the server-side timeout while the devices were fine).
|
||||
/// </para>
|
||||
/// <para>
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// <item>6 attempts total; between attempts <c>2^attempt</c> seconds (2s, 4s, 8s, 16s, 32s —
|
||||
/// ~62 s of backoff). The original V1 budget of 5 attempts / ~30 s was too short for the
|
||||
/// reboot-relaunch on Wi-Fi kiosks: the relaunched agent gave up and exited 7 before
|
||||
/// the network came back, leaving the session silent forever (tenant aebdce78,
|
||||
/// the network came back, leaving the session silent forever (kiosk-tenant
|
||||
/// 2026-08-23 audit). A relaunch that misses here is lost — nothing retries later.</item>
|
||||
/// <item>On <c>response.Success == true</c> → stop immediately.</item>
|
||||
/// <item>On <see cref="BackendAuthException"/> (401/403) → report to the
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
_sequenceCounter = sequenceCounter ?? throw new ArgumentNullException(nameof(sequenceCounter));
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_partitionKey = $"{tenantId}_{sessionId}";
|
||||
_partitionKey = TelemetryPartitionKey.ForSession(tenantId, sessionId);
|
||||
_traceEventsEnabled = traceEventsEnabled;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Signals
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_partitionKey = $"{tenantId}_{sessionId}";
|
||||
_partitionKey = TelemetryPartitionKey.ForSession(tenantId, sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_partitionKey = $"{tenantId}_{sessionId}";
|
||||
_partitionKey = TelemetryPartitionKey.ForSession(tenantId, sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+30
-7
@@ -306,7 +306,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
});
|
||||
|
||||
DelayLateEventGrace();
|
||||
DrainSpool();
|
||||
DrainSpool(args);
|
||||
|
||||
// Option 2 (WG Part 1 graceful-exit hardening, 2026-04-30): write the
|
||||
// clean-exit marker BEFORE _signalShutdown returns control to the main
|
||||
@@ -329,7 +329,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
|
||||
RunUploadDiagnosticsWithEvents(args);
|
||||
WriteEnrollmentCompleteMarker(args);
|
||||
RunStandaloneRebootIfRequested();
|
||||
RunStandaloneRebootIfRequested(args);
|
||||
RunSelfDestructIfAppropriate(args);
|
||||
// Option 2 (same hardening) — covers the standard Completed / Failed terminal
|
||||
// path too. Cleanup PowerShell does not touch this marker, so writing it before
|
||||
@@ -824,7 +824,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// IME-log event) but the agent actually observed only the end-state. Severity and phrasing
|
||||
/// are outcome-calibrated via <see cref="ObservationCoverage.DescribeLateStart"/>: a failure
|
||||
/// stays a Warning with the post-mortem/hung-script note, a success is an Info about
|
||||
/// tail-only coverage (fleet 2026-07-28: tenant 659c3a90 collected 12 Warnings on healthy
|
||||
/// tail-only coverage (fleet 2026-07-28: one tenant collected 12 Warnings on healthy
|
||||
/// enrollments whose bootstrap merely ran late). No state mutation.
|
||||
/// </summary>
|
||||
private void MaybeEmitAgentLateStart(EnrollmentTerminatedEventArgs args)
|
||||
@@ -979,6 +979,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// </param>
|
||||
private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args, string suffixOverride = null)
|
||||
{
|
||||
if (IsSessionGone(args))
|
||||
{
|
||||
// The backend answered 410 for this session: the package would describe a session
|
||||
// that no longer exists, and its diagnostics_* events could never be accepted.
|
||||
_logger.Info("EnrollmentTerminationHandler: diagnostics upload skipped — backend no longer knows this session.");
|
||||
return;
|
||||
}
|
||||
|
||||
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
|
||||
if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -1124,7 +1132,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// but enables <c>RebootOnComplete</c>, the agent's final act is <c>shutdown.exe /r</c>
|
||||
/// with the configured delay, giving the user a visible countdown.
|
||||
/// </summary>
|
||||
private void RunStandaloneRebootIfRequested()
|
||||
private void RunStandaloneRebootIfRequested(EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
if (_configuration.SelfDestructOnComplete) return;
|
||||
if (!_configuration.RebootOnComplete) return;
|
||||
@@ -1148,7 +1156,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
|
||||
DrainSpool();
|
||||
DrainSpool(args);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -1185,7 +1193,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// just above. The WG-Part-1 and standalone-reboot paths already drain first; this
|
||||
// closes the gap on the self-destruct path. Bounded + best-effort (never blocks
|
||||
// termination on a wedged backend).
|
||||
DrainSpool();
|
||||
DrainSpool(args);
|
||||
|
||||
var service = _cleanupServiceFactory();
|
||||
service.ExecuteSelfDestruct();
|
||||
@@ -1318,8 +1326,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainSpool()
|
||||
/// <summary>
|
||||
/// <see cref="TerminationOrigins.SessionGone"/>: the backend no longer knows the session.
|
||||
/// Every drain would only collect more 410s against a bounded budget, and a diagnostics
|
||||
/// package would describe a deleted session — both are skipped; marker, cleanup and
|
||||
/// self-destruct run as usual.
|
||||
/// </summary>
|
||||
private static bool IsSessionGone(EnrollmentTerminatedEventArgs args) =>
|
||||
string.Equals(args.Origin, TerminationOrigins.SessionGone, StringComparison.Ordinal);
|
||||
|
||||
private void DrainSpool(EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
if (IsSessionGone(args))
|
||||
{
|
||||
_logger.Info("EnrollmentTerminationHandler: spool drain skipped — backend no longer knows this session.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Block briefly so pending events can land before the next destructive step
|
||||
// (shutdown.exe, self-destruct). Two phases share the same bounded budget:
|
||||
//
|
||||
|
||||
+10
@@ -165,6 +165,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
_logger?.Warning($"BackendTelemetryUploader: ingest TRANSIENT (corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}): {result.ErrorReason}");
|
||||
}
|
||||
else if (result.IsSessionGone)
|
||||
{
|
||||
_logger?.Error($"BackendTelemetryUploader: ingest rejected — backend no longer knows this session (corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}): {result.ErrorReason}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.Error($"BackendTelemetryUploader: ingest PERMANENT fail (corr={correlationId}, items={items.Count}, durationMs={sw.ElapsedMilliseconds}, status={(int)response.StatusCode}): {result.ErrorReason}");
|
||||
@@ -208,6 +212,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
|
||||
case HttpStatusCode.RequestEntityTooLarge: // 413 — the data is fine, only the batch
|
||||
return UploadResult.TooLarge(shortReason); // size is wrong → split & retry (P1).
|
||||
|
||||
case HttpStatusCode.Gone: // 410 — the backend no longer knows this session.
|
||||
// Decided BEFORE the poison-body check: a poison body on 410 would make the
|
||||
// drain drop items and emit telemetry_upload_poisoned, which the backend
|
||||
// rejects with 410 again — an emit/reject loop for a session that is over.
|
||||
return UploadResult.SessionGone($"session gone: {shortReason}");
|
||||
}
|
||||
|
||||
if (statusCode >= 500 && statusCode <= 599) return UploadResult.Transient(shortReason);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#nullable enable
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// The one composition of a session's Azure-Table partition key. The three emitters stamp
|
||||
/// it on every <see cref="TelemetryItem"/>; the spool and the upload orchestrator compare
|
||||
/// against it to tell the current session's items from leftovers of a previous session id.
|
||||
/// A drift between producer and comparer would silently drop or retain the wrong items.
|
||||
/// </summary>
|
||||
public static class TelemetryPartitionKey
|
||||
{
|
||||
public static string ForSession(string tenantId, string sessionId) => $"{tenantId}_{sessionId}";
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
public int ByteLength { get; }
|
||||
}
|
||||
|
||||
public TelemetrySpool(string directoryPath, IClock clock, Logging.AgentLogger? logger = null)
|
||||
/// <param name="expectedPartitionKey">
|
||||
/// Partition key of the session this process serves (<see cref="TelemetryPartitionKey.ForSession"/>).
|
||||
/// When set, pending items of another session id found at the head of the spool are dropped
|
||||
/// on startup — see <see cref="DropLeadingForeignPending"/>. Null disables the filter.
|
||||
/// </param>
|
||||
public TelemetrySpool(
|
||||
string directoryPath,
|
||||
IClock clock,
|
||||
Logging.AgentLogger? logger = null,
|
||||
string? expectedPartitionKey = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(directoryPath))
|
||||
{
|
||||
@@ -96,6 +105,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// count as "pending" and need to be rehydrated into _pending.
|
||||
_lastUploadedItemId = _cursor.Load();
|
||||
_lastAssignedItemId = ScanSpoolAndRehydratePending();
|
||||
DropLeadingForeignPending(expectedPartitionKey);
|
||||
_peakPendingCount = _pending.Count;
|
||||
}
|
||||
|
||||
@@ -312,5 +322,68 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startup filter: drops the LEADING run of pending items whose partition key is not
|
||||
/// <paramref name="expectedPartitionKey"/>, advancing and persisting the cursor past them.
|
||||
/// Such items are leftovers of a previous session id in the same spool directory; the
|
||||
/// backend answers 410 for them, and that 410 must never end the session this process
|
||||
/// serves. One process serves one session, so a foreign item AFTER an own item cannot
|
||||
/// occur — if it does, the order is not trustworthy and nothing is dropped. WhiteGlove
|
||||
/// Part 2 reuses the Part-1 session id, so its leftovers carry the expected key and stay.
|
||||
/// Called once from the ctor, after rehydration.
|
||||
/// </summary>
|
||||
private void DropLeadingForeignPending(string? expectedPartitionKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(expectedPartitionKey) || _pending.Count == 0) return;
|
||||
|
||||
var leadingForeign = 0;
|
||||
long lastForeignId = -1;
|
||||
var seenOwn = false;
|
||||
foreach (var entry in _pending)
|
||||
{
|
||||
var isForeign = !string.Equals(entry.Item.PartitionKey, expectedPartitionKey, StringComparison.Ordinal);
|
||||
if (!isForeign)
|
||||
{
|
||||
seenOwn = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenOwn)
|
||||
{
|
||||
leadingForeign++;
|
||||
lastForeignId = entry.Item.TelemetryItemId;
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger?.Error(
|
||||
$"TelemetrySpool: pending itemId={entry.Item.TelemetryItemId} carries a foreign partition key after items of the current session — spool order is inconsistent; keeping all {_pending.Count} pending item(s).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (leadingForeign == 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
_cursor.Save(lastForeignId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep the items rather than risk a cursor that disagrees with disk; the drain
|
||||
// loop drops the same leading run on its first 410 (defence in depth).
|
||||
_logger?.Warning($"TelemetrySpool: could not persist the cursor past {leadingForeign} foreign pending item(s): {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
_lastUploadedItemId = lastForeignId;
|
||||
for (var i = 0; i < leadingForeign; i++)
|
||||
{
|
||||
_pendingBytes -= _pending.First!.Value.ByteLength;
|
||||
_pending.RemoveFirst();
|
||||
}
|
||||
|
||||
_logger?.Warning(
|
||||
$"TelemetrySpool: dropped {leadingForeign} pending item(s) of a previous session id (cursor advanced to {lastForeignId}, {_pending.Count} pending item(s) of the current session kept).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
-1
@@ -45,12 +45,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// telemetry_upload_blocked exactly once per process rather than on every 30 s drain.
|
||||
private bool _uploadBlockedReported;
|
||||
|
||||
// Partition key of the session this process serves; null = every batch counts as own.
|
||||
private readonly string? _currentPartitionKey;
|
||||
private readonly Logging.AgentLogger? _logger;
|
||||
|
||||
// One-shot guard for SessionGone. Latched only when a handler is subscribed: the host
|
||||
// wires the handler after EnrollmentOrchestrator.Start, and a 410 on the very first
|
||||
// drain must not be swallowed before anyone listens.
|
||||
private bool _sessionGoneReported;
|
||||
|
||||
/// <param name="currentPartitionKey">
|
||||
/// <see cref="TelemetryPartitionKey.ForSession"/> of the registered session. A 410 on a
|
||||
/// batch that starts with this key ends the session (<see cref="SessionGone"/>); a 410 on
|
||||
/// a batch that starts with any other key only drops those leftover items.
|
||||
/// </param>
|
||||
public TelemetryUploadOrchestrator(
|
||||
ITelemetrySpool spool,
|
||||
IBackendTelemetryUploader uploader,
|
||||
IClock clock,
|
||||
int batchSize = 100,
|
||||
IReadOnlyList<TimeSpan>? retryBackoffs = null)
|
||||
IReadOnlyList<TimeSpan>? retryBackoffs = null,
|
||||
string? currentPartitionKey = null,
|
||||
Logging.AgentLogger? logger = null)
|
||||
{
|
||||
_spool = spool ?? throw new ArgumentNullException(nameof(spool));
|
||||
_uploader = uploader ?? throw new ArgumentNullException(nameof(uploader));
|
||||
@@ -61,6 +77,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
_effectiveBatchSize = batchSize;
|
||||
|
||||
_retryBackoffs = retryBackoffs ?? DefaultRetryBackoffs;
|
||||
_currentPartitionKey = currentPartitionKey;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,6 +115,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// </summary>
|
||||
public event EventHandler<string>? UploadBlocked;
|
||||
|
||||
/// <summary>
|
||||
/// Raised once per process (after the drain guard releases) when the backend answered
|
||||
/// HTTP 410 for a batch of the CURRENT session: the session is over server-side (never
|
||||
/// registered, deleted, or being deleted) and no retry can succeed. The batch is retained
|
||||
/// (cursor stays) and the drain stops; the host reports once over the emergency channel
|
||||
/// and terminates the agent with self-destruct. Carries the backend error reason.
|
||||
/// </summary>
|
||||
public event EventHandler<string>? SessionGone;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when the last successful upload returned <see cref="UploadResult.DeviceBlocked"/>
|
||||
/// and the paused-until window has not expired. Drain is a no-op in this state.
|
||||
@@ -124,6 +151,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
List<UploadResult>? pendingSignals = null;
|
||||
List<PoisonReport>? pendingPoison = null;
|
||||
string? pendingBlockedReason = null;
|
||||
string? pendingSessionGoneReason = null;
|
||||
DrainResult result;
|
||||
|
||||
try
|
||||
@@ -247,6 +275,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
new PoisonReport(1, oversizeId, "oversize: " + outcome.ErrorReason, PoisonKind.Oversize));
|
||||
break;
|
||||
}
|
||||
else if (outcome.IsSessionGone)
|
||||
{
|
||||
// HTTP 410 — the backend no longer knows the session these items belong to.
|
||||
var foreignRun = CountLeadingForeignItems(batch);
|
||||
if (foreignRun > 0)
|
||||
{
|
||||
// Leftovers of a previous session id (the spool drops these at startup;
|
||||
// this is defence in depth). Drop only that leading run and carry on: a
|
||||
// legitimate new session must never be ended because of old items.
|
||||
var throughId = batch[foreignRun - 1].TelemetryItemId;
|
||||
_spool.MarkUploaded(throughId);
|
||||
_logger?.Warning(
|
||||
$"TelemetryUploadOrchestrator: dropped {foreignRun} item(s) of a previous session id the backend rejected with 410 (cursor advanced to {throughId}).");
|
||||
continue;
|
||||
}
|
||||
|
||||
// The current session is over server-side. RETAIN (cursor stays, the items
|
||||
// remain on disk for the diagnostics ZIP), stop this drain, and surface it
|
||||
// once — not as telemetry_upload_blocked, which would only queue another
|
||||
// item behind a batch that can never be accepted.
|
||||
failedBatches++;
|
||||
lastError = outcome.ErrorReason;
|
||||
if (!_sessionGoneReported && SessionGone != null)
|
||||
{
|
||||
_sessionGoneReported = true;
|
||||
pendingSessionGoneReason = outcome.ErrorReason;
|
||||
}
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
// P1 — RETAIN. Transient (retry next drain), auth-permanent (uploader already
|
||||
@@ -301,9 +358,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
catch { /* handler must not abort the drain result */ }
|
||||
}
|
||||
|
||||
if (pendingSessionGoneReason != null)
|
||||
{
|
||||
try { SessionGone?.Invoke(this, pendingSessionGoneReason); }
|
||||
catch { /* handler must not abort the drain result */ }
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of items at the head of <paramref name="batch"/> whose partition key is not the
|
||||
/// current session's. 0 when no current key is configured (every batch counts as own).
|
||||
/// </summary>
|
||||
private int CountLeadingForeignItems(IReadOnlyList<TelemetryItem> batch)
|
||||
{
|
||||
if (_currentPartitionKey == null) return 0;
|
||||
|
||||
var count = 0;
|
||||
while (count < batch.Count
|
||||
&& !string.Equals(batch[count].PartitionKey, _currentPartitionKey, StringComparison.Ordinal))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool CarriesSignal(UploadResult outcome) =>
|
||||
outcome.DeviceBlocked
|
||||
|| outcome.DeviceKillSignal
|
||||
|
||||
@@ -30,7 +30,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
bool isAuthFailure = false,
|
||||
bool requiresSplit = false,
|
||||
bool isPoison = false,
|
||||
IReadOnlyList<string>? poisonRowKeys = null)
|
||||
IReadOnlyList<string>? poisonRowKeys = null,
|
||||
bool isSessionGone = false)
|
||||
{
|
||||
Success = success;
|
||||
ErrorReason = errorReason;
|
||||
@@ -44,6 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
RequiresSplit = requiresSplit;
|
||||
IsPoison = isPoison;
|
||||
PoisonRowKeys = poisonRowKeys;
|
||||
IsSessionGone = isSessionGone;
|
||||
}
|
||||
|
||||
public bool Success { get; }
|
||||
@@ -81,6 +83,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// <summary>The RowKeys the backend named as poison (item-level). Non-empty when <see cref="IsPoison"/>.</summary>
|
||||
public IReadOnlyList<string>? PoisonRowKeys { get; }
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 410: the backend no longer knows the session the batch belongs to (never
|
||||
/// registered, deleted, or being deleted). Not transient, not auth, not poison — retrying
|
||||
/// can never succeed. The orchestrator stops the drain (cursor stays) and raises
|
||||
/// <see cref="TelemetryUploadOrchestrator.SessionGone"/> when the batch belongs to the
|
||||
/// current session, or drops the batch's leading items when they are leftovers of a
|
||||
/// previous session id.
|
||||
/// </summary>
|
||||
public bool IsSessionGone { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Non-terminal quarantine signalled by the backend. The agent should stop draining
|
||||
/// uploads until <see cref="UnblockAt"/> (if set) elapses — the session stays alive so
|
||||
@@ -165,5 +177,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
new UploadResult(false, reason ?? throw new ArgumentNullException(nameof(reason)), isTransient: false,
|
||||
deviceBlocked: false, unblockAt: null, deviceKillSignal: false, adminAction: null, actions: null,
|
||||
isPoison: true, poisonRowKeys: rejectedRowKeys ?? throw new ArgumentNullException(nameof(rejectedRowKeys)));
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 410 — the backend no longer knows the session. Flagged <see cref="IsSessionGone"/>;
|
||||
/// never retried, never split, never treated as poison.
|
||||
/// </summary>
|
||||
public static UploadResult SessionGone(string reason) =>
|
||||
new UploadResult(false, reason ?? throw new ArgumentNullException(nameof(reason)), isTransient: false,
|
||||
deviceBlocked: false, unblockAt: null, deviceKillSignal: false, adminAction: null, actions: null,
|
||||
isSessionGone: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// network yet — its failure says nothing about the latest version. The certificate is
|
||||
/// proof of connectivity, and this is the last chance to swap a stranded old build
|
||||
/// (stale baked-in ApiBaseUrl) for a current one BEFORE session registration hits a
|
||||
/// retired endpoint (field case sits-d.cloud, 2026-08-09). Timeouts are relaxed vs. the
|
||||
/// retired endpoint (field case 2026-08-09). Timeouts are relaxed vs. the
|
||||
/// boot-speed-tuned startup check; enrollment is already past OOBE's critical path here.
|
||||
/// On update success the process restarts and this method never returns; the
|
||||
/// await-enrollment config was already removed, so the new binary proceeds normally.
|
||||
|
||||
@@ -76,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.5 — config self-heal. When the Phase 4 fetch ran before the network link was
|
||||
/// up (agent relaunched at boot, Wi-Fi still associating — tenant aebdce78 audit 2026-08-24:
|
||||
/// up (agent relaunched at boot, Wi-Fi still associating — kiosk-tenant audit 2026-08-24:
|
||||
/// ~50% of sessions ran on ConfigVersion=0 exactly this way), a successful session
|
||||
/// registration has just proven the backend reachable. Retry the fetch once and apply
|
||||
/// the live tenant config instead of stranding the whole session on cache/defaults.
|
||||
|
||||
@@ -79,7 +79,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// write to the same state file; per-enrollment lifecycle like the other state files.
|
||||
var startupEventGate = new AutopilotMonitor.Agent.V2.Core.Persistence.StartupEventGate(stateSubdir, logger);
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19): a reboot_kill means the agent was down across the
|
||||
// Cloud-PC fix (2026-08-19): a reboot_kill means the agent was down across the
|
||||
// forced mid-ESP restart. Hand the boot timestamp to the factory so the Shell-Core
|
||||
// ESP-exit backfill can reach back over the whole downtime instead of 5 minutes.
|
||||
var previousBootUtc =
|
||||
@@ -439,6 +439,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
lifecyclePost,
|
||||
() => terminationHandler,
|
||||
agentConfig,
|
||||
auth.EmergencyReporter,
|
||||
shutdown,
|
||||
shutdownComplete,
|
||||
logger);
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Termination;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -103,6 +105,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// dispatcher because these are real actions, not admin-button echoes.</item>
|
||||
/// <item><b>DeviceBlocked</b> — non-terminal upload pause. Transport already reacted in
|
||||
/// <c>TelemetryUploadOrchestrator.ApplyControlSignals</c>; here we only log.</item>
|
||||
/// <item><b>SessionGone</b> (<see cref="TelemetryUploadOrchestrator.SessionGone"/>, HTTP 410
|
||||
/// on the current session's telemetry) — one emergency report, then the kill-signal
|
||||
/// termination path tagged <c>origin=session_gone</c>. See <see cref="HandleSessionGone"/>.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Shutdown race guard</b>: if the agent has already finished its own termination
|
||||
@@ -117,9 +122,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
InformationalEventPost post,
|
||||
Func<EnrollmentTerminationHandler> terminationHandlerAccessor,
|
||||
AgentConfiguration agentConfig,
|
||||
EmergencyReporter emergencyReporter,
|
||||
ManualResetEventSlim shutdown,
|
||||
ManualResetEventSlim shutdownComplete,
|
||||
AgentLogger logger)
|
||||
{
|
||||
orchestrator.Transport.SessionGone += (sender, reason) =>
|
||||
HandleSessionGone(reason, emergencyReporter, dispatcher, shutdown, shutdownComplete, logger);
|
||||
|
||||
orchestrator.Transport.ServerResponseReceived += (sender, upload) =>
|
||||
{
|
||||
if (shutdownComplete.IsSet)
|
||||
@@ -140,7 +150,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
{ "forceSelfDestruct", "true" },
|
||||
{ "gracePeriodSeconds", "0" },
|
||||
{ "origin", "kill_signal" },
|
||||
{ "origin", TerminationOrigins.KillSignal },
|
||||
},
|
||||
};
|
||||
try { dispatcher.DispatchAsync(new List<ServerAction> { killAction }).GetAwaiter().GetResult(); }
|
||||
@@ -181,6 +191,76 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
};
|
||||
}
|
||||
|
||||
// Bounded blocking send for the one session-gone report: the terminate that follows ends
|
||||
// the process and the cleanup script deletes the agent, so a fire-and-forget send would
|
||||
// be abandoned mid-flight. Retries stay inside one anti-flood reservation (ONE report).
|
||||
private const int SessionGoneReportAttempts = 2;
|
||||
private static readonly TimeSpan SessionGoneReportTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan SessionGoneReportRetryDelay = TimeSpan.FromSeconds(3);
|
||||
private static readonly TimeSpan SessionGoneReportBudget = TimeSpan.FromSeconds(40);
|
||||
|
||||
/// <summary>
|
||||
/// The backend answered HTTP 410 for the current session's telemetry: the session is over
|
||||
/// server-side. (a) Send ONE <see cref="AgentErrorType.TelemetryRejectedSessionUnknown"/>
|
||||
/// report over the emergency channel — the telemetry channel is exactly what was refused,
|
||||
/// and the backend accepts this report for unknown sessions. (b) Terminate through the same
|
||||
/// synthesised <c>terminate_session</c> path as the administrator kill signal (forced
|
||||
/// self-destruct, no grace), tagged <see cref="TerminationOrigins.SessionGone"/> so the
|
||||
/// termination handler skips the spool drain and the diagnostics upload. A shutdown that is
|
||||
/// already in progress owns the outcome — then nothing happens here.
|
||||
/// </summary>
|
||||
internal static void HandleSessionGone(
|
||||
string reason,
|
||||
EmergencyReporter emergencyReporter,
|
||||
ServerActionDispatcher dispatcher,
|
||||
ManualResetEventSlim shutdown,
|
||||
ManualResetEventSlim shutdownComplete,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (shutdownComplete.IsSet || shutdown.IsSet)
|
||||
{
|
||||
logger.Debug("ServerControlPlane: session gone (HTTP 410) while shutdown is already in progress — ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.Error($"Backend no longer knows this session (HTTP 410: {reason}) — reporting once over the emergency channel, then terminating with self-destruct.");
|
||||
|
||||
if (emergencyReporter != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
emergencyReporter
|
||||
.TrySendAsync(
|
||||
AgentErrorType.TelemetryRejectedSessionUnknown,
|
||||
$"Telemetry upload rejected with HTTP 410 — backend no longer knows this session ({reason}). Agent terminating with self-destruct.",
|
||||
httpStatusCode: (int)HttpStatusCode.Gone,
|
||||
attempts: SessionGoneReportAttempts,
|
||||
perAttemptTimeout: SessionGoneReportTimeout,
|
||||
retryDelay: SessionGoneReportRetryDelay)
|
||||
.Wait(SessionGoneReportBudget);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"Session-gone report send failed (best-effort): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var terminateAction = new ServerAction
|
||||
{
|
||||
Type = ServerActionTypes.TerminateSession,
|
||||
Reason = "Backend no longer knows this session (HTTP 410)",
|
||||
QueuedAt = DateTime.UtcNow,
|
||||
Params = new Dictionary<string, string>
|
||||
{
|
||||
{ "forceSelfDestruct", "true" },
|
||||
{ "gracePeriodSeconds", "0" },
|
||||
{ "origin", TerminationOrigins.SessionGone },
|
||||
},
|
||||
};
|
||||
try { dispatcher.DispatchAsync(new List<ServerAction> { terminateAction }).GetAwaiter().GetResult(); }
|
||||
catch (Exception ex) { logger.Error("ServerActionDispatcher.DispatchAsync (session gone) threw during server-response wiring.", ex); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft admin-override handling. Portal Mark-Succeeded / Mark-Failed is an echo of an
|
||||
/// admin button click, not a ServerAction: it bypasses the dispatcher (no
|
||||
@@ -281,6 +361,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
: EnrollmentTerminationOutcome.Failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>origin</c> param of a synthesised <c>terminate_session</c> action
|
||||
/// (<see cref="TerminationOrigins"/>), or null when absent — it travels into
|
||||
/// <see cref="EnrollmentTerminatedEventArgs.Origin"/>.
|
||||
/// </summary>
|
||||
internal static string ReadOrigin(IReadOnlyDictionary<string, string> parameters)
|
||||
{
|
||||
if (parameters == null) return null;
|
||||
return parameters.TryGetValue("origin", out var origin) && !string.IsNullOrEmpty(origin) ? origin : null;
|
||||
}
|
||||
|
||||
// ============================================================ Private terminate-callback
|
||||
|
||||
/// <summary>
|
||||
@@ -326,8 +417,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// before, masquerading every admin override as an error in SummaryDialog +
|
||||
// firing spurious diagnostics uploads).
|
||||
var mappedOutcome = MapAdminOutcome(action?.Params);
|
||||
var origin = ReadOrigin(action?.Params);
|
||||
|
||||
logger.Warning($"ServerAction terminate_session received (ruleId={action?.RuleId}, reason={action?.Reason}, outcome={mappedOutcome}) — invoking termination handler.");
|
||||
logger.Warning($"ServerAction terminate_session received (ruleId={action?.RuleId}, reason={action?.Reason}, outcome={mappedOutcome}, origin={origin ?? "none"}) — invoking termination handler.");
|
||||
// Synthesise a Terminated event as if the kernel fired it.
|
||||
terminationHandler.Handle(
|
||||
sender: null,
|
||||
@@ -336,7 +428,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
outcome: mappedOutcome,
|
||||
stageName: orchestrator.CurrentState?.Stage.ToString(),
|
||||
terminatedAtUtc: DateTime.UtcNow,
|
||||
details: $"Server-requested termination: ruleId={action?.RuleId}, reason={action?.Reason}"));
|
||||
details: $"Server-requested termination: ruleId={action?.RuleId}, reason={action?.Reason}",
|
||||
origin: origin));
|
||||
|
||||
// Plan §6.2 synchronous-shutdown — block the ingest dispatcher thread until the
|
||||
// main-thread cleanup (orchestrator.Stop, client disposal) has fully run. This
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
bool helloPolicyEnabledRead = false,
|
||||
bool allowContinueAnyway = false)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-2dac8298", "tenant-047b2e1f", T0);
|
||||
var state = DecisionState.CreateInitial("sess-2dac8298", "tenant-a", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
/// </summary>
|
||||
private static DecisionState SetupSession(DecisionEngine engine)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-4910a5a5", "tenant-e46bc88e", T0);
|
||||
var state = DecisionState.CreateInitial("sess-4910a5a5", "tenant-a", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
|
||||
@@ -105,7 +105,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
["failureType"] = "Provisioning_DeviceSetup_Apps_Failed",
|
||||
["failedSubcategory"] = "Apps",
|
||||
["category"] = "DeviceSetup",
|
||||
["likelyCulpritApps"] = "Oriflame Cosmectics - Teams Backgroundsv v2",
|
||||
["likelyCulpritApps"] = "Contoso Branding - Teams Backgrounds v2",
|
||||
["likelyCulpritAppCount"] = "1",
|
||||
}));
|
||||
|
||||
@@ -129,7 +129,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
|
||||
var advisory = FindTimelineEffect(step, "esp_failure_advisory");
|
||||
Assert.NotNull(advisory);
|
||||
Assert.Equal("Oriflame Cosmectics - Teams Backgroundsv v2", advisory!.Parameters!["likelyCulpritApps"]);
|
||||
Assert.Equal("Contoso Branding - Teams Backgrounds v2", advisory!.Parameters!["likelyCulpritApps"]);
|
||||
}
|
||||
|
||||
// ===================================================== recovery hook ====
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
var builder = DecisionState.CreateInitial("s", "t", Fixed.AddDays(-1))
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(452) // the restored StepIndex the sits-d sessions showed
|
||||
.WithStepIndex(452) // the restored StepIndex the field sessions showed
|
||||
.WithLastAppliedSignalOrdinal(9);
|
||||
|
||||
if (accountSetupEntered)
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
bool helloEnabled = false,
|
||||
bool includeDesktop = true)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-caa6cf50", "tenant-e46bc88e", T0);
|
||||
var state = DecisionState.CreateInitial("sess-caa6cf50", "tenant-a", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
|
||||
+6
-7
@@ -8,8 +8,7 @@ using Xunit;
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// sits-d Cloud-PC fix (2026-08-20, sessions 8110e262 / a89aac2d / e7ba63c9 / cb4a485a /
|
||||
/// 3d6278fb). On a SkipUser=true flow Windows never renders the user ESP page: Shell-Core
|
||||
/// Cloud-PC fix (2026-08-20, five field sessions). On a SkipUser=true flow Windows never renders the user ESP page: Shell-Core
|
||||
/// 62407 does not fire and the AccountSetup provisioning categories are never written —
|
||||
/// verified live (Shell-Core watcher armed across three agent runs, zero 62407 in six
|
||||
/// hours; SkipUserStatusPage read 10+ times across all runs, True every time). Arm B of
|
||||
@@ -20,14 +19,14 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
/// <para>
|
||||
/// The fix lets the observed skip stand in for the final-exit evidence inside
|
||||
/// <c>HandleImeUserSessionCompletedV1</c>'s completion attempt. These tests replay the real
|
||||
/// 8110e262 signal ordering end to end (including the Fix-10 AccountSetup bounce-back a
|
||||
/// field session's signal ordering end to end (including the Fix-10 AccountSetup bounce-back a
|
||||
/// restart re-emission triggers) and pin the negative space: no skip observed, no desktop,
|
||||
/// ghost IME completion, and Device Preparation flows are all unchanged.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ClassicSkipUserEspUserSessionCompletionTests
|
||||
{
|
||||
// 8110e262 run 1: agent_started 08:30:08Z.
|
||||
// Field session run 1: agent_started 08:30:08Z.
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 19, 8, 30, 8, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
@@ -35,7 +34,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial(
|
||||
"8110e262-da57-4282-9290-bb0c96d05614", "5ca2b350", T0);
|
||||
"33333333-3333-3333-3333-333333333333", "tenant-a", T0);
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
|
||||
@@ -94,7 +93,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
[Fact]
|
||||
public void Fix10_bounceback_is_healed_by_the_ime_reemission()
|
||||
{
|
||||
// Restart shape (8110e262 run 3): the promoted AwaitingHello gets bounced back by a
|
||||
// Restart shape (field session run 3): the promoted AwaitingHello gets bounced back by a
|
||||
// re-emitted AccountSetup phase line (Fix 10 cancels HelloSafety, deliberately — it
|
||||
// guards the premature-promotion case). The IME phase line always precedes the
|
||||
// user-session-complete line in log order, so the re-emitted completion re-knocks.
|
||||
@@ -116,7 +115,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
[Theory]
|
||||
// No EspConfigDetected at all — SkipUserEsp unknown.
|
||||
[InlineData(null)]
|
||||
// Explicit SkipUser=false (the two sits-d machines that succeeded had this shape and
|
||||
// Explicit SkipUser=false (the two field machines that succeeded had this shape and
|
||||
// a REAL page: exit + registry evidence. Without the exit the gate must stay shut.)
|
||||
[InlineData("false")]
|
||||
public void No_promotion_without_an_observed_skip(string? skipUser)
|
||||
|
||||
@@ -9,7 +9,7 @@ using Xunit;
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Continue-Anyway observation mode (tenant c9787ba2, session 53d1e9f6, 2026-08-08).
|
||||
/// Continue-Anyway observation mode (field case 2026-08-08).
|
||||
/// A Device-phase ESP terminal failure (AccountSetup never entered) on a
|
||||
/// Continue-Anyway-enabled profile is a hard fail by default; with the tenant opt-in
|
||||
/// (<c>EspContinueAnywayObservationEnabled</c> observation) it is defanged into a 60-min
|
||||
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
bool continueAnyway = true,
|
||||
bool helloPolicyDisabled = true)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-53d1e9f6", "tenant-c9787ba2", T0);
|
||||
var state = DecisionState.CreateInitial("sess-53d1e9f6", "tenant-a", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
var espConfig = new Dictionary<string, string>
|
||||
{
|
||||
|
||||
@@ -409,7 +409,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
[Fact]
|
||||
public void Timeout_fire_with_recent_first_deployment_activity_extends_instead_of_timing_out()
|
||||
{
|
||||
// Report 55e6afd61c9d (Douglas): timer armed at detection (phase 0, RJ agent MSI
|
||||
// Field report: timer armed at detection (phase 0, RJ agent MSI
|
||||
// install during DeviceSetup), first deployment only started 16 min later, Office
|
||||
// completed 3 s before the deadline — the hard cut truncated an actively working
|
||||
// deployment. The fire must now re-arm to lastActivity + 60 min instead.
|
||||
|
||||
@@ -70,7 +70,7 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
||||
{
|
||||
// PR1 (Session 4fa5a2d4, 2026-05-22) — ContinueAnyway-aware ESP terminal-failure
|
||||
// defang. The fixture replays the canonical race that produced 199/202 false
|
||||
// positives in tenant c9787ba2: ContinueAnyway=true profile, AccountSetup entered,
|
||||
// positives in one tenant: ContinueAnyway=true profile, AccountSetup entered,
|
||||
// then DeviceSetup/Certificates registry-write flips to failed. Without PR1 this
|
||||
// would land in Failed; PR1 records EspAdvisoryFailureRecordedUtc and keeps the
|
||||
// stage at EspAccountSetup so the agent stays in monitoring.
|
||||
|
||||
@@ -222,7 +222,7 @@ public class AppHomingServiceTests
|
||||
[Fact]
|
||||
public async Task Probe_reports_only_add_on_roles_when_manifest_and_add_on_roles_are_both_missing()
|
||||
{
|
||||
// Live 2026-09-03 shape (thesrgroup, 1 s after consent): validation role still
|
||||
// Live 2026-09-03 shape (1 s after consent): validation role still
|
||||
// propagating AND the add-on missing. The add-on decides (permanent); the propagating
|
||||
// manifest role is not the admin's problem and must not appear in the to-do list.
|
||||
SetupProbe(GraphTokenResult.Success(Jwt()));
|
||||
|
||||
@@ -118,7 +118,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
Assert.False(app.TryGetProperty("trendDelta", out _));
|
||||
}
|
||||
|
||||
// ── /apps/{appName}/analytics ───────────────────────────────────────────
|
||||
// ── /apps/analytics ─────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<JsonElement> BuildAnalyticsAsync(
|
||||
List<AppInstallSummary> summaries, ISessionRepository? sessionRepo = null)
|
||||
|
||||
@@ -32,6 +32,7 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<OpsEventService> _opsEventsMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
||||
@@ -96,6 +97,9 @@ public class AuthFunctionSideEffectTests
|
||||
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
|
||||
|
||||
_signalR = new FakeSignalRNotificationService();
|
||||
_opsEventsMock = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
_opsEventsMock.Setup(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
_sut = new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
globalAdminMock.Object,
|
||||
@@ -115,7 +119,8 @@ public class AuthFunctionSideEffectTests
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR,
|
||||
adminConfigService.Object,
|
||||
_offboardingRepo);
|
||||
_offboardingRepo,
|
||||
opsEvents: _opsEventsMock.Object);
|
||||
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
@@ -163,7 +168,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Empty(_signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn)
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn, string? tenantId = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
@@ -172,8 +177,9 @@ public class AuthFunctionSideEffectTests
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim("preferred_username", callerUpn) }, "test"));
|
||||
var claims = new List<Claim> { new("preferred_username", callerUpn) };
|
||||
if (tenantId != null) claims.Add(new Claim("tid", tenantId));
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(
|
||||
@@ -226,14 +232,19 @@ public class AuthFunctionSideEffectTests
|
||||
// OnboardedBy is the immutable copy of the first-login UPN that auto-promote on
|
||||
// preview approval reads — UpdatedBy may later be clobbered by background syncs.
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
// The DPA version rides along in the same onboarding write (D-252).
|
||||
Assert.Equal(CurrentDpa, config.DpaVersion);
|
||||
// A first sign-in without the get-started tick leaves the Terms + DPA pending: the portal asks.
|
||||
Assert.True(config.DpaAcceptancePending);
|
||||
Assert.Null(config.DpaVersion);
|
||||
var (written, source, _) = Assert.Single(_store.Writes);
|
||||
Assert.Equal("contoso.com", written.DomainName);
|
||||
Assert.Equal(Upn, written.OnboardedBy);
|
||||
Assert.Equal(CurrentDpa, written.DpaVersion);
|
||||
Assert.True(written.DpaAcceptancePending);
|
||||
Assert.Null(written.DpaVersion);
|
||||
Assert.Null(written.DpaAcceptedBy);
|
||||
Assert.Null(written.DpaAcceptedAt);
|
||||
Assert.Equal("auth", source);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), Times.Once); // the routable record next to the ping
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Tenant Signup",
|
||||
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
|
||||
@@ -437,20 +448,139 @@ public class AuthFunctionSideEffectTests
|
||||
private const string CurrentDpa = AutopilotMonitor.Shared.Constants.CurrentDpaVersion;
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_WhenDomainAlreadySet_NeverStampsDpaVersion()
|
||||
public async Task HandleNewTenantDomain_ThroughTheGetStartedTick_AcceptsWithTheOnboarding()
|
||||
{
|
||||
// The DPA version is written once, with the onboarding (D-252): an existing tenant's
|
||||
// login writes nothing — no stamp, no save.
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = null!;
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn, signupConsent: true);
|
||||
|
||||
var written = Assert.Single(_store.Writes).Row;
|
||||
Assert.False(written.DpaAcceptancePending);
|
||||
Assert.Equal(CurrentDpa, written.DpaVersion);
|
||||
Assert.Equal(Upn, written.DpaAcceptedBy);
|
||||
Assert.NotNull(written.DpaAcceptedAt);
|
||||
Assert.True(written.DpaAcceptedAt >= before);
|
||||
// This login's view follows the stored row, so its auth/me carries no dialog.
|
||||
Assert.False(config.DpaAcceptancePending);
|
||||
Assert.Equal(CurrentDpa, config.DpaVersion);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task HandleNewTenantDomain_WhenDomainAlreadySet_NeverAsksOrStamps(bool signupConsent)
|
||||
{
|
||||
// Decided once, with the onboarding: an existing tenant's login writes nothing — no stamp,
|
||||
// no pending flag, whichever button the user came through (tenants that pre-date the
|
||||
// acceptance are never asked).
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
config.DpaVersion = null;
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn, signupConsent: signupConsent);
|
||||
|
||||
Assert.Null(config.DpaVersion);
|
||||
Assert.False(config.DpaAcceptancePending);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// AcceptDpa — POST auth/dpa-acceptance (portal dialog)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static TenantConfiguration PendingTenant()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
config.OnboardedBy = "first@contoso.com";
|
||||
config.DpaAcceptancePending = true;
|
||||
return config;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_PendingTenant_RecordsTheAcceptanceAndClearsTheDialog()
|
||||
{
|
||||
_store.Row = PendingTenant();
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
var (written, source, _) = Assert.Single(_store.Writes);
|
||||
Assert.Equal("auth", source);
|
||||
Assert.False(written.DpaAcceptancePending);
|
||||
Assert.Equal(CurrentDpa, written.DpaVersion);
|
||||
Assert.Equal(Upn, written.DpaAcceptedBy);
|
||||
Assert.True(written.DpaAcceptedAt >= before);
|
||||
Assert.Equal("first@contoso.com", written.OnboardedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_NothingPending_IsANoOp()
|
||||
{
|
||||
// Already accepted (a double click, a second user) or a tenant that was never asked.
|
||||
var accepted = DefaultConfig();
|
||||
accepted.DomainName = "contoso.com";
|
||||
accepted.DpaVersion = "2026-01-1.0";
|
||||
accepted.DpaAcceptedBy = "first@contoso.com";
|
||||
_store.Row = accepted;
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId);
|
||||
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.Equal("2026-01-1.0", _store.Row!.DpaVersion);
|
||||
Assert.Equal("first@contoso.com", _store.Row.DpaAcceptedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_OnlyEverWritesTheCallersOwnTenant()
|
||||
{
|
||||
// The row is keyed by the token's tid: a caller from another tenant reaches no pending row.
|
||||
const string otherTenant = "ffffffff-0000-1111-2222-333333333333";
|
||||
var otherStore = _tenantConfigMock.StubWrites(otherTenant, row: null);
|
||||
_store.Row = PendingTenant();
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: "user@fabrikam.com", tenantId: otherTenant);
|
||||
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Empty(otherStore.Writes);
|
||||
Assert.Empty(_store.Writes);
|
||||
Assert.True(_store.Row!.DpaAcceptancePending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_OffboardingTombstone_IsRefused()
|
||||
{
|
||||
var tombstone = PendingTenant();
|
||||
tombstone.Disabled = true;
|
||||
tombstone.DisabledReason = AutopilotMonitor.Functions.Functions.Admin.TenantOffboardFunction.OffboardingDisabledReason;
|
||||
_store.Row = tombstone;
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId);
|
||||
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Empty(_store.Writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptDpa_LostWriteRace_AsksToRetry()
|
||||
{
|
||||
_store.Row = PendingTenant();
|
||||
_store.Force = TenantConfigUpdateStatus.Conflict;
|
||||
var (req, ctx) = BuildAuthenticatedRequest(callerUpn: Upn, tenantId: TenantId);
|
||||
|
||||
var response = await _sut.AcceptDpa(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_PreservesExistingOnboardedBy()
|
||||
{
|
||||
|
||||
@@ -75,6 +75,24 @@ public class AuthFunctionTests
|
||||
Assert.Equal(agentSeen, (DateTime)body.whatsNewSeenAgentUtc);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void HappyPath_CarriesThePendingTermsAndDpaAcceptance(bool pending)
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DpaAcceptancePending = pending;
|
||||
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpAllowed(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(pending, (bool)ToDynamic(result.Body).dpaAcceptancePending);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("platform", "platform")]
|
||||
[InlineData("Platform", "platform")]
|
||||
|
||||
@@ -372,6 +372,9 @@ public class CascadeVerificationServiceTests
|
||||
public Task<TableEntity?> GetSessionRowAsync(string tenantId, string sessionId, CancellationToken ct = default)
|
||||
=> Task.FromResult<TableEntity?>(null);
|
||||
|
||||
public Task<TableEntity?> GetSessionRowAsync(string tenantId, string sessionId, IEnumerable<string> select, CancellationToken ct = default)
|
||||
=> Task.FromResult<TableEntity?>(null);
|
||||
|
||||
public Task<TableEntity?> GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken ct = default)
|
||||
=> Task.FromResult<TableEntity?>(null);
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ public class ConfigBackupHookTests
|
||||
config.DomainName = "contoso.com";
|
||||
config.UpdatedBy = "admin@contoso.com";
|
||||
config.DataRetentionDays = 30;
|
||||
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
|
||||
config.WebhookUrl = "https://hooks.example.com/secret-hook";
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -127,9 +127,9 @@ public class ConfigBackupHookTests
|
||||
// The snapshot holds the STORED row (retention 30), not the incoming one.
|
||||
Assert.Contains("\"DataRetentionDays\":30", snapshot.EntityJson);
|
||||
// Raw secrets ARE in EntityJson (full-fidelity restore source)…
|
||||
Assert.Contains("contoso.webhook.office.com", snapshot.EntityJson);
|
||||
Assert.Contains("hooks.example.com/secret-hook", snapshot.EntityJson);
|
||||
// …but the advisory diff masks them by property-name heuristics (ConfigDiffHelper).
|
||||
Assert.DoesNotContain("contoso.webhook.office.com", snapshot.DiffJson ?? string.Empty);
|
||||
Assert.DoesNotContain("hooks.example.com/secret-hook", snapshot.DiffJson ?? string.Empty);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync(TenantId, Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
|
||||
@@ -93,7 +93,6 @@ public class ConfigRedactionTests
|
||||
var existing = new TenantConfiguration
|
||||
{
|
||||
DiagnosticsBlobSasUrl = "https://acct.blob.core.windows.net/c?sig=real",
|
||||
TeamsWebhookUrl = "https://teams.example/real",
|
||||
WebhookUrl = "https://hooks.example/real",
|
||||
WebhookCustomHeadersJson = "{\"X-Api-Key\":\"real\"}",
|
||||
DomainName = "contoso.com",
|
||||
@@ -106,7 +105,6 @@ public class ConfigRedactionTests
|
||||
incoming.RestoreRedactedSecretsFrom(existing);
|
||||
|
||||
Assert.Equal(existing.DiagnosticsBlobSasUrl, incoming.DiagnosticsBlobSasUrl);
|
||||
Assert.Equal(existing.TeamsWebhookUrl, incoming.TeamsWebhookUrl);
|
||||
Assert.Equal(existing.WebhookUrl, incoming.WebhookUrl);
|
||||
Assert.Equal(existing.WebhookCustomHeadersJson, incoming.WebhookCustomHeadersJson);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Analyze;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Functions.Services.Vulnerability;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The two queue handlers derive rows for a session (rule results, reports, inventory counters,
|
||||
/// CveIndex). For a session the backend does not know, or one a cascade is tearing down, they
|
||||
/// must stop before the first read of session data — nothing derived may ever outlive the
|
||||
/// session's cascade. Both handlers are driven up to that gate with every later dependency
|
||||
/// unavailable, so a regression that moves the gate shows up as a null dereference or an
|
||||
/// unexpected mock call.
|
||||
/// </summary>
|
||||
public class DerivedWriterLivenessTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static SessionDeletionGuard GuardFor(TableEntity? row)
|
||||
{
|
||||
var reader = new Mock<ISessionDeletionInventoryReader>();
|
||||
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(row);
|
||||
return new SessionDeletionGuard(reader.Object, NullLogger<SessionDeletionGuard>.Instance);
|
||||
}
|
||||
|
||||
private static Mock<AdminConfigurationService> AdminConfigWithCorrelationEnabled()
|
||||
{
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { VulnerabilityCorrelationEnabled = true });
|
||||
return adminConfig;
|
||||
}
|
||||
|
||||
public static IEnumerable<object?[]> NotLiveRows()
|
||||
{
|
||||
yield return new object?[] { null };
|
||||
yield return new object?[] { new TableEntity(TenantId, SessionId) { ["DeletionState"] = SessionDeletionState.Running } };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NotLiveRows))]
|
||||
public async Task VulnerabilityCorrelate_skips_a_missing_or_locked_session_before_loading_inventory(TableEntity? row)
|
||||
{
|
||||
var inventoryLoader = new Mock<IVulnerabilityInventoryLoader>(MockBehavior.Strict);
|
||||
var vulnRepo = new Mock<IVulnerabilityRepository>(MockBehavior.Strict);
|
||||
var handler = new VulnerabilityCorrelateHandler(
|
||||
AdminConfigWithCorrelationEnabled().Object,
|
||||
vulnerabilityCorrelation: null!,
|
||||
vulnRepo.Object,
|
||||
Mock.Of<ISessionRepository>(),
|
||||
inventoryLoader.Object,
|
||||
Mock.Of<IAnalyzeOnEnrollmentEndProducer>(),
|
||||
signalRNotification: null!,
|
||||
GuardFor(row),
|
||||
NullLogger<VulnerabilityCorrelateHandler>.Instance);
|
||||
|
||||
await handler.HandleAsync(new VulnerabilityCorrelateEnvelope
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, Reason = VulnerabilityCorrelateHandler.ReasonShutdownInventory, EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
inventoryLoader.VerifyNoOtherCalls();
|
||||
vulnRepo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NotLiveRows))]
|
||||
public async Task Analyze_skips_a_missing_or_locked_session_before_the_rule_engine_runs(TableEntity? row)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
var metricsRepo = new Mock<IMetricsRepository>(MockBehavior.Strict);
|
||||
var handler = new AnalyzeOnEnrollmentEndHandler(
|
||||
ruleService: null!,
|
||||
ruleRepo.Object,
|
||||
Mock.Of<ISessionRepository>(),
|
||||
metricsRepo.Object,
|
||||
signalRNotification: null!,
|
||||
configService: null!,
|
||||
channelDispatcher: null!,
|
||||
GuardFor(row),
|
||||
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
|
||||
|
||||
await handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, Reason = AnalyzeOnEnrollmentEndHandler.ReasonEnrollmentComplete, EnqueuedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
ruleRepo.VerifyNoOtherCalls();
|
||||
metricsRepo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_guard_read_error_propagates_so_the_queue_retries_instead_of_skipping()
|
||||
{
|
||||
var reader = new Mock<ISessionDeletionInventoryReader>();
|
||||
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Azure.RequestFailedException(503, "busy"));
|
||||
var handler = new AnalyzeOnEnrollmentEndHandler(
|
||||
ruleService: null!, Mock.Of<IRuleRepository>(), Mock.Of<ISessionRepository>(), Mock.Of<IMetricsRepository>(),
|
||||
signalRNotification: null!, configService: null!, channelDispatcher: null!,
|
||||
new SessionDeletionGuard(reader.Object, NullLogger<SessionDeletionGuard>.Instance),
|
||||
NullLogger<AnalyzeOnEnrollmentEndHandler>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<Azure.RequestFailedException>(() => handler.HandleAsync(new AnalyzeOnEnrollmentEndEnvelope
|
||||
{
|
||||
TenantId = TenantId, SessionId = SessionId, Reason = AnalyzeOnEnrollmentEndHandler.ReasonEnrollmentComplete, EnqueuedAt = DateTime.UtcNow,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -795,4 +798,38 @@ public class DeviceJourneyAndFtrTests
|
||||
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-00");
|
||||
Assert.DoesNotContain(selected, c => c.History.SerialKey == "dev-01");
|
||||
}
|
||||
|
||||
// ── sweep merge: a failed history read is never "no history" ────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SweepMerge_HistoryReadFailure_SkipsTheDevice_AndKeepsOlderRefsOfTheOthers()
|
||||
{
|
||||
// A failed point read used to come back as null ("no history"): the sweep merged the
|
||||
// window into an empty chain and replaced the row, dropping every older ref.
|
||||
var repo = new Mock<IMetricsRepository>();
|
||||
repo.Setup(r => r.GetDeviceHistoryAsync(TenantA, "broken"))
|
||||
.ThrowsAsync(new RequestFailedException(503, "Server Busy"));
|
||||
repo.Setup(r => r.GetDeviceHistoryAsync(TenantA, "healthy"))
|
||||
.ReturnsAsync(History(TenantA, Ref("old-1", SessionStatus.Failed, T0.AddDays(-200))));
|
||||
var upserted = new List<DeviceHistory>();
|
||||
repo.Setup(r => r.UpsertDeviceHistoryAsync(It.IsAny<DeviceHistory>()))
|
||||
.Callback<DeviceHistory>(upserted.Add)
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var byDevice = new Dictionary<(string TenantId, string SerialKey), List<SessionSummary>>
|
||||
{
|
||||
[(TenantA, "broken")] = new() { Session("w-1") },
|
||||
[(TenantA, "healthy")] = new() { Session("w-2") },
|
||||
};
|
||||
|
||||
var (merged, readFailures, firstError) = await MaintenanceService.MergeWindowIntoDeviceHistoriesAsync(
|
||||
repo.Object, byDevice, new Dictionary<string, ISet<string>>());
|
||||
|
||||
Assert.Equal(1, readFailures);
|
||||
Assert.IsType<RequestFailedException>(firstError);
|
||||
var written = Assert.Single(upserted);
|
||||
Assert.Equal("healthy", written.SerialKey);
|
||||
Assert.Equal(new[] { "old-1", "w-2" }, written.Chain.Select(r => r.SessionId).ToArray());
|
||||
Assert.Same(written, Assert.Single(merged));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path,
|
||||
/// never the bot), recipient validation, the Global-Admin gate, the rendered message, and the
|
||||
/// send against a stubbed provider endpoint.
|
||||
/// </summary>
|
||||
public class EmailChannelProviderTests
|
||||
{
|
||||
private const int Email = (int)WebhookProviderType.Email;
|
||||
private static readonly NotificationScope Platform = NotificationScope.Platform;
|
||||
|
||||
private static Mock<WebhookNotificationService> WebhookMock()
|
||||
=> new(new HttpClient(), NullLogger<WebhookNotificationService>.Instance) { CallBase = false };
|
||||
|
||||
private static Mock<TelegramNotificationService> TelegramMock()
|
||||
=> new(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance) { CallBase = false };
|
||||
|
||||
private static NotificationChannel Channel(string? url, string id = "mail", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = Email, Url = url, Enabled = enabled };
|
||||
|
||||
private static NotificationAlert Alert() => new()
|
||||
{
|
||||
EventType = "PushDeliveryFailed",
|
||||
Title = "Ops Alert: Platform/PushDeliveryFailed",
|
||||
Summary = "A push service refused the platform key",
|
||||
Severity = NotificationSeverity.Error,
|
||||
Facts = { new() { Name = "Category", Value = "Platform" }, new() { Name = "Tenant", Value = "11111111-1111-1111-1111-111111111111" } },
|
||||
Sections = { new() { Title = "What happened", Text = "status 401 <kid=abc>" } },
|
||||
Actions = { new() { Type = "openUrl", Title = "Open alerts", Url = "https://portal.example.invalid/admin/settings/alerts" } },
|
||||
};
|
||||
|
||||
// ── Dispatch routing ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Email_channel_reaches_the_email_sender_with_its_recipients_and_never_the_webhook_path()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var email = new Mock<IEmailChannelSender>();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
var alert = Alert();
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid; two@example.invalid") }, alert, Platform);
|
||||
|
||||
email.Verify(e => e.SendOpsAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Email_channel_without_recipients_is_skipped_and_the_test_path_reports_the_sender_result()
|
||||
{
|
||||
var email = new Mock<IEmailChannelSender>();
|
||||
email.Setup(e => e.SendAlertWithResultAsync("ops@example.invalid", It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "Sent to 1 recipient(s)." });
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>(), email.Object);
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform);
|
||||
email.Verify(e => e.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
|
||||
var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("Sent to 1 recipient(s).", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatcher_without_an_email_transport_skips_the_channel_and_says_so_on_test()
|
||||
{
|
||||
var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of<IPushChannelSender>());
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid") }, Alert(), Platform); // no throw
|
||||
var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not registered", result.Message);
|
||||
}
|
||||
|
||||
// ── Validation and gate ───────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("ops@example.invalid")]
|
||||
[InlineData("ops@example.invalid; second@example.invalid")]
|
||||
[InlineData("ops@example.invalid, second@example.invalid ,third@sub.example.invalid")]
|
||||
[InlineData(" first.last+tag@example.invalid ")]
|
||||
public void ValidateEmailDestination_accepts_one_to_five_plain_addresses(string recipients)
|
||||
=> Assert.Null(TenantConfigValidation.ValidateEmailDestination(recipients));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(";,")]
|
||||
[InlineData("not-an-address")]
|
||||
[InlineData("Ops Team <ops@example.invalid>")]
|
||||
[InlineData("ops@localhost")]
|
||||
[InlineData("https://hooks.example.invalid/x")]
|
||||
[InlineData("a@x.invalid;b@x.invalid;c@x.invalid;d@x.invalid;e@x.invalid;f@x.invalid")]
|
||||
public void ValidateEmailDestination_rejects_blank_malformed_named_hostless_and_too_many(string? recipients)
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateEmailDestination(recipients));
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_uses_the_recipient_rule_not_the_ssrf_url_gate()
|
||||
{
|
||||
var ok = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(ok));
|
||||
|
||||
var bad = NotificationChannel.SerializeList(new[] { Channel("https://hooks.example.invalid/x") });
|
||||
Assert.Contains("not a valid e-mail address", TenantConfigValidation.ValidateNotificationChannels(bad));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Email_provider_is_global_admin_only()
|
||||
{
|
||||
Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider(Email));
|
||||
var candidate = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") });
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: true));
|
||||
var refused = TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: false);
|
||||
Assert.Equal("E-mail channels can only be configured by a Global Administrator.", refused);
|
||||
|
||||
// A GA-created channel survives a tenant admin's unrelated save; retargeting it does not.
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, candidate, isGlobalAdmin: false));
|
||||
var retargeted = NotificationChannel.SerializeList(new[] { Channel("other@example.invalid") });
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, candidate, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Renderer_reads_like_a_message_not_a_log_line()
|
||||
{
|
||||
var alert = Alert();
|
||||
alert.DataJson = "{\"service\":\"apple\",\"statusCode\":401,\"note\":\"<script>\",\"nested\":{\"a\":1}}";
|
||||
|
||||
// No brackets, no severity shouting, the event name as words, the category in parentheses.
|
||||
Assert.Equal("Autopilot Monitor: Push delivery failed (Platform)", EmailAlertRenderer.Subject(alert));
|
||||
|
||||
var html = EmailAlertRenderer.Html(alert);
|
||||
Assert.Contains("Autopilot Monitor raised an error in the Platform category.", html); // one sentence first
|
||||
Assert.Contains("<h1 style=\"font-size:18px;margin:0 0 8px 0;\">Push delivery failed</h1>", html);
|
||||
Assert.DoesNotContain("Ops Alert: Platform/PushDeliveryFailed", html); // the machine title stays out
|
||||
Assert.Contains("A push service refused the platform key", html);
|
||||
Assert.Contains("<td style=\"padding:3px 0;\">Platform</td>", html); // facts as a table
|
||||
Assert.Contains("status 401 <kid=abc>", html); // section text escaped
|
||||
Assert.DoesNotContain("<kid=abc>", html);
|
||||
Assert.Contains("<td style=\"padding:3px 0;\"><script></td>", html); // payload as rows, escaped
|
||||
Assert.Contains("<td style=\"padding:3px 0;\">{"a":1}</td>", html); // nested values stay compact JSON
|
||||
Assert.DoesNotContain("<pre", html); // no raw JSON block for an object
|
||||
Assert.Contains("Open alerts: <a href=\"https://portal.example.invalid/admin/settings/alerts\"", html); // visible link
|
||||
Assert.Contains("sent automatically by Autopilot Monitor", html);
|
||||
|
||||
var text = EmailAlertRenderer.Text(alert);
|
||||
Assert.StartsWith("Autopilot Monitor raised an error in the Platform category.", text);
|
||||
Assert.Contains("Push delivery failed\n", text);
|
||||
Assert.Contains("Category: Platform\n", text);
|
||||
Assert.Contains("note: <script>\n", text); // text part is not HTML-escaped
|
||||
Assert.Contains("Open alerts: https://portal.example.invalid/admin/settings/alerts", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renderer_drops_non_http_links_caps_a_non_object_payload_and_survives_an_empty_alert()
|
||||
{
|
||||
var alert = new NotificationAlert { Title = "", Summary = "", DataJson = new string('x', EmailAlertRenderer.MaxPayloadChars + 100) };
|
||||
alert.Actions.Add(new NotificationAction { Type = "openUrl", Title = "bad", Url = "javascript:alert(1)" });
|
||||
|
||||
Assert.Equal("Autopilot Monitor: Notification", EmailAlertRenderer.Subject(alert));
|
||||
var html = EmailAlertRenderer.Html(alert);
|
||||
Assert.DoesNotContain("javascript:", html);
|
||||
Assert.Contains("(truncated)", html);
|
||||
Assert.True(html.Length < EmailAlertRenderer.MaxPayloadChars + 3000);
|
||||
Assert.Equal("Autopilot Monitor: Hello world", EmailAlertRenderer.Subject(new NotificationAlert { Title = "Hello world", Summary = "" }));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PushDeliveryFailed", "Push delivery failed")]
|
||||
[InlineData("SlaBreachNotification", "Sla breach notification")]
|
||||
[InlineData("SLAEvaluation", "SLA evaluation")]
|
||||
[InlineData("enrollment_failed", "Enrollment failed")]
|
||||
[InlineData("TenantSignup", "Tenant signup")]
|
||||
public void Humanize_splits_event_names_into_words(string eventName, string expected)
|
||||
=> Assert.Equal(expected, EmailAlertRenderer.Humanize(eventName));
|
||||
|
||||
// ── Sending through the provider path ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Sender_posts_one_message_per_recipient_and_reports_what_the_provider_accepted()
|
||||
{
|
||||
var handler = new StubHandler();
|
||||
var (service, _) = Sender(handler, apiKey: "key");
|
||||
|
||||
var result = await service.SendAlertWithResultAsync("ops@example.invalid; second@example.invalid", Alert());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("Sent to 2 recipient(s). Provider status: sent, id abc,abc.", result.Message);
|
||||
Assert.Equal(2, handler.Bodies.Count);
|
||||
Assert.Contains("\"to\":[{\"email\":\"ops@example.invalid\"", handler.Bodies[0]);
|
||||
Assert.Contains("\"subject\":\"Autopilot Monitor: Push delivery failed (Platform)\"", handler.Bodies[0]);
|
||||
Assert.Contains("\"tags\":[\"alert\"]", handler.Bodies[0]);
|
||||
Assert.Contains("\"track_opens\":false", handler.Bodies[0]);
|
||||
Assert.Contains("\"text\":\"Autopilot Monitor raised an error", handler.Bodies[0]); // own text part
|
||||
Assert.Contains("\"auto_text\":false", handler.Bodies[0]);
|
||||
Assert.Contains("\"headers\":{\"Auto-Submitted\":\"auto-generated\",\"X-Auto-Response-Suppress\":\"All\"}", handler.Bodies[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sender_reports_a_rejection_and_an_unconfigured_provider_without_throwing()
|
||||
{
|
||||
var handler = new StubHandler { Responder = _ => StubHandler.Json(HttpStatusCode.OK, "[{\"email\":\"ops@example.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\"}]") };
|
||||
var (service, _) = Sender(handler, apiKey: "key");
|
||||
var rejected = await service.SendAlertWithResultAsync("ops@example.invalid", Alert());
|
||||
Assert.False(rejected.Success);
|
||||
Assert.Contains("accepted 0 of 1", rejected.Message);
|
||||
Assert.Contains("Provider status: rejected", rejected.Message);
|
||||
|
||||
var (unconfigured, _) = Sender(new StubHandler(), apiKey: "");
|
||||
var skipped = await unconfigured.SendAlertWithResultAsync("ops@example.invalid", Alert());
|
||||
Assert.False(skipped.Success);
|
||||
Assert.Contains("not configured", skipped.Message);
|
||||
|
||||
await unconfigured.SendOpsAlertAsync("ops@example.invalid", Alert()); // never throws
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseRecipients_trims_dedupes_and_keeps_order()
|
||||
{
|
||||
var list = EmailNotificationService.ParseRecipients(" a@x.invalid ; B@x.invalid, a@x.invalid ,, c@x.invalid ");
|
||||
Assert.Equal(new[] { "a@x.invalid", "B@x.invalid", "c@x.invalid" }, list);
|
||||
Assert.Empty(EmailNotificationService.ParseRecipients(null));
|
||||
}
|
||||
|
||||
private static (EmailNotificationService Service, EmailService Email) Sender(StubHandler handler, string apiKey)
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[EmailService.ApiKeyConfigKey] = apiKey,
|
||||
["Email:Endpoint"] = "https://mail.example.invalid/send",
|
||||
}).Build();
|
||||
var templates = new EmailTemplateService(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
|
||||
NullLogger<EmailTemplateService>.Instance);
|
||||
var email = new EmailService(new HttpClient(handler, disposeHandler: false), config, templates, NullLogger<EmailService>.Instance);
|
||||
return (new EmailNotificationService(email, NullLogger<EmailNotificationService>.Instance), email);
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
public List<string> Bodies { get; } = new();
|
||||
public Func<HttpRequestMessage, HttpResponseMessage> Responder { get; set; } = _ =>
|
||||
Json(HttpStatusCode.OK, "[{\"email\":\"x@y.invalid\",\"status\":\"sent\",\"_id\":\"abc\"}]");
|
||||
|
||||
public static HttpResponseMessage Json(HttpStatusCode status, string body)
|
||||
=> new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Bodies.Add(request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken));
|
||||
return Responder(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,9 +111,14 @@ public sealed class EmailServiceTests
|
||||
Assert.Equal(EmailService.DefaultFromName, message.GetProperty("from_name").GetString());
|
||||
Assert.Equal("it@contoso.invalid", message.GetProperty("to")[0].GetProperty("email").GetString());
|
||||
Assert.Equal("to", message.GetProperty("to")[0].GetProperty("type").GetString());
|
||||
Assert.Equal(EmailTemplates.PreviewApprovedSubject, message.GetProperty("subject").GetString());
|
||||
Assert.Equal("Your Autopilot Monitor access for contoso.invalid is ready", message.GetProperty("subject").GetString());
|
||||
Assert.Contains("contoso.invalid", message.GetProperty("html").GetString());
|
||||
Assert.True(message.GetProperty("auto_text").GetBoolean());
|
||||
// A text part of our own (HtmlToText) instead of the provider's derived one, and the
|
||||
// RFC 3834 header on every automated mail.
|
||||
Assert.False(message.GetProperty("auto_text").GetBoolean());
|
||||
Assert.Contains("contoso.invalid", message.GetProperty("text").GetString());
|
||||
Assert.DoesNotContain("<", message.GetProperty("text").GetString());
|
||||
Assert.Equal("auto-generated", message.GetProperty("headers").GetProperty("Auto-Submitted").GetString());
|
||||
Assert.False(message.GetProperty("track_opens").GetBoolean());
|
||||
Assert.False(message.GetProperty("track_clicks").GetBoolean());
|
||||
Assert.Equal("welcome", message.GetProperty("tags")[0].GetString());
|
||||
@@ -253,7 +258,8 @@ public sealed class EmailServiceTests
|
||||
// The mail is sent from a noreply address after portal access is gone, so the
|
||||
// feedback pointers must be external channels the recipient can actually reach.
|
||||
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.Contains("linkedin.com", html);
|
||||
Assert.Contains("github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.DoesNotContain("linkedin.com", html); // one feedback link only (user go 2026-10-08)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+2
-3
@@ -137,7 +137,6 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
[InlineData("PUT", "/api/rules/gather/rule-1", "rules/gather/{ruleId}")]
|
||||
[InlineData("DELETE", "/api/tenants/tid-1/admins/user@contoso.com", "tenants/{tenantId}/admins/{adminUpn}")]
|
||||
[InlineData("PATCH", "/api/tenants/tid-1/admins/user@contoso.com/permissions", "tenants/{tenantId}/admins/{adminUpn}/permissions")]
|
||||
[InlineData("DELETE", "/api/devices/block/SN123456", "devices/block/{encodedSerialNumber}")]
|
||||
[InlineData("DELETE", "/api/versions/block/v1.0.*", "versions/block/{encodedPattern}")]
|
||||
[InlineData("PATCH", "/api/global/session-reports/report-1/note", "global/session-reports/{reportId}/note")]
|
||||
[InlineData("POST", "/api/rules/analyze/ANALYZE-ID-001/create-from-template", "rules/analyze/{ruleId}/create-from-template")]
|
||||
@@ -284,8 +283,8 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/global/apps/list", "global/apps/list")]
|
||||
[InlineData("GET", "/api/global/apps/Company%20Portal/analytics", "global/apps/{appName}/analytics")]
|
||||
[InlineData("GET", "/api/global/apps/Company%20Portal/sessions", "global/apps/{appName}/sessions")]
|
||||
[InlineData("GET", "/api/global/apps/analytics", "global/apps/analytics")]
|
||||
[InlineData("GET", "/api/global/apps/sessions", "global/apps/sessions")]
|
||||
public void GlobalAppsRoutes_AreGlobalReadOrAdmin(string method, string path, string expectedTemplate)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
|
||||
|
||||
@@ -355,7 +355,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
[Fact]
|
||||
public void Classify_device_provisioned_user_phase_pending_within_grace_is_AwaitingUser()
|
||||
{
|
||||
// The dominant crcins.com case: DeviceSetup 4/4, AccountSetup 0/5, silent, 6h in.
|
||||
// The dominant field case: DeviceSetup 4/4, AccountSetup 0/5, silent, 6h in.
|
||||
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup05) }, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, status);
|
||||
Assert.Contains("Device Setup completed", reason);
|
||||
@@ -382,7 +382,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
public void ExtractRollup_agent_timeout_enrollment_failed_is_not_explicit_failure()
|
||||
{
|
||||
// The max-lifetime watchdog's enrollment_failed(failureType=agent_timeout) is "the agent
|
||||
// gave up waiting", not a failure verdict — it must not poison rule 1 (tenant a53e67ec).
|
||||
// gave up waiting", not a failure verdict — it must not poison rule 1 (misclassification audit 2026-07-16).
|
||||
var timeout = Evt("enrollment_failed",
|
||||
data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
|
||||
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasExplicitFailure);
|
||||
@@ -589,7 +589,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
}
|
||||
}
|
||||
|
||||
// -------- WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21) --------
|
||||
// -------- WhiteGlove Part-2 awaiting-user gate (field analysis 2026-08-21) --------
|
||||
|
||||
private static readonly DateTime Resumed = Start.AddMinutes(20);
|
||||
|
||||
@@ -603,7 +603,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
return (status, reason);
|
||||
}
|
||||
|
||||
/// <summary>The fairstone event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
|
||||
/// <summary>The field event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
|
||||
private static EnrollmentEvent[] WgParkedEvents() => new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
|
||||
@@ -660,7 +660,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_parked_within_grace_is_AwaitingUser()
|
||||
{
|
||||
// The fairstone shape: technician powers the device off at the logon screen minutes
|
||||
// The field shape: technician powers the device off at the logon screen minutes
|
||||
// after the reseal-reboot. Part-1 whiteglove_complete must NOT reconcile to Succeeded.
|
||||
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, status);
|
||||
@@ -734,7 +734,7 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
|
||||
// -------- Self-deploying profile gate (kiosk tenant aebdce78, audit 2026-08-23) --------
|
||||
// -------- Self-deploying profile gate (kiosk tenant, audit 2026-08-23) --------
|
||||
|
||||
private static (SessionStatus, string) ClassifySelfDeploying(
|
||||
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 3, int grace = 51)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Functions.Feedback;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The rating bubble asks a person at most twice: an X click counts, the second ask waits for the pause,
|
||||
/// a rating ends it for good and is never replaced. Only clicks count.
|
||||
/// </summary>
|
||||
public sealed class FeedbackPromptRulesTests
|
||||
{
|
||||
private const int Pause = 60;
|
||||
private static readonly DateTime Now = new(2026, 10, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static FeedbackEntry Row(bool submitted, int dismissals, int daysAgo) => new()
|
||||
{
|
||||
Upn = "alice@contoso.invalid",
|
||||
TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
DisplayName = "Alice",
|
||||
Submitted = submitted,
|
||||
Dismissed = !submitted,
|
||||
Rating = submitted ? 5 : null,
|
||||
Comment = submitted ? "Great" : null,
|
||||
DismissCount = dismissals,
|
||||
InteractedAt = Now.AddDays(-daysAgo),
|
||||
};
|
||||
|
||||
private static FeedbackAnswer Dismiss() =>
|
||||
new("alice@contoso.invalid", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "Alice", true, null, null);
|
||||
|
||||
private static FeedbackAnswer Rate(int stars, string? comment) =>
|
||||
new("alice@contoso.invalid", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "Alice", false, stars, comment);
|
||||
|
||||
// ── When the bubble may ask ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NeverAnswered_IsAsked()
|
||||
=> Assert.True(FeedbackPromptRules.IsEligible(null, Pause, Now));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(0, 400)]
|
||||
[InlineData(1, 400)]
|
||||
public void AfterARating_NeverAgain(int dismissals, int daysAgo)
|
||||
=> Assert.False(FeedbackPromptRules.IsEligible(Row(submitted: true, dismissals, daysAgo), Pause, Now));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, false)]
|
||||
[InlineData(59, false)]
|
||||
[InlineData(60, true)]
|
||||
[InlineData(400, true)]
|
||||
public void AfterTheFirstDismissal_OnceMore_AfterThePause(int daysAgo, bool expected)
|
||||
=> Assert.Equal(expected, FeedbackPromptRules.IsEligible(Row(submitted: false, 1, daysAgo), Pause, Now));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(400)]
|
||||
public void AfterTheSecondDismissal_NeverAgain(int daysAgo)
|
||||
=> Assert.False(FeedbackPromptRules.IsEligible(Row(submitted: false, 2, daysAgo), Pause, Now));
|
||||
|
||||
[Fact]
|
||||
public void PauseZero_MeansOneAskOnly()
|
||||
=> Assert.False(FeedbackPromptRules.IsEligible(Row(submitted: false, 1, 400), 0, Now));
|
||||
|
||||
// ── What one answer does ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FirstDismissal_CountsOne()
|
||||
{
|
||||
var row = FeedbackPromptRules.Apply(null, Dismiss(), Now)!;
|
||||
|
||||
Assert.True(row.Dismissed);
|
||||
Assert.False(row.Submitted);
|
||||
Assert.Equal(1, row.DismissCount);
|
||||
Assert.Equal(Now, row.InteractedAt);
|
||||
Assert.Null(row.Rating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondDismissal_CountsTwo_AndEndsTheAsking()
|
||||
{
|
||||
var row = FeedbackPromptRules.Apply(Row(submitted: false, 1, 61), Dismiss(), Now)!;
|
||||
|
||||
Assert.Equal(2, row.DismissCount);
|
||||
Assert.False(FeedbackPromptRules.IsEligible(row, Pause, Now.AddDays(400)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DismissalBeyondTheSecond_ChangesNothing()
|
||||
=> Assert.Null(FeedbackPromptRules.Apply(Row(submitted: false, 2, 1), Dismiss(), Now));
|
||||
|
||||
[Fact]
|
||||
public void RatingAfterADismissal_IsStored_AndKeepsTheCount()
|
||||
{
|
||||
var row = FeedbackPromptRules.Apply(Row(submitted: false, 1, 61), Rate(4, "Mostly good"), Now)!;
|
||||
|
||||
Assert.True(row.Submitted);
|
||||
Assert.False(row.Dismissed);
|
||||
Assert.Equal(4, row.Rating);
|
||||
Assert.Equal("Mostly good", row.Comment);
|
||||
Assert.Equal(1, row.DismissCount);
|
||||
Assert.Equal("alice@contoso.invalid", row.Upn);
|
||||
Assert.Equal("Alice", row.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARatedRow_IsFinal()
|
||||
{
|
||||
// A second tab's dismissal or second rating must neither replace the rating nor ping again.
|
||||
var rated = Row(submitted: true, 0, 1);
|
||||
|
||||
Assert.Null(FeedbackPromptRules.Apply(rated, Dismiss(), Now));
|
||||
Assert.Null(FeedbackPromptRules.Apply(rated, Rate(1, "changed my mind"), Now));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// App names and serial numbers are free text and may contain '/', '%' and '+'. They travel as query
|
||||
/// values: in a path segment the host decodes an escaped slash to a separator before routing (a 404
|
||||
/// before any function runs), and a catalog placeholder never matches a '/'. The encoded forms below
|
||||
/// are what the portal's URLSearchParams sends; req.Query decodes them exactly once.
|
||||
/// </summary>
|
||||
public class FreeTextNameQueryParamTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-0000-0000-0000000000a7";
|
||||
|
||||
public static TheoryData<string, string> Names => new()
|
||||
{
|
||||
{ "a/b", "a%2Fb" },
|
||||
{ "100%", "100%25" },
|
||||
{ "x+y", "x%2By" },
|
||||
{ "Company Portal", "Company+Portal" },
|
||||
// A literal escape sequence survives; the former second decode turned it into "a b".
|
||||
{ "a%20b", "a%2520b" },
|
||||
};
|
||||
|
||||
// ── Policy catalog ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/apps/analytics", "apps/analytics", EndpointPolicy.MemberRead, TenantScoping.None)]
|
||||
[InlineData("GET", "/api/apps/sessions", "apps/sessions", EndpointPolicy.MemberRead, TenantScoping.None)]
|
||||
[InlineData("GET", "/api/global/apps/analytics", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
|
||||
[InlineData("GET", "/api/global/apps/sessions", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
|
||||
[InlineData("DELETE", "/api/devices/block", "devices/block", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
|
||||
public void Free_name_routes_are_literal_and_keep_their_policy(
|
||||
string method, string path, string template, EndpointPolicy policy, TenantScoping scoping)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(template, entry!.RouteTemplate);
|
||||
Assert.Equal(policy, entry.Policy);
|
||||
Assert.Equal(scoping, entry.TenantScoping);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_route_carries_an_app_name_or_serial_number_in_the_path()
|
||||
{
|
||||
var offenders = EndpointAccessPolicyCatalog.Entries
|
||||
.Where(e => e.RouteTemplate.Contains("{appName}", StringComparison.OrdinalIgnoreCase)
|
||||
|| e.RouteTemplate.Contains("serialNumber}", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
|
||||
.ToList();
|
||||
|
||||
Assert.Empty(offenders);
|
||||
}
|
||||
|
||||
// ── Query path: the name reaches the handler unchanged ──────────────────
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task App_analytics_matches_the_exact_name(string name, string encoded)
|
||||
{
|
||||
var function = new GetAppAnalyticsFunction(
|
||||
NullLogger<GetAppAnalyticsFunction>.Instance, MetricsWith(name), Sessions(), RegressionTracker());
|
||||
var req = MemberRequest($"?appName={encoded}&days=30");
|
||||
|
||||
var res = await function.Run(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
var root = Json(res);
|
||||
Assert.Equal(name, root.GetProperty("appName").GetString());
|
||||
Assert.Equal(1, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task App_sessions_match_the_exact_name(string name, string encoded)
|
||||
{
|
||||
var function = new GetAppSessionsFunction(NullLogger<GetAppSessionsFunction>.Instance, MetricsWith(name), Sessions());
|
||||
var req = MemberRequest($"?appName={encoded}&days=30");
|
||||
|
||||
var res = await function.Run(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
Assert.Equal(1, Json(res).GetProperty("total").GetInt32());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task Global_app_analytics_and_sessions_match_the_exact_name(string name, string encoded)
|
||||
{
|
||||
var analytics = new GetGlobalAppAnalyticsFunction(
|
||||
NullLogger<GetGlobalAppAnalyticsFunction>.Instance, MetricsWith(name), Sessions(), RegressionTracker());
|
||||
var sessions = new GetGlobalAppSessionsFunction(NullLogger<GetGlobalAppSessionsFunction>.Instance, MetricsWith(name), Sessions());
|
||||
var query = $"?appName={encoded}&days=30&tenantId={TenantId}";
|
||||
|
||||
var analyticsRes = await analytics.Run(EndpointHarness.Request(TenantId, queryString: query).Req);
|
||||
var sessionsRes = await sessions.Run(EndpointHarness.Request(TenantId, queryString: query).Req);
|
||||
|
||||
Assert.Equal(1, Json(analyticsRes).GetProperty("summary").GetProperty("totalInstalls").GetInt32());
|
||||
Assert.Equal(1, Json(sessionsRes).GetProperty("total").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task App_routes_without_a_name_are_a_bad_request()
|
||||
{
|
||||
var analytics = new GetAppAnalyticsFunction(
|
||||
NullLogger<GetAppAnalyticsFunction>.Instance, MetricsWith("x"), Sessions(), RegressionTracker());
|
||||
var globalSessions = new GetGlobalAppSessionsFunction(NullLogger<GetGlobalAppSessionsFunction>.Instance, MetricsWith("x"), Sessions());
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await analytics.Run(MemberRequest("?days=30"))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await globalSessions.Run(EndpointHarness.Request(TenantId, queryString: "?appName=%20&days=30").Req)).StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Names))]
|
||||
public async Task Unblock_receives_the_exact_serial_number(string serial, string encoded)
|
||||
{
|
||||
var repo = new Mock<IDeviceSecurityRepository>();
|
||||
repo.Setup(r => r.UnblockDeviceAsync(TenantId, serial)).ReturnsAsync(Array.Empty<string>());
|
||||
var function = UnblockFunction(repo.Object);
|
||||
|
||||
var res = await function.UnblockDevice(
|
||||
EndpointHarness.Request(TenantId, queryString: $"?serialNumber={encoded}&tenantId={TenantId}").Req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
repo.Verify(r => r.UnblockDeviceAsync(TenantId, serial), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unblock_without_a_serial_number_is_a_bad_request()
|
||||
{
|
||||
var repo = new Mock<IDeviceSecurityRepository>();
|
||||
var function = UnblockFunction(repo.Object);
|
||||
|
||||
var res = await function.UnblockDevice(EndpointHarness.Request(TenantId, queryString: $"?tenantId={TenantId}").Req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
repo.Verify(r => r.UnblockDeviceAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Rig ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>One finished install of <paramref name="name"/>, plus decoys named after its parts.</summary>
|
||||
private static IMetricsRepository MetricsWith(string name)
|
||||
{
|
||||
AppInstallSummary Install(string appName, string sessionId) => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = sessionId,
|
||||
AppName = appName,
|
||||
Status = "Succeeded",
|
||||
StartedAt = DateTime.UtcNow.AddHours(-2),
|
||||
DurationSeconds = 30,
|
||||
};
|
||||
|
||||
var summaries = new List<AppInstallSummary> { Install(name, "s-1"), Install("a", "s-2"), Install("b", "s-3"), Install("a b", "s-4") };
|
||||
var repo = new Mock<IMetricsRepository>();
|
||||
repo.Setup(r => r.GetAppsDashboardSummariesAsync(It.IsAny<DateTime>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(() => summaries.ToList());
|
||||
return repo.Object;
|
||||
}
|
||||
|
||||
private static ISessionRepository Sessions()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((SessionSummary?)null);
|
||||
return repo.Object;
|
||||
}
|
||||
|
||||
private static IHardwareRejectionNotificationTracker RegressionTracker()
|
||||
{
|
||||
var tracker = new Mock<IHardwareRejectionNotificationTracker>();
|
||||
tracker.Setup(t => t.GetAppVersionRegressionsAsync(It.IsAny<string>())).ReturnsAsync(new List<AppVersionRegressionAlert>());
|
||||
return tracker.Object;
|
||||
}
|
||||
|
||||
/// <summary>A member request: the JWT tenant comes from the principal the authentication middleware would set.</summary>
|
||||
private static HttpRequestData MemberRequest(string queryString)
|
||||
{
|
||||
var (req, context) = EndpointHarness.Request(TenantId, queryString: queryString);
|
||||
context.Items["ClaimsPrincipal"] = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", TenantId) }, "test"));
|
||||
return req;
|
||||
}
|
||||
|
||||
private static DeviceBlockFunction UnblockFunction(IDeviceSecurityRepository securityRepo)
|
||||
{
|
||||
var maintenance = new Mock<IMaintenanceRepository>();
|
||||
maintenance
|
||||
.Setup(m => m.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var opsEvents = new OpsEventService(
|
||||
Mock.Of<IOpsEventRepository>(), NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
|
||||
|
||||
return new DeviceBlockFunction(
|
||||
NullLogger<DeviceBlockFunction>.Instance,
|
||||
new BlockedDeviceService(securityRepo, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance),
|
||||
maintenance.Object,
|
||||
opsEvents);
|
||||
}
|
||||
|
||||
private static JsonElement Json(HttpResponseData res)
|
||||
{
|
||||
res.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(res.Body);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -82,63 +82,59 @@ public class GenericWebhookTests
|
||||
Assert.Equal("hardware_rejected", alert.EventType);
|
||||
}
|
||||
|
||||
// ── TenantConfiguration.GetGenericWebhookHeaders ──────────────────────
|
||||
// ── NotificationChannel.GetCustomHeaders ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_ReturnsEmpty_ForNonGenericProvider()
|
||||
public void GetCustomHeaders_ReturnsEmpty_ForNonGenericProvider()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
var channel = new NotificationChannel
|
||||
{
|
||||
WebhookUrl = "https://hooks.slack.com/x",
|
||||
WebhookProviderType = (int)WebhookProviderType.Slack,
|
||||
WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer x\"}",
|
||||
ProviderType = (int)WebhookProviderType.Slack,
|
||||
CustomHeadersJson = "{\"Authorization\":\"Bearer x\"}",
|
||||
};
|
||||
|
||||
Assert.Empty(config.GetGenericWebhookHeaders());
|
||||
Assert.Empty(channel.GetCustomHeaders());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_ParsesHeaders_ForGenericProvider()
|
||||
public void GetCustomHeaders_ParsesHeaders_ForGenericProvider()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
var channel = new NotificationChannel
|
||||
{
|
||||
WebhookUrl = "https://tickets.example.com/in",
|
||||
WebhookProviderType = (int)WebhookProviderType.GenericJson,
|
||||
WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer abc\",\"X-Api-Key\":\"k1\"}",
|
||||
ProviderType = (int)WebhookProviderType.GenericJson,
|
||||
CustomHeadersJson = "{\"Authorization\":\"Bearer abc\",\"X-Api-Key\":\"k1\"}",
|
||||
};
|
||||
|
||||
var headers = config.GetGenericWebhookHeaders();
|
||||
var headers = channel.GetCustomHeaders();
|
||||
Assert.Equal("Bearer abc", headers["Authorization"]);
|
||||
Assert.Equal("k1", headers["X-Api-Key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_DropsRestrictedHeaders()
|
||||
public void GetCustomHeaders_DropsRestrictedHeaders()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
var channel = new NotificationChannel
|
||||
{
|
||||
WebhookUrl = "https://tickets.example.com/in",
|
||||
WebhookProviderType = (int)WebhookProviderType.GenericJson,
|
||||
WebhookCustomHeadersJson = "{\"Host\":\"evil.example.com\",\"Content-Length\":\"0\",\"X-Ok\":\"v\"}",
|
||||
ProviderType = (int)WebhookProviderType.GenericJson,
|
||||
CustomHeadersJson = "{\"Host\":\"evil.example.com\",\"Content-Length\":\"0\",\"X-Ok\":\"v\"}",
|
||||
};
|
||||
|
||||
var headers = config.GetGenericWebhookHeaders();
|
||||
var headers = channel.GetCustomHeaders();
|
||||
Assert.False(headers.ContainsKey("Host"));
|
||||
Assert.False(headers.ContainsKey("Content-Length"));
|
||||
Assert.True(headers.ContainsKey("X-Ok"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGenericWebhookHeaders_ReturnsEmpty_ForMalformedJson()
|
||||
public void GetCustomHeaders_ReturnsEmpty_ForMalformedJson()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
var channel = new NotificationChannel
|
||||
{
|
||||
WebhookUrl = "https://tickets.example.com/in",
|
||||
WebhookProviderType = (int)WebhookProviderType.GenericJson,
|
||||
WebhookCustomHeadersJson = "{not valid",
|
||||
ProviderType = (int)WebhookProviderType.GenericJson,
|
||||
CustomHeadersJson = "{not valid",
|
||||
};
|
||||
|
||||
Assert.Empty(config.GetGenericWebhookHeaders());
|
||||
Assert.Empty(channel.GetCustomHeaders());
|
||||
}
|
||||
|
||||
// ── TenantConfigValidation.ValidateWebhookCustomHeaders ────
|
||||
|
||||
@@ -68,8 +68,8 @@ public class GetImeVersionHistoryRedactionTests
|
||||
{
|
||||
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
|
||||
|
||||
Assert.DoesNotContain("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("baae0453-6d19-47b5-aa58-8bed5a572fc8", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("aaaaaaaa-1111-4222-8333-444444444444", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("bbbbbbbb-1111-4222-8333-444444444444", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -109,8 +109,8 @@ public class GetImeVersionHistoryRedactionTests
|
||||
using var doc = JsonDocument.Parse(SerializeFor(hasGlobalScope: true, MakeEntry()));
|
||||
|
||||
var row = doc.RootElement[0];
|
||||
Assert.Equal("57f34dd1-6a42-47f9-9bc0-b921fa6caa30", row.GetProperty("firstSeenTenantId").GetString());
|
||||
Assert.Equal("baae0453-6d19-47b5-aa58-8bed5a572fc8", row.GetProperty("firstSeenSessionId").GetString());
|
||||
Assert.Equal("aaaaaaaa-1111-4222-8333-444444444444", row.GetProperty("firstSeenTenantId").GetString());
|
||||
Assert.Equal("bbbbbbbb-1111-4222-8333-444444444444", row.GetProperty("firstSeenSessionId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -300,8 +300,8 @@ public class GetImeVersionHistoryRedactionTests
|
||||
{
|
||||
Version = version,
|
||||
FirstSeenAt = firstSeenAt ?? First,
|
||||
FirstSeenSessionId = "baae0453-6d19-47b5-aa58-8bed5a572fc8",
|
||||
FirstSeenTenantId = "57f34dd1-6a42-47f9-9bc0-b921fa6caa30",
|
||||
FirstSeenSessionId = "bbbbbbbb-1111-4222-8333-444444444444",
|
||||
FirstSeenTenantId = "aaaaaaaa-1111-4222-8333-444444444444",
|
||||
LastSeenAt = Last,
|
||||
SessionCount = 3109,
|
||||
MsiArchiveStatus = "Archived",
|
||||
|
||||
@@ -185,18 +185,18 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
DiagnosticsBlobSasUrl = "https://example.blob.core.windows.net/diagnostics?sv=secret-sas-token",
|
||||
TeamsWebhookUrl = "https://outlook.office.com/webhook/secret-team-hook",
|
||||
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
|
||||
NotificationChannelsJson = "[{\"id\":\"c1\",\"providerType\":2,\"url\":\"https://hooks.example.com/secret-channel-hook\"}]",
|
||||
};
|
||||
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-sas-token", json);
|
||||
Assert.DoesNotContain("secret-team-hook", json);
|
||||
Assert.DoesNotContain("secret-generic-hook", json);
|
||||
Assert.DoesNotContain("secret-channel-hook", json);
|
||||
Assert.DoesNotContain("diagnosticsBlobSasUrl", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("teamsWebhookUrl", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("webhookUrl", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("notificationChannelsJson", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ── diagnosticsUploadConfigured (drives the session-detail "Collect Logs" button) ──
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>The text/plain twin of the HTML mails (HtmlToText): what it keeps, what it drops, how it breaks lines.</summary>
|
||||
public class HtmlToTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Drops_head_style_script_and_comments_and_keeps_the_prose()
|
||||
{
|
||||
const string html = "<!DOCTYPE html><html><head><title>t</title><style>p{color:red}</style></head>" +
|
||||
"<body><!-- note --><script>alert(1)</script><p>Hello <strong>world</strong></p></body></html>";
|
||||
Assert.Equal("Hello world", HtmlToText.Convert(html));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Turns_blocks_lists_and_breaks_into_lines_and_decodes_entities()
|
||||
{
|
||||
const string html = "<h2>Welcome</h2><p>Great – access for <strong>contoso.invalid</strong> is ready.<br>Second line.</p>" +
|
||||
"<ul><li>One & two</li><li>Three</li></ul><table><tr><td>a</td><td>b</td></tr></table>";
|
||||
var text = HtmlToText.Convert(html);
|
||||
Assert.Equal("Welcome\nGreat – access for contoso.invalid is ready.\nSecond line.\n\n- One & two\n- Three\na b", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keeps_link_targets_and_collapses_whitespace()
|
||||
{
|
||||
const string html = "<p>You can now <a href=\"https://portal.example.invalid/\" target=\"_blank\" style=\"color:red\">sign in</a> and start.</p>" +
|
||||
"<p><a href=\"https://docs.example.invalid\">https://docs.example.invalid</a></p>" +
|
||||
"<p><a href=\"mailto:x@example.invalid\">write us</a> or <a href=\"https://x.invalid/a?b=1&c=2\">this</a></p>";
|
||||
var text = HtmlToText.Convert(html);
|
||||
Assert.Equal("You can now sign in (https://portal.example.invalid/) and start.\nhttps://docs.example.invalid\nwrite us or this (https://x.invalid/a?b=1&c=2)", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Survives_empty_and_broken_markup()
|
||||
{
|
||||
Assert.Equal(string.Empty, HtmlToText.Convert(null));
|
||||
Assert.Equal(string.Empty, HtmlToText.Convert(" "));
|
||||
Assert.Equal("unterminated bold", HtmlToText.Convert("<p>unterminated <b>bold"));
|
||||
Assert.Equal("x", HtmlToText.Convert("<a href='nolink'>x</a>"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renders_the_default_welcome_template_as_readable_text()
|
||||
{
|
||||
var text = HtmlToText.Convert(EmailTemplates.GetPreviewApprovedHtml("contoso.invalid"));
|
||||
Assert.Contains("Welcome to Autopilot Monitor", text);
|
||||
Assert.Contains("contoso.invalid", text);
|
||||
Assert.DoesNotContain("<", text);
|
||||
Assert.DoesNotContain("style=", text);
|
||||
Assert.Contains("https://", text); // the links survive as URLs
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@ public class InfrastructureWireParityTests
|
||||
unrestrictedModeEnabled = false,
|
||||
mcpClientRegistrationEnabled = true,
|
||||
whatsNewSeenPlatformUtc = platformSeen,
|
||||
whatsNewSeenAgentUtc = agentSeen
|
||||
whatsNewSeenAgentUtc = agentSeen,
|
||||
dpaAcceptancePending = true
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -72,7 +73,8 @@ public class InfrastructureWireParityTests
|
||||
UnrestrictedModeEnabled = false,
|
||||
McpClientRegistrationEnabled = true,
|
||||
WhatsNewSeenPlatformUtc = platformSeen,
|
||||
WhatsNewSeenAgentUtc = agentSeen
|
||||
WhatsNewSeenAgentUtc = agentSeen,
|
||||
DpaAcceptancePending = true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,7 +111,8 @@ public class InfrastructureWireParityTests
|
||||
unrestrictedModeEnabled = false,
|
||||
mcpClientRegistrationEnabled = false,
|
||||
whatsNewSeenPlatformUtc,
|
||||
whatsNewSeenAgentUtc
|
||||
whatsNewSeenAgentUtc,
|
||||
dpaAcceptancePending = false
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
@@ -130,7 +133,8 @@ public class InfrastructureWireParityTests
|
||||
UnrestrictedModeEnabled = false,
|
||||
McpClientRegistrationEnabled = false,
|
||||
WhatsNewSeenPlatformUtc = null,
|
||||
WhatsNewSeenAgentUtc = null
|
||||
WhatsNewSeenAgentUtc = null,
|
||||
DpaAcceptancePending = false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Data.Tables;
|
||||
using Xunit;
|
||||
@@ -26,14 +26,14 @@ public class IngestStatusPrefetchTests
|
||||
[InlineData("succeeded", SessionStatus.Succeeded)] // mapper parses case-insensitively — mirror it
|
||||
public void StringStatus_TheActualWireFormat_IsParsed(string stored, SessionStatus expected)
|
||||
{
|
||||
Assert.Equal(expected, IngestTelemetryFunction.TryReadSessionStatus(Row(stored)));
|
||||
Assert.Equal(expected, SessionRowProjections.TryReadStatus(Row(stored)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IntStatus_LegacyDefensiveFallback_IsCast()
|
||||
{
|
||||
var defined = (int)SessionStatus.Stalled;
|
||||
Assert.Equal(SessionStatus.Stalled, IngestTelemetryFunction.TryReadSessionStatus(Row(defined)));
|
||||
Assert.Equal(SessionStatus.Stalled, SessionRowProjections.TryReadStatus(Row(defined)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -41,13 +41,13 @@ public class IngestStatusPrefetchTests
|
||||
[InlineData("")]
|
||||
public void UnparseableStatus_ReturnsNull_SoCallerFallsBackToOwnRead(string stored)
|
||||
{
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(Row(stored)));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(Row(stored)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingStatusOrRow_ReturnsNull()
|
||||
{
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(Row(status: null)));
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(null));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(Row(status: null)));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
@@ -37,7 +39,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <c>OrdinalIgnoreCase</c> equality Run performs (Run L169-180).</item>
|
||||
/// <item>session deletion locked → 410: the real <see cref="SessionDeletionGuard"/> throwing
|
||||
/// <see cref="SessionDeletionLockedException"/> (Run L205-213), plus the unique
|
||||
/// <see cref="IngestTelemetryFunction.TryReadSessionStatus"/> that consumes the guard's row.</item>
|
||||
/// <see cref="SessionRowProjections.TryReadStatus"/> that consumes the guard's row.</item>
|
||||
/// <item>kill-switch / device-blocked → 200-block: NOT duplicated here. The verdict Run() routes
|
||||
/// on (<c>IsBlocked</c>/<c>IsKill</c>/<c>UnblockAt</c>) is produced by
|
||||
/// <see cref="Functions.Services.KillSwitchEvaluator"/> and is already exhaustively covered at the
|
||||
@@ -138,7 +140,7 @@ public class IngestTelemetryAuthPathTests
|
||||
public async Task DeletionUnlocked_guard_returns_row_that_feeds_the_status_prefetch()
|
||||
{
|
||||
// Run L205-217: on an unlocked session the guard hands back the loaded row, and Run reads
|
||||
// Status off it via TryReadSessionStatus to seed the stall-heal prefetch (no second read).
|
||||
// Status off it via SessionRowProjections.TryReadStatus to seed the stall-heal prefetch (no second read).
|
||||
var guard = NewGuard(out var reader);
|
||||
var row = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
@@ -151,10 +153,31 @@ public class IngestTelemetryAuthPathTests
|
||||
var returned = await guard.EnsureWritableAndGetRowAsync(TenantId, SessionId, callerContext: "V2.IngestTelemetry");
|
||||
|
||||
Assert.Same(row, returned);
|
||||
Assert.Equal(SessionStatus.Succeeded, IngestTelemetryFunction.TryReadSessionStatus(returned));
|
||||
Assert.Equal(SessionStatus.Succeeded, SessionRowProjections.TryReadStatus(returned));
|
||||
}
|
||||
|
||||
// ============================================================ TryReadSessionStatus (unique helper)
|
||||
// ============================================================ Session unknown → 410
|
||||
|
||||
[Fact]
|
||||
public async Task SessionUnknown_guard_returns_null_that_Run_maps_to_410_session_unknown()
|
||||
{
|
||||
// Run: a null guard row (no Sessions row, no active tombstone) is refused with 410 via
|
||||
// WriteSessionUnknownAsync and stamped IngestRefusal=session_unknown; the cascade lock keeps
|
||||
// its own value so the two 410 reasons stay apart on the request row.
|
||||
var guard = NewGuard(out var reader);
|
||||
reader.Setup(r => r.GetSessionRowAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
reader.Setup(r => r.GetActiveSessionTombstoneAsync(TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((TableEntity?)null);
|
||||
|
||||
var row = await guard.EnsureWritableAndGetRowAsync(TenantId, SessionId, callerContext: "V2.IngestTelemetry");
|
||||
|
||||
Assert.Null(row);
|
||||
Assert.NotEqual(RequestRowMarkers.IngestRefusal.SessionUnknown, RequestRowMarkers.IngestRefusal.CascadeLocked);
|
||||
Assert.Equal("IngestRefusal", RequestRowMarkers.IngestRefusalKey);
|
||||
}
|
||||
|
||||
// ============================================================ TryReadStatus (unique helper)
|
||||
|
||||
[Theory]
|
||||
[InlineData("InProgress", SessionStatus.InProgress)]
|
||||
@@ -164,27 +187,27 @@ public class IngestTelemetryAuthPathTests
|
||||
public void TryReadSessionStatus_parses_string_status_case_insensitively(string stored, SessionStatus expected)
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId) { ["Status"] = stored };
|
||||
Assert.Equal(expected, IngestTelemetryFunction.TryReadSessionStatus(row));
|
||||
Assert.Equal(expected, SessionRowProjections.TryReadStatus(row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadSessionStatus_returns_null_for_null_row()
|
||||
{
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(null));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadSessionStatus_returns_null_when_status_column_absent()
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId); // no Status column
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(row));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(row));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadSessionStatus_returns_null_for_unparseable_status()
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId) { ["Status"] = "NotARealStatus" };
|
||||
Assert.Null(IngestTelemetryFunction.TryReadSessionStatus(row));
|
||||
Assert.Null(SessionRowProjections.TryReadStatus(row));
|
||||
}
|
||||
|
||||
// ============================================================ Harness (mirrors SessionDeletionGuardTests)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Maintenance;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class MaintenanceStepTimerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Records_every_step_in_order_and_returns_the_body_result()
|
||||
{
|
||||
var timer = new MaintenanceStepTimer();
|
||||
|
||||
await timer.RunAsync("first", () => Task.CompletedTask);
|
||||
var value = await timer.RunAsync<int>("second", () => Task.FromResult(42));
|
||||
|
||||
Assert.Equal(42, value);
|
||||
Assert.Equal(new[] { "first", "second" }, timer.Steps.Keys);
|
||||
Assert.All(timer.Steps.Values, ms => Assert.True(ms >= 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_step_is_still_recorded_and_the_exception_propagates()
|
||||
{
|
||||
var timer = new MaintenanceStepTimer();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => timer.RunAsync("boom", () => throw new InvalidOperationException("x")));
|
||||
|
||||
Assert.True(timer.Steps.ContainsKey("boom"));
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public class NotificationChannelsTests
|
||||
ChannelJson("ok", providerType: 10),
|
||||
"{\"name\":\"no-id\",\"providerType\":20,\"url\":\"https://x.example\"}",
|
||||
ChannelJson("none-provider", providerType: 0),
|
||||
ChannelJson("retired-teams-connector", providerType: 1),
|
||||
ChannelJson("unknown-provider", providerType: 99)) + "]";
|
||||
|
||||
var parsed = NotificationChannel.ParseList(json);
|
||||
@@ -113,23 +114,6 @@ public class NotificationChannelsTests
|
||||
Assert.Equal("k", ch.GetCustomHeaders()["X-Api-Key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNotificationChannels_LegacyTeamsOnly_SynthesizesTeamsLegacyConnector()
|
||||
{
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
TeamsWebhookUrl = "https://teams.example/hook",
|
||||
TeamsNotifyOnSuccess = true,
|
||||
TeamsNotifyOnFailure = false,
|
||||
};
|
||||
|
||||
var ch = Assert.Single(cfg.GetNotificationChannels());
|
||||
Assert.Equal((int)WebhookProviderType.TeamsLegacyConnector, ch.ProviderType);
|
||||
Assert.Equal("https://teams.example/hook", ch.Url);
|
||||
Assert.True(ch.NotifyOnSuccess);
|
||||
Assert.False(ch.NotifyOnFailure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNotificationChannels_ChannelsJsonWinsOverLegacyFields()
|
||||
{
|
||||
@@ -233,6 +217,7 @@ public class NotificationChannelsTests
|
||||
[InlineData("not-json", "not valid JSON")]
|
||||
[InlineData("[{\"name\":\"no-id\",\"providerType\":20}]", "needs an id")]
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":99}]", "invalid provider type")]
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":1}]", "invalid provider type")] // retired Teams Office 365 Connector
|
||||
[InlineData("[{\"id\":\"a\",\"providerType\":20,\"url\":\"http://plain.example\"}]", "")] // non-https rejected by SsrfGuard format check
|
||||
public void ValidateNotificationChannels_RejectsInvalidEntries(string json, string expectedFragment)
|
||||
{
|
||||
@@ -251,6 +236,26 @@ public class NotificationChannelsTests
|
||||
Assert.Contains("duplicate", error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, false)] // retired Teams Office 365 Connector
|
||||
[InlineData(99, false)]
|
||||
[InlineData(0, true)]
|
||||
[InlineData(2, true)]
|
||||
public void ValidateModel_LegacyWebhookProviderType_MustBeSupportedOrNone(int providerType, bool valid)
|
||||
{
|
||||
var existing = TenantConfiguration.CreateDefault("tenant-1");
|
||||
var candidate = TenantConfiguration.CreateDefault("tenant-1");
|
||||
candidate.WebhookProviderType = providerType;
|
||||
candidate.WebhookUrl = "https://hooks.example/x";
|
||||
|
||||
var error = TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false);
|
||||
|
||||
if (valid)
|
||||
Assert.Null(error);
|
||||
else
|
||||
Assert.Equal("Invalid webhook provider type.", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsBadPerChannelHeaders()
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user