mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52ae6541a7 | |||
| d763a885e2 | |||
| eedca11214 | |||
| 73a6817713 | |||
| 0a0c323f73 | |||
| bac26def52 | |||
| 4c70ea8a0a | |||
| eaef1601d0 | |||
| 51737174b2 | |||
| c04280cf68 | |||
| d055eed074 | |||
| 2b6565b3cb | |||
| 5f3ae2d06b | |||
| 83e15e49de | |||
| 3c8ef02191 | |||
| 251e82a45a | |||
| 01311898a8 | |||
| a5bca8a730 | |||
| 87cb1f1cea |
+1
-1
Submodule .claude/commands updated: 3e4d919c98...ad441d6577
@@ -324,13 +324,19 @@ jobs:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
# First-party `gh` instead of a third-party action: this job holds contents:write,
|
||||
# id-token:write and attestations:write, so no code we do not control runs here.
|
||||
# --generate-notes appends the auto-generated notes below the body.
|
||||
- name: Create GitHub Release
|
||||
if: inputs.create_release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
|
||||
body: |
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ steps.version.outputs.tag }}
|
||||
RELEASE_NAME: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
|
||||
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
|
||||
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
RELEASE_BODY: |
|
||||
## Autopilot Monitor Agent ${{ steps.version.outputs.version }}
|
||||
|
||||
### Installation
|
||||
@@ -355,9 +361,22 @@ jobs:
|
||||
```
|
||||
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
|
||||
```
|
||||
draft: false
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
files: |
|
||||
${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
${{ env.MANIFEST_NAME }}
|
||||
generate_release_notes: true
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
|
||||
Set-Content -Path $notesFile -Value $env:RELEASE_BODY -Encoding utf8 -NoNewline
|
||||
|
||||
$ghArgs = @(
|
||||
'release', 'create', $env:RELEASE_TAG,
|
||||
$env:RELEASE_ZIP, $env:MANIFEST_NAME,
|
||||
'--repo', $env:GITHUB_REPOSITORY,
|
||||
'--target', $env:GITHUB_SHA,
|
||||
'--title', $env:RELEASE_NAME,
|
||||
'--notes-file', $notesFile,
|
||||
'--generate-notes'
|
||||
)
|
||||
if ($env:RELEASE_PRERELEASE -eq 'true') { $ghArgs += '--prerelease' }
|
||||
|
||||
& gh @ghArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "gh release create failed with exit code $LASTEXITCODE" }
|
||||
Write-Host "Release $env:RELEASE_TAG created"
|
||||
|
||||
@@ -143,3 +143,29 @@ jobs:
|
||||
git diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shared-manifests-in-sync:
|
||||
name: Generated wire types match shared-manifests.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# JSON ↔ C# parity is the dotnet job's SharedManifestParityTests; this job pins
|
||||
# JSON ↔ generated TS (shared-manifests.generated.ts, wire-types.generated.ts and the
|
||||
# MCP's wire-types + wire-vocabularies copies - the vocabularies are what the MCP
|
||||
# tool input schemas derive their z.enum() from).
|
||||
- name: Regenerate
|
||||
run: node scripts/generate-shared-manifest-types.js
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
|
||||
- name: Fail if the generated files drifted
|
||||
run: |
|
||||
if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then
|
||||
echo "::error::Generated wire types are out of date. Run 'npm run generate:manifests' in src/Web/autopilot-monitor-web and commit the result."
|
||||
git diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.37.3
|
||||
uses: github/codeql-action/init@v4.37.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4.37.3
|
||||
uses: github/codeql-action/analyze@v4.37.9
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -63,11 +63,23 @@ jobs:
|
||||
- name: Combine rules into dist/
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
# Plain git instead of a third-party action: this step runs with contents:write on
|
||||
# main, so no code we do not control gets to push. Checkout leaves the job's
|
||||
# GITHUB_TOKEN in the remote credential helper, which is all `git push` needs.
|
||||
# NOTE: combine.js writes THREE outputs; committing only dist/ would silently discard
|
||||
# the two generated TS modules and leave the guardrails-in-sync CI check red.
|
||||
- name: Commit generated outputs
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "chore: regenerate rules/dist + generated TS from individual files"
|
||||
# combine.js writes THREE outputs; committing only dist/ would silently discard
|
||||
# the two generated TS modules and leave the guardrails-in-sync CI check red.
|
||||
file_pattern: "rules/dist/* src/Web/autopilot-monitor-web/utils/guardrails.generated.ts src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -- rules/dist/ \
|
||||
src/Web/autopilot-monitor-web/utils/guardrails.generated.ts \
|
||||
src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts
|
||||
if git diff --cached --quiet; then
|
||||
echo "Generated outputs unchanged - nothing to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: regenerate rules/dist + generated TS from individual files"
|
||||
git push origin HEAD:main
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -65,7 +65,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@v1
|
||||
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1
|
||||
with:
|
||||
app-name: ${{ env.FUNCTION_APP }}
|
||||
package: ./publish
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
name: Deploy Web (Static Web App)
|
||||
|
||||
# Build here, upload with the pinned SWA CLI. No third-party action sees the deploy token
|
||||
# (the CLI is Microsoft-published npm code at an exact version), the build runs on the same
|
||||
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
|
||||
# action's container, and the exported site is attested like the agent ZIP and the MSI.
|
||||
#
|
||||
# PR preview environments were dropped with the action: their per-PR URLs are never
|
||||
# registered as redirect URIs, so nobody could log in on them anyway.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -8,39 +16,44 @@ on:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
env:
|
||||
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
WEB_DIR: src/Web/autopilot-monitor-web
|
||||
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
|
||||
# only thing that keeps the deploy step's code fixed. Bump deliberately.
|
||||
SWA_CLI_VERSION: 2.0.10
|
||||
|
||||
jobs:
|
||||
build_and_deploy_job:
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
# Dependabot PRs run without repo secrets, so the deploy token is absent.
|
||||
# Surface it as an env var so the deploy step can gate on it — secrets
|
||||
# cannot be referenced directly in an `if:` expression.
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
- name: Build And Deploy
|
||||
id: builddeploy
|
||||
# Skip (green) instead of failing when the token is missing (Dependabot
|
||||
# PRs); real deploys on main and internal PRs still have it.
|
||||
if: env.DEPLOY_TOKEN != ''
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: ${{ env.WEB_DIR }}/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
run: npm ci
|
||||
|
||||
# next.config output:'export' -- `next build` writes the static site to out/
|
||||
# (prebuild: sync-error-codes, generate-lastmod, generate-version).
|
||||
- name: Build
|
||||
working-directory: ${{ env.WEB_DIR }}
|
||||
env:
|
||||
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
|
||||
# Dual app-reg window: previous app registration (unset = single-app behaviour)
|
||||
@@ -55,28 +68,50 @@ jobs:
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
# the verify step below polls the deployed file for exactly this commit.
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
|
||||
action: "upload"
|
||||
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
|
||||
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
|
||||
app_location: "src/Web/autopilot-monitor-web" # App source code path
|
||||
api_location: "" # Api source code path - optional
|
||||
# Static export (next.config output:'export'): Oryx runs `npm run build`
|
||||
# and uploads the exported out/ directory as plain static files — no
|
||||
# managed SSR runtime is provisioned anymore. (No is_static_export
|
||||
# input on this action version; Oryx detects the export from
|
||||
# next.config + output_location.)
|
||||
output_location: "out" # Built app content directory - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
run: |
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
|
||||
# The SWA action reporting success proves the upload, not that the production host
|
||||
# serves the new build — verify against the live /version.json (Cache-Control:
|
||||
# no-store via staticwebapp.config.json) before publishing the version manifest.
|
||||
# PR builds deploy to per-PR preview environments, so only main deploys are verified.
|
||||
# One archive of the exact bytes that get uploaded, so the attestation has a single subject.
|
||||
- name: Package export
|
||||
id: package
|
||||
run: |
|
||||
archive="portal-${GITHUB_SHA::7}.tar.gz"
|
||||
tar -C "$WEB_DIR/out" -czf "$archive" .
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
sha256sum "$archive"
|
||||
|
||||
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
|
||||
# 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@v4
|
||||
with:
|
||||
subject-path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
# Upload only -- out/ is already built. --swa-config-location points at the app root,
|
||||
# where staticwebapp.config.json lives (next export does not copy it into out/).
|
||||
- name: Deploy to Static Web App
|
||||
env:
|
||||
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
npx --yes --package "@azure/static-web-apps-cli@${SWA_CLI_VERSION}" swa deploy \
|
||||
--app-location "$WEB_DIR" \
|
||||
--output-location out \
|
||||
--swa-config-location "$WEB_DIR" \
|
||||
--env production \
|
||||
--no-use-keychain
|
||||
|
||||
# The CLI reporting success proves the upload, not that the production host serves the
|
||||
# new build -- verify against the live /version.json (Cache-Control: no-store via
|
||||
# staticwebapp.config.json) before publishing the version manifest.
|
||||
- name: Verify deployed portal build is live
|
||||
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
|
||||
@@ -100,10 +135,9 @@ jobs:
|
||||
|
||||
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
# Cross-component deploy record, written last and only on a verified deploy —
|
||||
# Cross-component deploy record, written last and only on a verified deploy --
|
||||
# same contract as versions/backend.json and versions/mcp.json.
|
||||
- name: Publish version manifest
|
||||
if: env.DEPLOY_TOKEN != '' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$manifest = [ordered]@{
|
||||
@@ -119,22 +153,3 @@ jobs:
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' } -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/web.json"
|
||||
Write-Host $manifest
|
||||
|
||||
close_pull_request_job:
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
name: Close Pull Request Job
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
steps:
|
||||
- name: Close Pull Request
|
||||
id: closepullrequest
|
||||
if: env.DEPLOY_TOKEN != ''
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
action: "close"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Federated login + read check
|
||||
steps:
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -4,7 +4,7 @@ title: CMTrace Time Resolution — Per-Line Self-Anchoring
|
||||
description: How the agent converts bias-less CMTrace local times to UTC without trusting any process's timezone belief — each provably fresh line anchors its own offset against the agent clock on the 15-minute grid; everything not provably fresh falls back uniformly and says so.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime
|
||||
tags: [agent, ime, cmtrace, timestamps, timezone, calibration, provenance]
|
||||
timestamp: 2026-08-30
|
||||
timestamp: 2026-09-01
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -139,6 +139,22 @@ regression); `measuredWriterOffsetMinutes` is never consulted (sticky after era
|
||||
flip-backs). Kill switch: app setting `CmTraceSkewTripwireDisabled`. Goal state: the event
|
||||
never fires — any hit is an anchoring case the per-line design misses, or a detector bug.
|
||||
|
||||
Samples are windowed to the session's most recent INGEST ERA before any of that is
|
||||
computed: the batch stamps of both sides are walked backwards from the newest one and the
|
||||
era ends at the first `ReceivedAt` gap wider than 2 h. A session partition is not one agent
|
||||
run — a pre-provisioning session's Part 1 is written by whatever agent build was current
|
||||
weeks earlier, and its events stay in the partition forever. Field 2026-09-01 (sessions
|
||||
`e797117b` / `c06d639d` / `d7c8032b`, one tenant): 26 IME samples at exactly −60 min from a
|
||||
2026-08-20 technician leg under agent 2.0.1409 — before per-line anchoring — outnumbered the
|
||||
3…9 clean samples of the user leg that completed that morning and fired the tripwire against
|
||||
2.0.1445, a build the device had self-updated to minutes earlier. The skew was real, 12 days
|
||||
old, and already fixed; no scan-wide statistic can tell those apart, because event rows carry
|
||||
no agent version. The era boundary can. `eraStartUtc` and the excluded per-side sample counts
|
||||
travel in the ops event, so an inherited leg stays visible without being alarmed on. The
|
||||
2 h threshold sits above any in-leg upload gap (a live agent uploads self-metrics every few
|
||||
minutes, reports `session_stalled` at 60 min idle, and the maintenance sweep classifies
|
||||
silence at 2 h) and below every pre-provisioning handover.
|
||||
|
||||
Device-clock problems (the customer-actionable cousin) are covered separately by the
|
||||
`clock_skew` analyze condition behind `ANALYZE-DEV-008`, which excludes IME-derived events
|
||||
precisely so an anchoring regression can never surface as a customer finding.
|
||||
|
||||
@@ -8,7 +8,7 @@ tags:
|
||||
- decision-engine
|
||||
- reducer
|
||||
- completion
|
||||
timestamp: 2026-07-11T00:00:00+02:00
|
||||
timestamp: 2026-08-30T00:00:00+02:00
|
||||
---
|
||||
|
||||
# V2 Agent — Decision Engine (DecisionCore)
|
||||
@@ -175,6 +175,20 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
|
||||
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
|
||||
The idle case (detected, no deployment activity ever) times out at 60 min
|
||||
unchanged, and phase 200/210 activity never extends.
|
||||
**Self-update observation** (gktatooine session 946ccbd6): the version on
|
||||
`RealmJoinDetected` is read once from `RealmJoin.exe`, but the tray's `AppUpdater`
|
||||
often replaces the MSI-shipped binary on its first start — minutes after detection,
|
||||
before the deployment begins. `RealmJoinWatcher` therefore arms a
|
||||
`RegNotifyChangeKeyValue` watcher on `HKLM\...\Uninstall\RealmJoin` (the only
|
||||
registry write of the updater; appearance-watch on the `Uninstall` parent when the
|
||||
entry does not exist yet) once Detected fired, re-reads the binary version after a
|
||||
2 s debounce (registry `DisplayVersion` as fallback while the file is locked) and
|
||||
posts the typed `RealmJoinAutoUpdateDetected` signal plus the
|
||||
`realmjoin_autoupdate_detected` timeline event when the version differs from the
|
||||
baseline. The reducer overrides `RealmJoinFacts.ProductVersion` / `ReleaseChannel`
|
||||
(`WithVersionOverride` — the one legitimate later write past the set-once helpers);
|
||||
it never touches the gate, the deadline, or `LastActivityUtc`. An unreadable
|
||||
baseline at detection is seeded silently by the first successful re-read.
|
||||
A closed gate also outranks the 30-minute `AdvisoryCompletion` backstop: that
|
||||
handler re-arms (trigger suffix `:CompletionGateHolding`) instead of failing while
|
||||
the RealmJoin gate is closed *and* its own deadline is still armed, because the gate
|
||||
@@ -208,6 +222,15 @@ IME false-positive AccountSetup entries.
|
||||
- **No silent parking**: a non-terminal post-AccountSetup session must always hold a
|
||||
resolution-capable deadline; a 10-min dwell tripwire emits
|
||||
`session_parked_without_deadline`.
|
||||
- **Every payload-derived string stored into state is bounded** (`FactStringBounds`,
|
||||
256 chars): RealmJoin package id/version/scope (scope normalised to `machine`/`user`),
|
||||
failed app ids, `HelloOutcome`, `ImeMatchedPatternId`, `EspAdvisoryFailureCategory`,
|
||||
RealmJoin product version/channel. The bound is applied *before* any dedupe comparison
|
||||
so a forged multi-hundred-KB key neither costs a full-length compare per stored row nor
|
||||
lands in snapshots/audit trails. It never truncates real telemetry — the sources are
|
||||
registry key names (Windows caps them at 255), GUIDs and enum-like strings. The same
|
||||
engine replays device-uploaded signals server-side (`ReducerVerifier`), which is why
|
||||
the bound lives in the reducer, not in the agent adapter alone.
|
||||
|
||||
# Hosting & threading (inside the agent)
|
||||
|
||||
@@ -228,6 +251,21 @@ Wired by `EnrollmentOrchestrator.Start` (`src/Agent/AutopilotMonitor.Agent.V2.Co
|
||||
one-shot terminal-stage hook (raises the agent's `Terminated` event off-thread).
|
||||
- The `DeadlineScheduler`'s `Fired` event loops back in as synthetic `DeadlineFired`
|
||||
signals.
|
||||
- **Pipeline health in `agent_metrics_snapshot`** (2026-09-01, motivated by the WG-seal
|
||||
soak in which a frozen engine and a deliberate Weak sealing verdict were telemetrically
|
||||
indistinguishable): every snapshot carries monotonic totals from `SignalIngress`
|
||||
(`signal_last_ordinal`, `signal_processed_total`, `signal_pending_count`,
|
||||
`signal_reentrant_total`, `signal_queue_peak` — a monotonic peak, deliberately no
|
||||
momentary queue length, so snapshot sampling cannot alias it) plus `decision_stage`,
|
||||
`decision_last_applied_ordinal` and — once the sealing classifier has produced a
|
||||
verdict — `wg_sealing_level`/`wg_sealing_score` from the immutable `DecisionState`.
|
||||
Two snapshots therefore prove whether the worker ran in between: a frozen worker keeps
|
||||
`signal_pending_count` growing while the processed/applied counters stand still. The
|
||||
probe chain is read-only (`EnrollmentOrchestrator` → `IComponentFactory.
|
||||
CreateCollectorHosts(decisionStateProbe)` → `PeriodicCollectorLifecycleHost` →
|
||||
`AgentSelfMetricsCollector`); the snapshot event itself travels the collector/event
|
||||
path, which is independent of the signal pipeline it measures — field-proven by the
|
||||
freeze cases, whose perf/metrics events kept flowing while the engine stood still.
|
||||
- On restart, `RecoveryCoordinator` folds the persisted signal log through a transient
|
||||
`new DecisionEngine()` before the live pipeline starts — see
|
||||
[logs & persistence](logs-and-persistence.md).
|
||||
|
||||
@@ -39,7 +39,9 @@ Every table in `Constants.TableNames` must belong to at least one deletion lifec
|
||||
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
|
||||
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
|
||||
|
||||
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
|
||||
The lookup row is **claimed at registration, first-writer-wins** (`ClaimSessionTenantLookupAsync`, create-only `AddEntity` BEFORE any Sessions/SessionsIndex write): on 409 the existing owner is read back — same tenant ⇒ normal re-registration, different tenant ⇒ `SessionTenantConflictException` ⇒ `409 Conflict` with `ErrorCode=session_owner_mismatch` (owning tenant not disclosed) plus a `SessionTenantConflict` ops event, and nothing is written. The claim is deliberately NOT fail-soft. Before this, the row was an unconditional last-writer-wins upsert after the session write, so any tenant's device (self-service onboarding) that knew a victim session GUID (portal deep links, Teams/Slack notifications, progress-portal URLs) could re-point the mapping at its own tenant and have every no-`tenantId` Global Admin/Reader read — and GA writes such as the `globaladmin` annotation lane — silently served from a forged session. Session ids are 122-bit random, so a genuine cross-tenant collision does not occur; a conflict is an attack or an agent bug and fails loudly. The legacy self-heal write is Add-only too (an existing claim is the authority), and `scripts/Migration/Backfill-SessionTenantLookup.ps1` closes the pre-table window once (idempotent, Add-only) so no legacy session id is claimable. The row is deleted by the cascade one step before the tombstone (no tombstone is kept — a freed id gives a foreign tenant nothing a fresh GUID would not) and is rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
|
||||
|
||||
`requireGlobalAdmin: true` additionally corroborates the resolved tenant against its Sessions row (one point-read): a mapping whose session does not exist in the resolved tenant keeps `TargetTenantId`, so a stale or poisoned row can never steer a GA write — silently, without any operator confirmation step (a "confirm tenant GUID" dialog was rejected as unusable). Read paths keep the single point-read; their own session read is the corroboration (404).
|
||||
|
||||
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Ops-Events Query Surface
|
||||
description: The server-side filter surface of GET /api/global/ops-events (category, eventType, severity/minSeverity, tenant drill, date window), why every filter is folded into the continuation fingerprint, why minSeverity expands to an OR-set, and why the paged cross-category read has to fan out over the complete category vocabulary.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- table-storage
|
||||
- ops-events
|
||||
- pagination
|
||||
- mcp
|
||||
timestamp: 2026-09-01T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
`OpsEvents` is the platform operator's log: consent flows, maintenance runs, security blocks,
|
||||
tenant offboards, agent emergency breaks, SLA and Azure Monitor alerts. It is partitioned by
|
||||
**category** (`PartitionKey`), with a reverse-tick `RowKey` for newest-first ordering.
|
||||
|
||||
Until 2026-09-01 the read endpoint could only narrow by category and date window. Every other
|
||||
question — *"did any `AgentEmergencyBreak` fire yesterday?"*, *"show me everything at Error or
|
||||
worse"* — had to be answered by pulling the whole category and filtering it on the client. For the
|
||||
MCP tool that client is a language model, so an event-type question cost a category-sized response
|
||||
in model context.
|
||||
|
||||
# Schema
|
||||
|
||||
`GET /api/global/ops-events` (GA + Global Reader; `TenantScoping.None`, so a delegated admin cannot
|
||||
reach it at all):
|
||||
|
||||
| Param | Where it is applied | Notes |
|
||||
| --- | --- | --- |
|
||||
| `category` | `PartitionKey eq` — indexed | One partition; omit to fan out over all of them |
|
||||
| `eventType` | `EventType eq` — server-side scan | **Exact and case-sensitive**; Table Storage cannot fold case |
|
||||
| `severity` | `Severity eq` — server-side scan | Normalised onto the canonical vocabulary before use |
|
||||
| `minSeverity` | OR-set over `Severity` — server-side scan | Threshold: that level and everything above |
|
||||
| `dateFrom` / `dateTo` | RowKey range — indexed | Both absent ⇒ backend resolves the last 30 days |
|
||||
| `tenantId` | **in-memory**, post-fetch | Drill, not an isolation boundary — see below |
|
||||
| `pageSize` / `continuation` | — | `pageSize` opts into pagination; absent ⇒ full window, no cap |
|
||||
|
||||
The filter surface lives in two shared pieces so no path can honour a different one:
|
||||
`OpsEventFilterRequest` (query → `OpsEventQueryFilters` + pagination extras) and
|
||||
`TableOpsEventRepository.AppendFieldFilters` (filters → OData clauses), which **both** filter
|
||||
builders call — the single-category/unpaged builder and the per-partition fan-out builder that adds
|
||||
the `RowKey gt` bound. This mirrors `AuditLogFilterRequest` / `AuditLogQueryFilters` on the audit
|
||||
endpoints one-for-one.
|
||||
|
||||
## Why `minSeverity` is an OR-set, not a range
|
||||
|
||||
Table Storage compares strings lexicographically, and the severity names sort
|
||||
`Critical < Error < Info < Warning` — nothing to do with severity order. A `Severity ge 'Warning'`
|
||||
clause would return `Warning` only, silently dropping `Error` and `Critical`, which is precisely the
|
||||
alert-worthy half. `minSeverity` therefore expands to
|
||||
`(Severity eq 'Warning' or Severity eq 'Error' or Severity eq 'Critical')`. The ladder itself lives
|
||||
in `OpsEventSeverity.Rank` and is shared with `OpsAlertDispatchService`, so *"Warning and above"* in
|
||||
a read means exactly what *"Warning and above"* meant when the alert rules decided to fire.
|
||||
|
||||
`minSeverity=Info` emits no clause at all — it is the floor, so the clause would only cost query
|
||||
length.
|
||||
|
||||
## Why severity is normalised but eventType is not
|
||||
|
||||
Both columns are matched with `eq`, which is case-sensitive. Severity has a closed vocabulary of
|
||||
four values, so `?severity=warning` is normalised to `Warning`, and anything outside the vocabulary
|
||||
is a **400** — an unnormalised value would return an empty page that reads exactly like *"no such
|
||||
events happened"*.
|
||||
|
||||
Event types have a declared vocabulary too (`OpsEventTypes`, reflected into the shared manifest and
|
||||
served to the model as `get_resource(name="ops_event_types")`), but the filter deliberately does
|
||||
**not** validate against it: stored rows outlive the vocabulary, so a type that was retired after a
|
||||
refactor must stay searchable in the historical window. The value is passed verbatim and a typo
|
||||
legitimately returns zero rows — the catalog is there so nobody has to guess.
|
||||
|
||||
## Continuation fingerprint
|
||||
|
||||
Every filter value is appended to the pagination *extras*, which are both folded into the
|
||||
continuation-token fingerprint and echoed on `nextLink`. Consequences:
|
||||
|
||||
* a token minted for one filter cannot page a different one (it is rejected with a 400),
|
||||
* the follow-up request is self-contained — the resolved date window and the filters travel on the
|
||||
link, so no page re-resolves "now",
|
||||
* extras are appended **after** the pre-existing `category` / `tenantId` discriminators and skipped
|
||||
when empty, so tokens minted before the field filters existed still fingerprint identically.
|
||||
|
||||
Because severity values are normalised *before* fingerprinting, `?severity=error` and
|
||||
`?severity=Error` share one token instead of minting two that each reject the other.
|
||||
|
||||
## The tenant drill stays in memory
|
||||
|
||||
`?tenantId=` is filtered post-fetch (`GetOpsEventsFunction.FilterByTenant`) because OpsEvents is
|
||||
partitioned by category, and — deliberately — because the in-memory comparison is
|
||||
`OrdinalIgnoreCase` while an OData `eq` would be case-sensitive: stored tenant casing is not
|
||||
guaranteed, so pushing this one down would lose rows. It is drill *correctness*, not isolation:
|
||||
every caller that reaches this route already has full cross-tenant scope. Pages therefore can report
|
||||
fewer items than `pageSize` when the drill is narrow.
|
||||
|
||||
## The category vocabulary must be complete
|
||||
|
||||
The paged cross-category read does not issue one cross-partition query (Azure would page it by
|
||||
`PartitionKey asc`, so the first page would come entirely from the alphabetically first category).
|
||||
It fans out per partition over a fixed list and merge-sorts. That list is therefore load-bearing: a
|
||||
category that is written but not listed is **invisible to every paged reader** while the unpaged
|
||||
full-table path still returns it. `Platform` (Azure Monitor alerts relayed through the ops alert
|
||||
webhook) was missing that way from its introduction until 2026-09-01.
|
||||
|
||||
The list is now owned by `OpsEventCategory.All`, and `OpsEventCategoryCoverageTests` reflects over
|
||||
the constants to fail the build when a new category is not added to it.
|
||||
|
||||
# Examples
|
||||
|
||||
```
|
||||
# One event type, last 24h, cross-category
|
||||
GET /api/global/ops-events?eventType=AgentEmergencyBreak&dateFrom=2026-08-31T12:00:00Z&pageSize=200
|
||||
|
||||
# Everything alert-worthy in the maintenance partition this month
|
||||
GET /api/global/ops-events?category=Maintenance&minSeverity=Error&dateFrom=2026-08-01T00:00:00Z
|
||||
```
|
||||
|
||||
MCP `get_ops_events` exposes the same surface plus a `days` shorthand, which it resolves to a
|
||||
concrete `dateFrom` **client-side** — a backend-side "last N days" would re-resolve "now" on every
|
||||
page and blow the token fingerprint. An explicit `dateFrom`/`dateTo` always wins over the shorthand.
|
||||
|
||||
# Citations
|
||||
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Functions/Admin/OpsEventFilterRequest.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs`
|
||||
* `/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs`
|
||||
* `/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs` — the declared event-type vocabulary
|
||||
* `/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventFieldFilterTests.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions.Tests/OpsEventCategoryCoverageTests.cs`
|
||||
* `/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts`
|
||||
* [Business Timestamps](business-timestamps.md) — why the date window filters on the RowKey
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Ops Notification Channels
|
||||
description: How platform alerts are routed to named channels instead of three fixed provider slots — the shared NotificationChannel model on both the tenant and platform side, one NotificationChannelDispatcher that routes each channel to its transport, the legacy-slot synthesis that keeps pre-migration dispatch behaviour identical, the empty-means-all rule binding, why Telegram is Global-Admin-only by construction, and why an ops event's structured payload only reaches a destination when a rule opts in.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs
|
||||
tags:
|
||||
- backend
|
||||
- notifications
|
||||
- ops-events
|
||||
- configuration
|
||||
- security
|
||||
timestamp: 2026-09-01T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
|
||||
Platform (ops) alerting had no routing. An `OpsAlertRule` was
|
||||
`{ EventType, MinSeverity, Enabled }` with no destination, and `OpsAlertDispatchService`
|
||||
broadcast every matching rule to all three hard-wired slots in `AdminConfiguration`
|
||||
(`OpsAlertTelegramChatId`, `OpsAlertTeamsWebhookUrl`, `OpsAlertSlackWebhookUrl`). Three
|
||||
consequences followed:
|
||||
|
||||
1. **No per-event destinations.** Binding a conversion signal to a sales channel meant every
|
||||
other rule reached that channel too.
|
||||
2. **Only the first matching rule was evaluated** (`FirstOrDefault`), so two rules on the same
|
||||
event type could not coexist.
|
||||
3. **The payload never left the backend.** `OpsEventService.WriteAsync` serialized a `details`
|
||||
object into the `OpsEvents` table but passed only `(category, eventType, severity, message,
|
||||
tenantId)` to the dispatch — a receiving system saw a tenant GUID and a sentence.
|
||||
|
||||
The tenant side already had the right model: `NotificationChannel` (a named list in
|
||||
`TenantConfiguration.NotificationChannelsJson`) with analyze rules binding channels by id
|
||||
(`RuleState.NotifyChannelIdsJson`). The platform side needed that model, not a second one.
|
||||
|
||||
# Schema
|
||||
|
||||
## One model, two scopes
|
||||
|
||||
`NotificationChannel` is unchanged and now backs both lists:
|
||||
|
||||
| Scope | Storage | Bound by |
|
||||
| --- | --- | --- |
|
||||
| Tenant | `TenantConfiguration.NotificationChannelsJson` | per-channel `NotifyOn*` toggles + analyze rules (`NotifyChannelIdsJson`) |
|
||||
| Platform | `AdminConfiguration.OpsNotificationChannelsJson` | `OpsAlertRule.NotifyChannelIds` |
|
||||
|
||||
The scopes stay separate on purpose — platform events resolve against platform channels, and a
|
||||
tenant's channel list can never receive an ops alert. What is shared is the *model* and the
|
||||
*dispatch path*, so a new provider is added once.
|
||||
|
||||
## One dispatch path
|
||||
|
||||
`NotificationChannelDispatcher` is the channel-level send API. It routes by provider:
|
||||
`WebhookProviderType.Telegram` goes to `TelegramNotificationService`; everything else is a
|
||||
rendered webhook POST through `WebhookNotificationService`. Every caller that sends to
|
||||
configured channels — enrollment (`EventIngestProcessor`), session start
|
||||
(`RegisterSessionFunction`), hardware rejection (`ReportDistressFunction`), SLA
|
||||
(`SlaBreachEvaluationService`), analyze rules (`AnalyzeOnEnrollmentEndHandler`), ops alerts
|
||||
(`OpsAlertDispatchService`) and both test endpoints — goes through it.
|
||||
|
||||
## Telegram is Global-Admin-only by construction
|
||||
|
||||
Telegram (`WebhookProviderType.Telegram = 40`) is the odd provider: it is not a webhook. The
|
||||
channel's `Url` carries the destination **chat ID**, and the bot token is platform-owned
|
||||
(PreviewConfig `WebhookUrl`). A tenant admin configuring one would be sending through *our*
|
||||
bot, which is why the gate is a server-side rule, not a hidden dropdown option:
|
||||
|
||||
* `TenantConfigValidation.ValidateTelegramChannelGate` refuses a non-GA caller who **adds** a
|
||||
Telegram channel or changes an existing one's destination or enabled state. It lives in
|
||||
`ValidateModel`, so it covers both write paths — the full-model PUT *and* the MCP field-patch
|
||||
flow (`TenantConfigPatchService`). A check placed in `UpdateTenantConfigurationFunction` alone
|
||||
would not.
|
||||
* An **unchanged** GA-created channel passes, so a tenant admin can still save unrelated config.
|
||||
* Telegram destinations skip `SsrfGuard.ValidateWebhookUrlFormat` (a chat ID is not a URL and
|
||||
would always fail it) and are format-checked as a numeric id or `@username` instead.
|
||||
|
||||
## Migration without a data job
|
||||
|
||||
`AdminConfiguration.GetOpsNotificationChannels()` returns the stored list, or — when it is empty —
|
||||
synthesizes one channel per *configured* legacy slot with stable ids (`legacy-telegram`,
|
||||
`legacy-teams`, `legacy-slack`) and the legacy enabled flags. `GetAdminConfigurationFunction`
|
||||
projects the same synthesis into the read model, so the editor shows exactly what receives
|
||||
alerts today and the first save materializes it. The legacy fields stay as the migration source
|
||||
and are no longer written by the Alerts section.
|
||||
|
||||
## Rule binding: empty means all
|
||||
|
||||
`OpsAlertRule.NotifyChannelIds` null or empty = every enabled channel. That is what every rule
|
||||
written before routing existed carries, so behaviour on the deploy day is identical. Dispatch
|
||||
resolves **all** matching rules (`Where`, not `FirstOrDefault`) and unions their channels;
|
||||
ids that no longer resolve are dropped rather than falling back to broadcast — a rule whose only
|
||||
channel was deleted must reach nothing, not everything. The web editor mirrors the same rule in
|
||||
`app/admin/components/opsChannelRouting.ts`.
|
||||
|
||||
## Payload delivery is opt-in per rule
|
||||
|
||||
`WriteAsync` hands the serialized `details` to `DispatchAsync`, but a channel only receives it when
|
||||
a rule sets `OpsAlertRule.IncludePayload`. **Default false**: an ops alert is a "something happened,
|
||||
go look" signal, most payloads are operational noise in a chat, and some carry data the baseline
|
||||
never does (tenant domain, administrator contact address). Widening what leaves the platform stays
|
||||
a deliberate per-rule decision.
|
||||
|
||||
`ResolveTargets` therefore returns two groups. Channels reached only by plain rules get an alert
|
||||
built with `detailsJson: null`; channels reached by an opted-in rule get the enriched one. A channel
|
||||
in both groups appears in the payload group only — one message per channel, and an unrelated
|
||||
sibling rule cannot cancel an explicit opt-in.
|
||||
|
||||
For the enriched alert, `BuildAlert`:
|
||||
|
||||
* flattens top-level **scalars** into `NotificationFact`s — capped at 12 facts and 256 characters,
|
||||
nulls and nested values skipped — so card formats and the plain-text Telegram message carry the
|
||||
same information,
|
||||
* passes the raw JSON through `NotificationAlert.DataJson`, which only `GenericJsonRenderer`
|
||||
emits, as a real JSON object under `data`. Additive within `schemaVersion` 1.0.
|
||||
|
||||
`NotificationAlert.EventType` is set either way: it is the same value the `Event` fact already
|
||||
carries, so it adds no information — only a stable key for a generic consumer to branch on.
|
||||
|
||||
# Examples
|
||||
|
||||
A trial-conversion signal reaching only a sales webhook:
|
||||
|
||||
1. Admin → Alerts → add a channel `Sales` (Generic JSON, the internal endpoint).
|
||||
2. Enable the `TenantTrialStarted` rule at `Info`, select **only** `Sales`, and switch its
|
||||
**Payload** toggle on (off by default, so every other rule keeps the baseline alert).
|
||||
3. `PlanManagementFunction` fires `TenantTrialStarted` from both plan write paths — the
|
||||
self-service `POST config/{tenantId}/trial` and a GA grant via `PATCH config/{tenantId}/plan`
|
||||
(distinguished by `selfService`). Ending a trial is a downgrade and stays with
|
||||
`TenantPlanDowngraded`.
|
||||
4. The webhook receives `eventType`, the rendered facts, and `data` with `domainName`,
|
||||
`contactEmail`, `trialStartedUtc`, `trialExpiresUtc`, `grantedBy`, `selfService`. The operator
|
||||
push channel receives nothing, because it is not bound to that rule.
|
||||
|
||||
# Gotchas
|
||||
|
||||
* **A new ops event type must be dual-registered** in `OPS_EVENT_TYPES`
|
||||
(`app/admin/components/OpsAlertRulesSection.tsx`) or it cannot be selected. `OpsEventTypeDualRegisterTests`
|
||||
enforces it.
|
||||
* **`OpsNotificationChannelsJson` is secret-bearing** and is on the deny-list in
|
||||
`AdminConfiguration.RedactedCopyForReader()`. `NotificationChannel.RedactList` /
|
||||
`RestoreRedactedList` are shared with the tenant list — do not add a second copy.
|
||||
* **Details are capped at 4096 characters** in the `OpsEvents` table (`TableOpsEventRepository`)
|
||||
before they ever reach a channel.
|
||||
|
||||
# Citations
|
||||
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Services/OpsAlertDispatchService.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationChannelDispatcher.cs`
|
||||
* `/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationChannel.cs`
|
||||
* `/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs`
|
||||
* `/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs`
|
||||
+97
-11
@@ -1,14 +1,14 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Drift Guards — SignalR Names, RowKeys, Config Sections, Scoped Routing
|
||||
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, the OkAsync ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs
|
||||
title: Drift Guards — SignalR Names, RowKeys, Wire DTOs, Tool Vocabularies, Config Sections, Scoped Routing
|
||||
description: The 2026-08 fragility-audit follow-up contracts that turn formerly hand-synchronized mirrors into enforced ones — SignalR message-name catalog, the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs with generated TypeScript interfaces, the MCP's generated tool vocabularies with their inline-enum ratchet, per-section config PATCH, and the web's scoped-endpoint builders.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs
|
||||
tags:
|
||||
- contracts
|
||||
- guard-tests
|
||||
- signalr
|
||||
- web
|
||||
timestamp: 2026-08-13T00:00:00+02:00
|
||||
timestamp: 2026-08-31T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Drift Guards
|
||||
@@ -49,13 +49,94 @@ restore + dry-run); the historical `Contains('_')` RowKey-shape heuristic surviv
|
||||
solely as the fallback for manifests written before the field existed and must outlive
|
||||
one full manifest-retention cycle.
|
||||
|
||||
## Anonymous OkAsync responses — frozen, shrink-only
|
||||
## Wire DTOs — generated interfaces, zero anonymous success bodies
|
||||
|
||||
`OkAsyncBaselineGuardTests` freezes the 44 existing `req.OkAsync(new { … })` call
|
||||
sites as a per-file baseline. A new anonymous response (new file or count above
|
||||
baseline) fails; converting a site to a typed DTO (Shared response models, picked up
|
||||
by the manifest parity tests) fails too until its baseline entry is lowered — the debt
|
||||
list stays truthful and only ever shrinks.
|
||||
Every HTTP success body is a flat class in `AutopilotMonitor.Shared.Models`
|
||||
implementing the `IApiResponse` marker (2026-08-31, `feat/typed-api-contract` —
|
||||
migrated from the 44 `OkAsync(new { … })` + 134 raw `WriteAsJsonAsync(new { … })`
|
||||
sites the 2026-08-13 audit froze). Three layers enforce it:
|
||||
|
||||
- **Compile time**: the only `OkAsync/CreatedAsync/JsonAsync` overloads constrain on
|
||||
`IApiResponse` — an anonymous object cannot satisfy the constraint. Error bodies
|
||||
(first key `error`/`message`, or literal `success = false`) stay anonymous by design.
|
||||
- **`TypedResponseGuardTests`** (successor of `OkAsyncBaselineGuardTests`): both
|
||||
per-file baselines are EMPTY — any anonymous success body, through the helpers or
|
||||
raw `WriteAsJsonAsync`, is a straight failure. A reflection fact keeps every
|
||||
`IApiResponse` implementer flat (System.Text.Json serializes derived properties
|
||||
before base ones — key order is wire contract; MCP hands raw JSON to an LLM) and
|
||||
inside the Shared assembly.
|
||||
- **`*WireParityTests`**: each migrated site carries an ordinal old-anonymous-literal
|
||||
vs. new-DTO serialization proof (production `ApiJsonOptions`), including a null case
|
||||
per key that WhenWritingNull omits.
|
||||
- **Object-slot ratchet** (2026-08-31 typisierung follow-up): a reflection walk over
|
||||
the reachable wire-type graph fails on any `object` / collection-of-`object`
|
||||
property outside `ObjectSlotBaseline` (sole entry: `RuleDryRunCondition.Evidence`,
|
||||
heterogeneous by design). `[ProjectedItems]` slots and dictionary VALUES are exempt.
|
||||
The former deliberately-untyped slots (RuleDryRun trace, HealthCheck,
|
||||
GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports,
|
||||
MetricsSummary items, auth/me, auth/mcp) are concrete Shared types now. The raw
|
||||
table entities left the wire entirely in the same pass: global-admins,
|
||||
tenant-admins and preview-whitelist serve flat rows (`GlobalAdminRow` /
|
||||
`TenantAdminRow` / `PreviewWhitelistTenantEntry`) WITHOUT
|
||||
partitionKey/rowKey/eTag/timestamp — a deliberate wire change pinned by exact-JSON
|
||||
shape tests instead of parity facts.
|
||||
|
||||
The DTO rules: declaration order == wire order; a property is nullable exactly when a
|
||||
site can emit null (the key then vanishes — never add non-null defaults that would
|
||||
invent a key); preformatted strings (`.ToString("o")`, enum `.ToString()`) stay
|
||||
`string`; `fields=` projections stay `IReadOnlyList<object>` tagged
|
||||
`[ProjectedItems(typeof(Item))]`; raw table rows stay verbatim-keyed dictionaries
|
||||
(no `DictionaryKeyPolicy` — dictionary KEYS never run through the camelCase policy,
|
||||
and dictionary VALUES keep explicit nulls, both pinned by tests).
|
||||
|
||||
`SharedManifestParityTests` (schemaVersion 2) reflects the full graph — every
|
||||
`IApiResponse` implementer plus `[WireContract]`-marked payload types, transitively
|
||||
closed by `WireTypeManifestBuilder` — into the `types` section of
|
||||
`shared-manifests.json`, carrying C# `<summary>` texts. The web codegen
|
||||
(`npm run generate:manifests`) emits `utils/wire-types.generated.ts` (one interface
|
||||
per object, one string union per enum, JSDoc from the summaries); the hand-written
|
||||
`types/session.ts` / `types/enrollment.ts` / `types/adminConfig.ts` re-export from
|
||||
it. Freshness is pinned by the vitest suite and the `shared-manifests-in-sync` CI
|
||||
job. Unmappable shapes (non-string dictionary keys, foreign BCL classes) fail the
|
||||
manifest build instead of degrading to `unknown`.
|
||||
|
||||
## Tool vocabularies — the MCP derives them, it no longer retypes them
|
||||
|
||||
Wire TYPES reached the MCP from day one (`src/generated/wire-types.generated.ts`, the
|
||||
same codegen output the web gets). Vocabularies did not: types are erased at runtime and
|
||||
a `z.enum([...])` needs values, so every MCP tool schema hand-typed its own copy of
|
||||
statuses, severities and categories — with nothing to compare against. The copies drifted
|
||||
exactly as you would expect: `search_sessions` offered 5 of 8 session statuses (no
|
||||
`AwaitingUser`, no `Incomplete`), both event readers offered 4 of 6 severities (no `Debug`,
|
||||
no `Trace`), and `get_ops_events` named 6 ops categories while the backend wrote 7. None of
|
||||
that surfaces as an error — the tool just quietly cannot ask for the missing value.
|
||||
|
||||
The vocabularies now ship as VALUES. `MCP_VOCABULARIES` in the codegen maps manifest
|
||||
sections onto `src/generated/wire-vocabularies.generated.ts` (`export const
|
||||
SESSION_STATUSES = [...] as const` plus its union type); the tool schemas do
|
||||
`z.enum(SESSION_STATUSES)` and descriptions interpolate the list instead of spelling it.
|
||||
Freshness is pinned twice: `wire-types-freshness.test.ts` compares the committed file
|
||||
byte-for-byte against a fresh codegen run, and the `shared-manifests-in-sync` CI job
|
||||
diffs it after regenerating.
|
||||
|
||||
`vocabulary-drift.guard.test.ts` is the ratchet that keeps it that way: every inline
|
||||
`z.enum([...])` left in `src/tools/*.ts` must be on a baseline that names why it has no
|
||||
backend owner (MCP-local knobs like `depth: fast|deep`, plus three free-string columns —
|
||||
rule type, connection type, CVE risk — that have no C# constants class yet). A new
|
||||
hand-typed list fails until it is derived or reviewed onto the baseline, and a stale
|
||||
baseline entry fails too.
|
||||
|
||||
Ops event types got the missing owner: they were bare literals at the `OpsEventService`
|
||||
call sites, so nothing could enumerate them and both the portal alert-rule catalog and the
|
||||
MCP had to retype the list. `OpsEventTypes` (Shared) now declares all 77; the write sites
|
||||
use the constants (a raw literal fails `OpsEventTypeDualRegisterTests`), the manifest
|
||||
exports them, and `get_resource(name="ops_event_types")` serves categories + severities +
|
||||
types to the model from the generated copy.
|
||||
|
||||
Backend-internal lists follow the same rule: a list that enumerates a constants class IS
|
||||
the class. `TableOpsEventRepository.AllCategories` is `OpsEventCategory.All` rather than a
|
||||
retyped copy — the copy had gone stale and silently hid every `Platform` (Azure Monitor)
|
||||
event from the paged cross-category read while the unpaged path still showed it.
|
||||
|
||||
## Tenant settings — per-section PATCH
|
||||
|
||||
@@ -98,9 +179,14 @@ list in `sectionFieldMap.test.ts`.
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog
|
||||
- `src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js` — `MCP_VOCABULARIES`, the vocabulary codegen
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/__tests__/vocabulary-drift.guard.test.ts` — the inline-enum ratchet
|
||||
- `src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs` + `OpsEventTypeDualRegisterTests.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Helpers/RowKeyCodec.cs` + `RowKeyCodecTests.cs`
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/Deletion/DeletionManifest.cs` — `DeletionRowDump.Table`, `DeletionTombstoneTables`
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/OkAsyncBaselineGuardTests.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/TypedResponseGuardTests.cs` + `WireTypeManifestBuilder.cs` + the `*WireParityTests` files
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/CommonApiModels.cs` — `IApiResponse`, `[WireContract]`, `[ProjectedItems]`
|
||||
- `src/Web/autopilot-monitor-web/utils/wire-types.generated.ts` (via `scripts/generate-shared-manifest-types.js`)
|
||||
- `src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts` + `__tests__/sectionFieldMap.test.ts`
|
||||
- `src/Web/autopilot-monitor-web/lib/scopedApi.ts`, `lib/navVisibility.ts`, `hooks/concreteAdminScopeView.ts`
|
||||
- [Version Contract](versioning.md) — the web `/version.json` stamp + deploy verify added in the same round
|
||||
|
||||
+3
-1
@@ -15,7 +15,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
|
||||
* [Customer Script Publishing](script-publishing.md) - How the customer-facing PowerShell scripts reach download.autopilotmonitor.com/agent/, why publishing is decoupled from agent releases, and the guards keeping the published copy, the version badge and the portal's version oracle in agreement.
|
||||
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
|
||||
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
|
||||
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, typed wire DTOs (IApiResponse + generated wire-types.generated.ts, empty anonymous-body baselines), the MCP's generated tool vocabularies (wire-vocabularies.generated.ts + the inline-z.enum ratchet), per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
|
||||
|
||||
# Agent
|
||||
|
||||
@@ -54,6 +54,8 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Telemetry Ingest Shaping](backend/telemetry-ingest-shaping.md) - One canonical App Insights request record (worker copy with tenant context, per-item sampling bypass; host HTTP duplicate dropped by a workspace transformation DCR), expected storage outcomes 404/412/409 and successful SignalR REST calls filtered in the worker, and the count()/sum(itemCount) query rules that follow.
|
||||
* [Table Schema Sentinel](backend/table-schema-sentinel.md) - Startup no longer issues one CreateTableIfNotExists per table: a SHA-256 over TableNames.All in AdminConfiguration gates the full pass (derived, never hand-maintained), the SessionsIndex backfill runs only after a full pass behind a conditional-insert claim, and daily maintenance repairs out-of-band deletions.
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
* [Ops-Events Query Surface](backend/ops-events-query-surface.md) - The server-side filter surface of the ops-events read (category, eventType, severity/minSeverity, in-memory tenant drill, RowKey date window), why every filter is folded into the continuation fingerprint, why minSeverity expands to an OR-set instead of a lexicographic range, and why the paged cross-category fan-out must cover the complete category vocabulary (Platform was missing).
|
||||
* [Ops Notification Channels](backend/ops-notification-channels.md) - Platform alerts route to NAMED channels instead of three fixed provider slots: the tenant-side NotificationChannel model now backs both scopes, one NotificationChannelDispatcher routes each channel to its transport (Telegram is not a webhook - chat id, platform-owned bot, Global-Admin-only enforced in ValidateModel so the MCP patch path is covered too), legacy slots are synthesized with stable ids so pre-migration dispatch is unchanged, a rule's empty channel binding means all, and an ops event's structured details reach the destination as facts plus a `data` object only when a rule opts in (IncludePayload, off by default, so the baseline alert is unchanged).
|
||||
* [Send-Time Frame Separation](backend/send-time-frame-separation.md) - One device-clock send timestamp per upload batch (X-Send-Time-Utc → SentAt on Events) splits ReceivedAt − OccurredUtc into pure spool delay and pure device-vs-server clock offset; the clock_skew condition measures SentAt batches directly (spool-immune), the CMTrace tripwire deliberately stays on its differential math.
|
||||
* [OOBE Bootstrap go-Code Flow](backend/oobe-bootstrap-go-flow.md) - The bootstrap short-code lifecycle (portal-created session → `irm go.autopilotmonitor.com/CODE | iex` at Shift+F10 → GetBootstrapScriptFunction), the always-HTTP-200 text/plain contract, the two-line injection defense (GUID/URL/expiry validator + single-quoted PS literals, golden-pinned against the legacy TS template), and the per-IP rate-limit story behind Front Door.
|
||||
* [Silent-Session Classification](backend/silent-session-classification.md) - How a session whose agent went silent gets its honest status: the two-stage maintenance sweep (2h Stalled marker with the WhiteGlove Part-2 AwaitingUser gate and the self-deploying Succeeded gate, 5h timeout classification), the EnrollmentTimeoutClassifier rule order (explicit failure → WG Part 2 → self-deploying → completion evidence → emergency break → desktop+Hello → Device Setup/grace), the ingest paths sharing it (max-lifetime, late-telemetry heal, stall probe) and the admin retro-reconcile modes incl. self_deploying_silent.
|
||||
|
||||
+44
@@ -1,5 +1,49 @@
|
||||
# Log
|
||||
|
||||
## 2026-09-01 (3)
|
||||
|
||||
* **Update**: `agent/decision-engine.md` - agent_metrics_snapshot gains a signal-pipeline health block: monotonic SignalIngress totals (last ordinal, processed, pending, reentrant, queue peak) + decision_stage / decision_last_applied_ordinal / wg_sealing_level+score from the immutable DecisionState, wired via a read-only decisionStateProbe through IComponentFactory. Motivated by the WG-seal soak (project_wg_seal_postfix_soak_residual): a frozen engine and a deliberate Weak sealing verdict were previously indistinguishable in telemetry.
|
||||
|
||||
## 2026-09-01 (4)
|
||||
|
||||
* **Update**: `drift-guards.md` — MCP tool vocabularies are generated, not retyped. The codegen now emits `wire-vocabularies.generated.ts` (VALUES: session statuses, event severities, ops categories/severities/types, annotation lanes/verdicts) alongside the MCP's wire types, byte-pinned by the MCP freshness suite and the `shared-manifests-in-sync` CI job; the tool schemas derive their `z.enum()` from it. Found by the switch: `search_sessions` had offered 5 of 8 session statuses (no `AwaitingUser`/`Incomplete`) and both event readers 4 of 6 severities (no `Debug`/`Trace`). `vocabulary-drift.guard.test.ts` ratchets the remaining inline enums against a reasoned baseline. New `OpsEventTypes` (Shared) gives the 77 ops event types a canonical home — `OpsEventService` write sites use the constants (a raw literal now fails the dual-register test), and `get_resource(name="ops_event_types")` serves the vocabulary to the model.
|
||||
|
||||
## 2026-09-01 (3)
|
||||
|
||||
* **New**: `backend/ops-notification-channels.md` - platform alerting gains named channels. `AdminConfiguration.OpsNotificationChannelsJson` reuses the tenant `NotificationChannel` model (redaction/restore helpers hoisted onto the model so both scopes share one implementation); `OpsAlertRule.NotifyChannelIds` binds rules to channels with empty = all, and dispatch now evaluates ALL matching rules (`Where`, not `FirstOrDefault`) and unions their targets, dropping ids that no longer resolve instead of falling back to broadcast. New `NotificationChannelDispatcher` is the single channel-level send API for every caller (enrollment, session start, hardware rejection, SLA, analyze rules, ops alerts, both test endpoints). New provider `WebhookProviderType.Telegram = 40` carries a chat id rather than a URL and sends through the platform-owned bot - Global-Admin-only, enforced in `TenantConfigValidation.ValidateModel` so the MCP field-patch path is gated too, not just the PUT. Legacy provider slots are synthesized into channels with stable ids (`legacy-telegram` named "Autopilot Monitor IT Engineer") and projected into the GET read model, so day-one behaviour is unchanged and the first save materializes the list. New ops event `TenantTrialStarted` fires from both plan write paths, and `OpsEventService.WriteAsync` hands the serialized details to the dispatch - but delivery is opt-in per rule (`OpsAlertRule.IncludePayload`, default false), so every existing alert keeps its category/event/severity/tenant baseline and the trust-page description of operator alerting stays accurate. `ResolveTargets` splits targets into a payload group and a plain group (a channel in both gets the payload once); for the enriched alert `BuildAlert` flattens top-level scalars into facts (12 / 256 chars) and passes the raw object to `GenericJsonRenderer` as `data` (additive, schemaVersion stays 1.0). `eventType` is set either way - same value the `Event` fact already carried.
|
||||
|
||||
## 2026-09-01 (2)
|
||||
|
||||
* **New**: `backend/ops-events-query-surface.md` - the ops-events read gains server-side `eventType` / `severity` / `minSeverity` filters (audit-log filter pattern: one parse helper, one clause appender used by both filter builders, values folded into the continuation fingerprint and echoed on nextLink). `minSeverity` expands to an OR-set because the severity names sort `Critical < Error < Info < Warning` lexicographically; the ladder is now shared with `OpsAlertDispatchService`. Fixed alongside: the paged cross-category fan-out skipped `Platform` (Azure Monitor alerts) because its category list had drifted from `OpsEventCategory` - the list is now `OpsEventCategory.All` with a reflection coverage test. MCP `get_ops_events` exposes the filters plus a client-resolved `days` shorthand.
|
||||
|
||||
## 2026-09-01 (1)
|
||||
|
||||
* **Update**: `agent/cmtrace-time-resolution.md` — the backend skew tripwire now judges only the session's most recent ingest era (batch stamps walked back from the newest, split at the first `ReceivedAt` gap > 2 h). Pre-provisioning partitions hold a Part 1 leg written weeks earlier by an older agent build: on 2026-09-01 three sessions of one tenant fired on 26 IME samples at −60 min left by agent 2.0.1409 on 2026-08-20 (pre per-line anchoring), against a device already self-updated to 2.0.1445. `SessionSkewScan` carries `ReceivedAt` per sample (distinct-batch counts derive from the era-filtered set; the cap keeps the newest samples), and the ops event reports `eraStartUtc` plus the excluded per-side counts.
|
||||
|
||||
## 2026-08-31 (2)
|
||||
|
||||
* **Update**: `drift-guards.md` — typisierung follow-up: the deliberately-untyped remainder (RuleDryRun trace, HealthCheck, GlobalNotificationDto, TenantConfigFieldSchema, maintenance run reports, MetricsSummary items, auth/me, auth/mcp) is typed end-to-end; a new object-slot reflection ratchet in `TypedResponseGuardTests` closes the class (baseline: `RuleDryRunCondition.Evidence` only). Deliberate wire change: global-admins / tenant-admins / preview-whitelist rows are flat read models (`GlobalAdminRow` / `TenantAdminRow` / `PreviewWhitelistTenantEntry`) without partitionKey/rowKey/eTag/timestamp, pinned by exact-JSON shape tests.
|
||||
|
||||
## 2026-08-31 (1)
|
||||
|
||||
* **Update**: `drift-guards.md` — the shrink-only OkAsync baseline became the typed API contract (`feat/typed-api-contract`): all 175 anonymous success bodies (44 helper + 134 raw minus overlaps already typed) are flat `IApiResponse` DTOs in Shared, both `TypedResponseGuardTests` baselines are empty regression guards, the untyped `OkAsync/CreatedAsync(object)` overloads are gone, and every migrated site carries an ordinal wire-parity proof. `SharedManifestParityTests` schemaVersion 2 exports the full type graph (208 types incl. `[WireContract]` extras) with C# `<summary>` JSDoc into `utils/wire-types.generated.ts`; web hand-types re-export from it; freshness pinned by vitest + the new `shared-manifests-in-sync` CI job.
|
||||
|
||||
## 2026-08-30 (15)
|
||||
|
||||
* **Update**: `backend/lifecycle-manifests-and-session-scope.md` — SessionTenantLookup is now claimed first-writer-wins at registration (create-only Add BEFORE any session write; foreign owner ⇒ 409 `session_owner_mismatch` + `SessionTenantConflict` ops event, nothing written; not fail-soft). Closes the poisoning path where any tenant's device could re-point a victim sessionId's mapping at its own tenant and hijack every no-tenantId Global Admin/Reader read and GA write (annotation lane). `ResolveSessionScopeAsync(requireGlobalAdmin: true)` corroborates the resolved tenant against its Sessions row (silent — no operator confirmation). Self-heal write is Add-only; `scripts/Migration/Backfill-SessionTenantLookup.ps1` closes the legacy window once. No tombstone on deletion (deliberate: unbounded growth for no gain).
|
||||
|
||||
## 2026-08-30 (14)
|
||||
|
||||
* **Update**: `agent/decision-engine.md` — new invariant: every payload-derived string stored into `DecisionState` is bounded to 256 chars by `FactStringBounds` before any dedupe comparison (RealmJoin package id/version/scope, failed app ids, `HelloOutcome`, `ImeMatchedPatternId`, `EspAdvisoryFailureCategory`, RJ product version/channel). Backend `ReducerVerifier` now replays decode-fold-discard instead of materialising all decoded signals, and `ISignalRepository.QueryBySessionAsync` carries a cumulative `PayloadJson` budget (`SignalQueryLimits`, 32 M chars) reflected in the `truncated` flag of `sessions/{id}/signals` and `sessions/{id}/reducer-verification`.
|
||||
|
||||
## 2026-08-30 (13)
|
||||
|
||||
* **Update**: `agent/decision-engine.md` — RealmJoin self-update observation. `realmjoin_detected` reported the MSI-shipped build (4.21.4) although the tray auto-updated to 4.21.18 nine minutes later and ran the whole deployment on it (gktatooine session 946ccbd6). `RealmJoinWatcher` now watches `HKLM\...\Uninstall\RealmJoin` (the updater's only registry write) after detection, re-reads the binary version on change and emits `realmjoin_autoupdate_detected` (`previousVersion`, `newVersion`, `releaseChannel`); the typed `RealmJoinAutoUpdateDetected` signal overrides `RealmJoinFacts.ProductVersion`/`ReleaseChannel` and nothing else. Portal device card shows the updated version with an "auto-updated from" note.
|
||||
|
||||
## 2026-08-30 (12)
|
||||
|
||||
* **Update**: `web/portal-navigation-prefetch.md` — hosting citation now points at `deploy-web.yml`: the portal is built in CI (setup-node 22, `npm ci`, `next build`) and uploaded with the pinned SWA CLI; the Azure/static-web-apps-deploy action, its Oryx in-container build and the per-PR preview environments are gone. All non-GitHub-owned actions are SHA-pinned; the release and rules auto-commit steps use first-party `gh`/`git`.
|
||||
|
||||
## 2026-08-30 (11)
|
||||
|
||||
* **New**: `agent/ime-process-watcher.md` — `ImeProcessWatcher` no longer attaches to the first `IntuneManagementExtension` name match: `ImeProcessIdentity` requires session 0 + image under the IME install root (`QueryFullProcessImageName`, cross-bitness), prefers the oldest trusted candidate, logs untrusted matches once per PID, and re-arms discovery after every reported exit (previously one-shot — a fake in a user session could forge `ime_process_exited` and mute the real signal for the session; even an IME service restart was never watched again). Registered in `index.md`.
|
||||
|
||||
@@ -123,4 +123,4 @@ Application Insights resource — the prefetch is cheap only while the runtime i
|
||||
|
||||
* `src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx` — nav link rendering, rationale comment
|
||||
* `src/Web/autopilot-monitor-web/components/Navbar.tsx` — top-bar and notification links
|
||||
* [SWA and Oryx node pinning](../architecture.md) — hosting model for the web app
|
||||
* `.github/workflows/deploy-web.yml` — hosting model: static export built in CI (Node 22 via setup-node, `engines` in package.json), uploaded with the pinned SWA CLI
|
||||
|
||||
+125
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
@@ -158,6 +159,130 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.False(health.HasSkippedWork);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Starved_timeout_is_retried_once_and_the_recovered_match_is_not_skipped_work()
|
||||
{
|
||||
// Session b9f1d134: a CPU-starved Hyper-V guest tripped the 1 s wall-clock regex
|
||||
// timeout on a line the pattern genuinely matches in 5.8 µs. A timeout whose attempt
|
||||
// consumed almost no match cost is starvation — retried once, recovered, no warning.
|
||||
using var h = new Harness();
|
||||
var degraded = 0;
|
||||
h.Tracker.OnTrackerDegraded = (_, __, ___) => degraded++;
|
||||
h.Tracker.MatchCostReader = () => 0; // attempt cost 0 < gate
|
||||
var attempts = 0;
|
||||
h.Tracker.MatchInvoker = (id, regex, input) =>
|
||||
{
|
||||
if (id == "T-MARK" && ++attempts == 1) throw new RegexMatchTimeoutException();
|
||||
return regex.Match(input);
|
||||
};
|
||||
|
||||
h.Append(Entry("marker 1") + "\n");
|
||||
await h.Pass();
|
||||
|
||||
var health = h.Tracker.GetHealthSnapshot();
|
||||
Assert.Equal(2, attempts);
|
||||
Assert.Equal(1, health.PatternHits["T-MARK"]); // recovered
|
||||
Assert.Equal(0, health.RegexTimeouts);
|
||||
Assert.Equal(1, health.RegexTimeoutRetries);
|
||||
Assert.False(health.HasSkippedWork);
|
||||
Assert.Equal(0, degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_that_burned_real_cpu_is_skipped_without_a_retry()
|
||||
{
|
||||
// The gate exists so a genuinely spinning pattern never gets a second helping of
|
||||
// CPU: an attempt at or over the gate is a real timeout, counted and skipped. The
|
||||
// wall-clock fallback lands here by construction (a timed-out attempt always
|
||||
// measures >= the full timeout > gate).
|
||||
using var h = new Harness();
|
||||
var degraded = 0;
|
||||
h.Tracker.OnTrackerDegraded = (_, __, ___) => degraded++;
|
||||
h.Tracker.LineMatchBudget = long.MaxValue; // isolate the gate from the budget
|
||||
long cost = 0;
|
||||
h.Tracker.MatchCostReader = () => cost += h.Tracker.TimeoutRetryGate; // every delta >= gate
|
||||
var attempts = 0;
|
||||
h.Tracker.MatchInvoker = (id, regex, input) =>
|
||||
{
|
||||
if (id == "T-MARK") { attempts++; throw new RegexMatchTimeoutException(); }
|
||||
return regex.Match(input);
|
||||
};
|
||||
|
||||
h.Append(Entry("marker 1 loose 1") + "\n");
|
||||
await h.Pass();
|
||||
|
||||
var health = h.Tracker.GetHealthSnapshot();
|
||||
Assert.Equal(1, attempts); // no retry
|
||||
Assert.Equal(0, health.PatternHits["T-MARK"]);
|
||||
Assert.Equal(1, health.PatternHits["T-LOOSE"]); // later patterns still ran
|
||||
Assert.Equal(1, health.RegexTimeouts);
|
||||
Assert.Equal(0, health.RegexTimeoutRetries);
|
||||
Assert.True(health.HasSkippedWork);
|
||||
Assert.Equal(1, degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retry_that_times_out_again_is_counted_once_and_not_retried_further()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.MatchCostReader = () => 0; // every attempt looks starved
|
||||
var attempts = 0;
|
||||
h.Tracker.MatchInvoker = (id, regex, input) =>
|
||||
{
|
||||
if (id == "T-MARK") { attempts++; throw new RegexMatchTimeoutException(); }
|
||||
return regex.Match(input);
|
||||
};
|
||||
|
||||
h.Append(Entry("marker 1") + "\n");
|
||||
await h.Pass();
|
||||
|
||||
var health = h.Tracker.GetHealthSnapshot();
|
||||
Assert.Equal(2, attempts); // exactly one retry
|
||||
Assert.Equal(1, health.RegexTimeouts);
|
||||
Assert.Equal(1, health.RegexTimeoutRetries);
|
||||
Assert.True(health.HasSkippedWork);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_break_skips_the_remaining_patterns_and_names_the_first_skipped()
|
||||
{
|
||||
using var h = new Harness();
|
||||
string? firstSkipped = null;
|
||||
h.Tracker.OnTrackerDegraded = (_, __, pattern) => firstSkipped = pattern;
|
||||
h.Tracker.LineMatchBudget = 500;
|
||||
long cost = 0;
|
||||
h.Tracker.MatchCostReader = () => cost += 1000; // every Match charges 1000 units
|
||||
|
||||
h.Append(Entry("marker 1 loose 1") + "\n");
|
||||
await h.Pass();
|
||||
|
||||
var health = h.Tracker.GetHealthSnapshot();
|
||||
Assert.Equal("T-NEVER", firstSkipped); // pattern order: T-MARK, T-NEVER, T-LOOSE
|
||||
Assert.Equal(1, health.PatternHits["T-MARK"]); // matched before the budget was spent
|
||||
Assert.Equal(0, health.PatternHits["T-LOOSE"]); // skipped by the break
|
||||
Assert.Equal(1, health.BudgetBreaks);
|
||||
Assert.True(health.HasSkippedWork);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retry_counter_survives_a_restart()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.MatchCostReader = () => 0;
|
||||
var attempts = 0;
|
||||
h.Tracker.MatchInvoker = (id, regex, input) =>
|
||||
{
|
||||
if (id == "T-MARK" && ++attempts == 1) throw new RegexMatchTimeoutException();
|
||||
return regex.Match(input);
|
||||
};
|
||||
h.Append(Entry("marker 1") + "\n");
|
||||
await h.Pass();
|
||||
h.Tracker.SaveStateForTest();
|
||||
|
||||
h.Restart();
|
||||
Assert.Equal(1, h.Tracker.GetHealthSnapshot().RegexTimeoutRetries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ime_agent_version_is_captured_from_the_pattern_action()
|
||||
{
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
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.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the signal-pipeline block of <c>agent_metrics_snapshot</c> (2026-09-01 WG-seal
|
||||
/// soak: sessions whose DecisionEngine never sealed were telemetrically
|
||||
/// indistinguishable from a deliberate Weak classifier verdict, because the snapshots
|
||||
/// carried no pipeline counters). The fields are monotonic totals plus a monotonic
|
||||
/// queue peak — deliberately no momentary queue length — so two snapshots prove
|
||||
/// whether the ingress worker ran in between.
|
||||
/// </summary>
|
||||
public sealed class AgentSelfMetricsCollectorSignalPipelineTests
|
||||
{
|
||||
private static AgentLogger NewLogger(string path)
|
||||
=> new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
|
||||
|
||||
private static (AgentSelfMetricsCollector Collector, FakeSignalIngressSink Sink) Build(
|
||||
string tmpPath, Func<SignalPipelineHealth?>? probe)
|
||||
{
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var post = new InformationalEventPost(sink, SystemClock.Instance);
|
||||
var collector = new AgentSelfMetricsCollector(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: post,
|
||||
networkMetrics: new NetworkMetrics(),
|
||||
logger: NewLogger(tmpPath),
|
||||
agentVersion: "2.0.test",
|
||||
signalPipelineProbe: probe);
|
||||
return (collector, sink);
|
||||
}
|
||||
|
||||
private static IDictionary<string, object> CollectSnapshotData(
|
||||
AgentSelfMetricsCollector collector, FakeSignalIngressSink sink)
|
||||
{
|
||||
collector.CollectSafe();
|
||||
var snapshot = sink.Posted.Single(p =>
|
||||
p.TypedPayload is IDictionary<string, object> d
|
||||
&& d.ContainsKey("agent_version"));
|
||||
return (IDictionary<string, object>)snapshot.TypedPayload!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_carries_pipeline_counters_stage_and_wg_verdict()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path, () => new SignalPipelineHealth
|
||||
{
|
||||
LastAssignedSignalOrdinal = 41,
|
||||
ProcessedSignalCount = 42,
|
||||
PendingSignalCount = 3,
|
||||
ReentrantPostCount = 7,
|
||||
QueueLengthPeak = 12,
|
||||
DecisionStage = "EspDeviceSetup",
|
||||
LastAppliedSignalOrdinal = 40,
|
||||
WhiteGloveSealingLevel = "Weak",
|
||||
WhiteGloveSealingScore = 40,
|
||||
});
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.Equal(41L, data["signal_last_ordinal"]);
|
||||
Assert.Equal(42L, data["signal_processed_total"]);
|
||||
Assert.Equal(3L, data["signal_pending_count"]);
|
||||
Assert.Equal(7L, data["signal_reentrant_total"]);
|
||||
Assert.Equal(12L, data["signal_queue_peak"]);
|
||||
Assert.Equal("EspDeviceSetup", data["decision_stage"]);
|
||||
Assert.Equal(40L, data["decision_last_applied_ordinal"]);
|
||||
Assert.Equal("Weak", data["wg_sealing_level"]);
|
||||
Assert.Equal(40, data["wg_sealing_score"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_derived_fields_are_omitted_when_the_probe_has_no_state()
|
||||
{
|
||||
// Counters-only health (test fakes without a decision-state probe): the numeric
|
||||
// block still lands, the state-derived keys must be ABSENT — not null-valued.
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path, () => new SignalPipelineHealth
|
||||
{
|
||||
ProcessedSignalCount = 5,
|
||||
});
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.Equal(5L, data["signal_processed_total"]);
|
||||
Assert.False(data.ContainsKey("decision_stage"));
|
||||
Assert.False(data.ContainsKey("decision_last_applied_ordinal"));
|
||||
Assert.False(data.ContainsKey("wg_sealing_level"));
|
||||
Assert.False(data.ContainsKey("wg_sealing_score"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wg_fields_are_omitted_before_the_classifier_produced_a_verdict()
|
||||
{
|
||||
// Level stays null until the classifier ran — every non-WG session would
|
||||
// otherwise carry a meaningless Unknown/0 pair on each snapshot.
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path, () => new SignalPipelineHealth
|
||||
{
|
||||
DecisionStage = "Monitoring",
|
||||
LastAppliedSignalOrdinal = 9,
|
||||
});
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.Equal("Monitoring", data["decision_stage"]);
|
||||
Assert.False(data.ContainsKey("wg_sealing_level"));
|
||||
Assert.False(data.ContainsKey("wg_sealing_score"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_failure_degrades_to_a_snapshot_without_the_block()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path,
|
||||
() => throw new InvalidOperationException("probe exploded"));
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.True(data.ContainsKey("agent_version")); // snapshot itself survives
|
||||
Assert.DoesNotContain(data.Keys, k => k.StartsWith("signal_", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_probe_means_no_pipeline_fields()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var (collector, sink) = Build(tmp.Path, probe: null);
|
||||
|
||||
var data = CollectSnapshotData(collector, sink);
|
||||
|
||||
Assert.DoesNotContain(data.Keys, k => k.StartsWith("signal_", StringComparison.Ordinal));
|
||||
Assert.False(data.ContainsKey("decision_stage"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -64,7 +64,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null)
|
||||
{
|
||||
CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds;
|
||||
CapturedIngress = ingress;
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null)
|
||||
{
|
||||
CapturedPatternIds = whiteGloveSealingPatternIds;
|
||||
return new CollectorSurfaces(Array.Empty<ICollectorHost>());
|
||||
|
||||
@@ -65,6 +65,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
return SpinWait.SpinUntil(condition, timeoutMs);
|
||||
}
|
||||
|
||||
// ==================================== Pipeline-health counters (metrics snapshot)
|
||||
|
||||
// The agent_metrics_snapshot exports these as MONOTONIC totals so two snapshots can
|
||||
// prove whether the worker ran in between (2026-09-01 WG-seal soak: a frozen engine
|
||||
// and a deliberate Weak sealing verdict were otherwise indistinguishable).
|
||||
|
||||
[Fact]
|
||||
public void ProcessedSignalCount_counts_fully_processed_signals()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var ing = rig.Build();
|
||||
Assert.Equal(0, ing.ProcessedSignalCount);
|
||||
ing.Start();
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence($"p-{i}"));
|
||||
|
||||
Assert.True(WaitFor(() => ing.ProcessedSignalCount == 5),
|
||||
$"expected 5 processed, saw {ing.ProcessedSignalCount}");
|
||||
Assert.Equal(0, ing.PendingSignalCount);
|
||||
ing.Stop();
|
||||
Assert.Equal(5, ing.ProcessedSignalCount); // stable after drain
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueueLengthPeak_is_monotonic_and_survives_drain()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
var ing = rig.Build();
|
||||
|
||||
// Post BEFORE Start: the worker is not consuming yet, so the channel depth
|
||||
// deterministically reaches the number of queued items.
|
||||
for (int i = 0; i < 4; i++)
|
||||
ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence($"q-{i}"));
|
||||
Assert.True(ing.QueueLengthPeak >= 4, $"peak {ing.QueueLengthPeak} < 4");
|
||||
|
||||
ing.Start();
|
||||
Assert.True(WaitFor(() => ing.ProcessedSignalCount == 4));
|
||||
// Drained queue must NOT reset the peak — that is the whole point of exporting
|
||||
// a peak instead of a momentary length (snapshot sampling cannot alias it away).
|
||||
Assert.True(ing.QueueLengthPeak >= 4);
|
||||
ing.Stop();
|
||||
}
|
||||
|
||||
// ============================================================ Lifecycle
|
||||
|
||||
[Fact]
|
||||
|
||||
+133
@@ -600,5 +600,138 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.DoesNotContain(ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted);
|
||||
}
|
||||
|
||||
|
||||
// ---- auto-update (Uninstall\RealmJoin) -----------------------------------------------
|
||||
|
||||
private sealed class VersionSource
|
||||
{
|
||||
public RealmJoinVersionInfo Info = RealmJoinVersionInfo.Empty;
|
||||
public string? DisplayVersion;
|
||||
public RealmJoinVersionInfo Read() => Info;
|
||||
public string? ReadDisplayVersion() => DisplayVersion;
|
||||
}
|
||||
|
||||
private sealed class AutoUpdateHarness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||
public VersionSource Versions { get; } = new VersionSource();
|
||||
public RealmJoinWatcher Watcher { get; }
|
||||
public RealmJoinWatcherAdapter Adapter { get; }
|
||||
|
||||
public AutoUpdateHarness()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var registry = new FakeRealmJoinRegistry();
|
||||
Watcher = new RealmJoinWatcher(
|
||||
logger,
|
||||
enumeratePackageIds: registry.Enumerate,
|
||||
tryReadPackage: registry.TryRead,
|
||||
keyExists: registry.KeyExists,
|
||||
readVersionInfo: Versions.Read,
|
||||
readUninstallDisplayVersion: Versions.ReadDisplayVersion);
|
||||
Adapter = new RealmJoinWatcherAdapter(Watcher, Ingress, new VirtualClock(Fixed));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Adapter.Dispose();
|
||||
Watcher.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoUpdate_fires_once_with_previous_and_new_version_when_binary_version_changes_after_detection()
|
||||
{
|
||||
// gktatooine session 946ccbd6: realmjoin_detected read 4.21.4 from the MSI-shipped
|
||||
// binary; nine minutes later the tray AppUpdater swapped it for 4.21.18 and rewrote
|
||||
// Uninstall\RealmJoin. The Detected read is the baseline; the re-read after the
|
||||
// Uninstall-key change must surface exactly one auto-update event.
|
||||
using var h = new AutoUpdateHarness();
|
||||
h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary");
|
||||
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
|
||||
Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest);
|
||||
|
||||
h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary");
|
||||
h.Watcher.TriggerCheckAutoUpdateFromTest();
|
||||
|
||||
var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
|
||||
Assert.Equal("4.21.4", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]);
|
||||
Assert.Equal("4.21.18", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ProductVersion]);
|
||||
Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]);
|
||||
|
||||
var info = Assert.Single(h.Ingress.Posted, p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == SharedEventTypes.RealmJoinAutoUpdateDetected);
|
||||
Assert.Equal("4.21.4", info.Payload!["previousVersion"]);
|
||||
Assert.Equal("4.21.18", info.Payload["newVersion"]);
|
||||
Assert.Equal("canary", info.Payload["releaseChannel"]);
|
||||
Assert.Contains("4.21.4 -> 4.21.18", info.Payload[SignalPayloadKeys.Message]);
|
||||
|
||||
// Same version again (updater rewrote more values) — no duplicate.
|
||||
h.Watcher.TriggerCheckAutoUpdateFromTest();
|
||||
Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
|
||||
Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoUpdate_stays_silent_when_version_is_unchanged_or_unreadable()
|
||||
{
|
||||
using var h = new AutoUpdateHarness();
|
||||
h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "release");
|
||||
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
|
||||
|
||||
// Unchanged (e.g. the tray creating the Uninstall entry on first start).
|
||||
h.Watcher.TriggerCheckAutoUpdateFromTest();
|
||||
// Unreadable (binary locked mid-swap, registry value not there yet) — baseline kept.
|
||||
h.Versions.Info = RealmJoinVersionInfo.Empty;
|
||||
h.Versions.DisplayVersion = null;
|
||||
h.Watcher.TriggerCheckAutoUpdateFromTest();
|
||||
|
||||
Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
|
||||
Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoUpdate_falls_back_to_Uninstall_DisplayVersion_and_keeps_baseline_channel()
|
||||
{
|
||||
using var h = new AutoUpdateHarness();
|
||||
h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary");
|
||||
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
|
||||
|
||||
h.Versions.Info = RealmJoinVersionInfo.Empty; // RealmJoin.exe still held by the updater
|
||||
h.Versions.DisplayVersion = "4.21.18"; // registry already rewritten
|
||||
h.Watcher.TriggerCheckAutoUpdateFromTest();
|
||||
|
||||
var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
|
||||
Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.ProductVersion]);
|
||||
// DisplayVersion carries no channel tag — the Detected channel is the best evidence.
|
||||
Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoUpdate_seeds_baseline_silently_when_Detected_could_not_read_the_binary()
|
||||
{
|
||||
using var h = new AutoUpdateHarness();
|
||||
h.Versions.Info = RealmJoinVersionInfo.Empty;
|
||||
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
|
||||
Assert.Null(h.Watcher.BaselineVersionForTest);
|
||||
|
||||
// First successful read has nothing to compare against — no phantom update.
|
||||
h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary");
|
||||
h.Watcher.TriggerCheckAutoUpdateFromTest();
|
||||
Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
|
||||
Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest);
|
||||
|
||||
// A later genuine change is still reported against the seeded baseline.
|
||||
h.Versions.Info = new RealmJoinVersionInfo("4.21.19", "canary");
|
||||
h.Watcher.TriggerCheckAutoUpdateFromTest();
|
||||
var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
|
||||
Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+52
-24
@@ -331,6 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private sealed class PassStats
|
||||
{
|
||||
public int RegexTimeouts;
|
||||
public int RegexTimeoutRetries;
|
||||
public int BudgetBreaks;
|
||||
public int OversizedLines;
|
||||
public string FirstSkippedPatternId;
|
||||
@@ -342,23 +343,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// remaining patterns are skipped for this line (counted, first skipped ID recorded) —
|
||||
/// matches already handled on this line stand.
|
||||
/// <para>
|
||||
/// The budget is the SUM of the time spent inside <c>Regex.Match</c> on this line — the
|
||||
/// cost the budget exists to bound (hostile input × unanchored custom patterns). It is
|
||||
/// deliberately not the wall-clock from the first to the last pattern: that window also
|
||||
/// contains the match handlers (signal ingress, match-log append, state writes) and
|
||||
/// whatever the scheduler does to this thread in between. Session 946ccbd6 (2026-08-30)
|
||||
/// recorded two budget breaks on a 1 MB `AppWorkload.log` with no line over 5 KB — the
|
||||
/// Hyper-V guest had stalled the whole process for 5–10 s mid-line — and each break
|
||||
/// silently skipped the remaining patterns of a genuine line.
|
||||
/// The budget is the SUM of the COST spent inside <c>Regex.Match</c> on this line — the
|
||||
/// resource the budget exists to bound (hostile input × unanchored custom patterns).
|
||||
/// Cost is thread CPU cycles when <see cref="ThreadCycleTimeAvailable"/>, wall ticks
|
||||
/// otherwise. Two production sessions forced the cycle move (both Hyper-V, 2026-08-30):
|
||||
/// 946ccbd6 — a 5–10 s guest stall BETWEEN two patterns broke the then wall-clock-window
|
||||
/// budget on a genuine line (fixed by charging only Match time); b9f1d134 — a stall
|
||||
/// INSIDE Match calls (CPU pinned at 100 % during the DeviceSetup script burst) broke
|
||||
/// the budget six times and tripped the 1 s regex timeout on a line PS-AGENT-OUTPUT
|
||||
/// genuinely matches in 5.8 µs. Descheduled time accrues no cycles, so only the second
|
||||
/// failure mode needed the cost change; the timeout itself is wall-clock inside .NET
|
||||
/// and cannot be moved — hence the gated single retry below.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A <see cref="RegexMatchTimeoutException"/> whose attempt consumed less than
|
||||
/// <see cref="TimeoutRetryGate"/> of cost was starvation, not matching — that attempt
|
||||
/// is retried exactly once (counted as a retry, not a timeout; a recovered match is
|
||||
/// handled normally and is NOT skipped work). An attempt at or over the gate burned
|
||||
/// real CPU: counted as a timeout and skipped, never granted a second helping. In the
|
||||
/// wall-clock fallback the gate is unreachable (a timed-out attempt always measures
|
||||
/// ≥ the full timeout), so the fallback keeps the old skip-on-first-timeout behaviour.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void MatchLine(string filePath, string line, string messageToMatch, CmTraceLogEntry entry, PassStats stats)
|
||||
{
|
||||
long matchTicks = 0;
|
||||
long matchCost = 0;
|
||||
for (var i = 0; i < _activePatterns.Count; i++)
|
||||
{
|
||||
var pattern = _activePatterns[i];
|
||||
if (matchTicks > LineMatchBudgetTicks)
|
||||
if (matchCost > LineMatchBudget)
|
||||
{
|
||||
stats.BudgetBreaks++;
|
||||
_budgetBreaks++;
|
||||
@@ -366,22 +379,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return;
|
||||
}
|
||||
|
||||
Match match;
|
||||
var matchStarted = Stopwatch.GetTimestamp();
|
||||
try
|
||||
Match match = null;
|
||||
var retried = false;
|
||||
while (true)
|
||||
{
|
||||
match = pattern.Regex.Match(messageToMatch);
|
||||
var costBefore = MatchCostReader();
|
||||
try
|
||||
{
|
||||
match = MatchInvoker(pattern.PatternId, pattern.Regex, messageToMatch);
|
||||
matchCost += MatchCostReader() - costBefore;
|
||||
break;
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
var attemptCost = MatchCostReader() - costBefore;
|
||||
matchCost += attemptCost;
|
||||
if (!retried && attemptCost < TimeoutRetryGate)
|
||||
{
|
||||
retried = true;
|
||||
stats.RegexTimeoutRetries++;
|
||||
_regexTimeoutRetries++;
|
||||
_logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' consumed only {attemptCost} match-cost units (< gate {TimeoutRetryGate}) — starved thread, retrying once");
|
||||
continue;
|
||||
}
|
||||
stats.RegexTimeouts++;
|
||||
_regexTimeouts++;
|
||||
if (_timeoutLoggedPatternIds.Add(pattern.PatternId))
|
||||
_logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' (message length {messageToMatch.Length}, attempt cost {attemptCost}) — skipped; further timeouts of this pattern are only counted");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
matchTicks += Stopwatch.GetTimestamp() - matchStarted;
|
||||
stats.RegexTimeouts++;
|
||||
_regexTimeouts++;
|
||||
if (_timeoutLoggedPatternIds.Add(pattern.PatternId))
|
||||
_logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' (message length {messageToMatch.Length}) — skipped; further timeouts of this pattern are only counted");
|
||||
continue;
|
||||
}
|
||||
matchTicks += Stopwatch.GetTimestamp() - matchStarted;
|
||||
if (match == null) continue;
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
|
||||
+54
-7
@@ -241,12 +241,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// (IME writes an entry in one call) is far smaller.
|
||||
internal const int HeldTailMaxBytes = 1024 * 1024;
|
||||
|
||||
// Aggregate matching budget per line across ALL active patterns. The 1 s per-regex
|
||||
// timeout bounds one Match call; with ~80 active patterns and tenant-authored custom
|
||||
// patterns (which are not anchored) a crafted line could still serialize ~80 s. After
|
||||
// this budget the remaining patterns are skipped for that line and the pass summary
|
||||
// says so. No genuine IME line comes anywhere near it.
|
||||
private static readonly long LineMatchBudgetTicks = Stopwatch.Frequency * 2;
|
||||
// Per-Match wall-clock timeout baked into every compiled Regex. .NET checks it against
|
||||
// the wall clock internally, so it CANNOT be moved to CPU time — the starvation retry
|
||||
// below exists precisely because of that.
|
||||
internal static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1);
|
||||
|
||||
// Whether per-thread CPU cycle accounting is available (probed once). When it is, the
|
||||
// line budget and the timeout-retry gate are charged in cycles the matching thread
|
||||
// actually executed; when not, both fall back to wall-clock Stopwatch ticks — exactly
|
||||
// the pre-cycle behaviour, including "no retry ever" (see TimeoutRetryGate).
|
||||
internal static readonly bool ThreadCycleTimeAvailable = Interop.ThreadNativeMethods.Probe();
|
||||
|
||||
// Converts "seconds of hostile CPU" into a cycle budget. Nominal frequency is unknowable
|
||||
// in advance and precision is irrelevant: genuine lines cost microseconds (session
|
||||
// b9f1d134: PS-AGENT-OUTPUT on its worst real line = 5.8 µs), the budget bounds seconds.
|
||||
// A 1 GHz machine gets an effective 6 s budget, a 5 GHz machine 1.2 s — both fine.
|
||||
private const long AssumedCyclesPerSecond = 3_000_000_000;
|
||||
|
||||
// Aggregate matching budget per line across ALL active patterns, charged in match COST
|
||||
// (thread CPU cycles when available, wall ticks otherwise). The per-regex timeout bounds
|
||||
// one Match call; with ~80 active patterns and tenant-authored custom patterns (which
|
||||
// are not anchored) a crafted line could still serialize ~80 s of CPU. After this budget
|
||||
// the remaining patterns are skipped for that line and the pass summary says so. No
|
||||
// genuine IME line comes anywhere near it — but a starved thread no longer trips it,
|
||||
// because descheduled time accrues no cycles (sessions 946ccbd6 / b9f1d134, 2026-08-30).
|
||||
// Instance field as a test seam; initialised from the probed static default.
|
||||
internal long LineMatchBudget =
|
||||
ThreadCycleTimeAvailable ? AssumedCyclesPerSecond * 2 : Stopwatch.Frequency * 2;
|
||||
|
||||
// Gate for the ONE retry after a RegexMatchTimeoutException: retry only when the
|
||||
// timed-out attempt consumed less than ~0.5 s of match cost — then the 1 s wall-clock
|
||||
// timeout was starvation, and the retry costs what the match really costs (µs). A
|
||||
// genuinely spinning pattern burns ~1 s of CPU into the attempt, exceeds the gate and
|
||||
// is skipped without a second helping. In the wall-clock fallback the gate is
|
||||
// unreachable by construction (a timed-out attempt always measures >= 1 s wall), so no
|
||||
// retry ever fires there — starvation and spinning are indistinguishable without
|
||||
// cycle accounting, and retrying a genuine spinner would double the hostile bound.
|
||||
internal long TimeoutRetryGate =
|
||||
ThreadCycleTimeAvailable ? AssumedCyclesPerSecond / 2 : Stopwatch.Frequency / 2;
|
||||
|
||||
// Monotonic per-thread match-cost counter (cycles or wall ticks; deltas taken around
|
||||
// each Regex.Match on the poll thread only). Test seam.
|
||||
internal Func<long> MatchCostReader = ThreadCycleTimeAvailable
|
||||
? (Func<long>)Interop.ThreadNativeMethods.GetCurrentThreadCycles
|
||||
: Stopwatch.GetTimestamp;
|
||||
|
||||
// Test seam for MatchLine: lets tests throw RegexMatchTimeoutException deterministically
|
||||
// for a chosen pattern instead of constructing a genuinely catastrophic regex.
|
||||
internal Func<string, Regex, string, Match> MatchInvoker =
|
||||
(patternId, regex, input) => regex.Match(input);
|
||||
|
||||
// An unterminated tail at EOF (a physical line without '\n', or a multiline entry
|
||||
// without its ']LOG]!>' close) is normally the writer mid-write. It is held back —
|
||||
@@ -279,6 +322,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private long _entriesMatched;
|
||||
private long _oversizedLines;
|
||||
private long _regexTimeouts;
|
||||
private long _regexTimeoutRetries;
|
||||
private long _budgetBreaks;
|
||||
private long _heldTailCount;
|
||||
private int _unanchoredPatterns;
|
||||
@@ -306,6 +350,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
EntriesMatched = _entriesMatched,
|
||||
OversizedLines = _oversizedLines,
|
||||
RegexTimeouts = _regexTimeouts,
|
||||
RegexTimeoutRetries = _regexTimeoutRetries,
|
||||
BudgetBreaks = _budgetBreaks,
|
||||
HeldTails = _heldTailCount,
|
||||
HealthScriptResultParseFailures = HealthScriptResultParseFailures,
|
||||
@@ -733,7 +778,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var regexStr = pattern.Pattern.Replace("{GUID}", GuidPattern);
|
||||
// Singleline: make '.' match newlines too — required because multiline CMTrace
|
||||
// entries are reassembled with '\n' chars (e.g. DO TEL JSON in IME >= 1.101)
|
||||
var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, TimeSpan.FromSeconds(1));
|
||||
var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, RegexMatchTimeout);
|
||||
|
||||
var compiled = new CompiledPattern
|
||||
{
|
||||
@@ -1000,6 +1045,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_entriesMatched = state.Health.EntriesMatched;
|
||||
_oversizedLines = state.Health.OversizedLines;
|
||||
_regexTimeouts = state.Health.RegexTimeouts;
|
||||
_regexTimeoutRetries = state.Health.RegexTimeoutRetries;
|
||||
_budgetBreaks = state.Health.BudgetBreaks;
|
||||
_heldTailCount = state.Health.HeldTails;
|
||||
HealthScriptResultParseFailures = state.Health.HealthScriptResultParseFailures;
|
||||
@@ -1068,6 +1114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
EntriesMatched = _entriesMatched,
|
||||
OversizedLines = _oversizedLines,
|
||||
RegexTimeouts = _regexTimeouts,
|
||||
RegexTimeoutRetries = _regexTimeoutRetries,
|
||||
BudgetBreaks = _budgetBreaks,
|
||||
HeldTails = _heldTailCount,
|
||||
HealthScriptResultParseFailures = HealthScriptResultParseFailures,
|
||||
|
||||
@@ -21,6 +21,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public long EntriesMatched { get; set; }
|
||||
public long OversizedLines { get; set; }
|
||||
public long RegexTimeouts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Regex timeouts absorbed by the one gated retry: the timed-out attempt consumed
|
||||
/// almost no CPU (a starved thread, not a spinning pattern) and the retry recovered
|
||||
/// the match. Expected nonzero on CPU-saturated VMs; NOT skipped work — only a
|
||||
/// timeout that survives (or is denied) the retry counts into <see cref="RegexTimeouts"/>.
|
||||
/// </summary>
|
||||
public long RegexTimeoutRetries { get; set; }
|
||||
|
||||
public long BudgetBreaks { get; set; }
|
||||
public long HeldTails { get; set; }
|
||||
public int HealthScriptResultParseFailures { get; set; }
|
||||
|
||||
+1
@@ -159,6 +159,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public long EntriesMatched { get; set; }
|
||||
public long OversizedLines { get; set; }
|
||||
public long RegexTimeouts { get; set; }
|
||||
public long RegexTimeoutRetries { get; set; }
|
||||
public long BudgetBreaks { get; set; }
|
||||
public long HeldTails { get; set; }
|
||||
public int HealthScriptResultParseFailures { get; set; }
|
||||
|
||||
+43
@@ -102,6 +102,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <summary>Defensive fallback for legacy 32-bit installs.</summary>
|
||||
public const string RealmJoinExePathX86 = @"C:\Program Files (x86)\RealmJoin\RealmJoin.exe";
|
||||
|
||||
/// <summary>
|
||||
/// Always-existing parent of the RJ Add/Remove-Programs entry — the appearance-watch
|
||||
/// anchor when <see cref="UninstallRealmJoinPath"/> does not exist yet (the tray creates
|
||||
/// the entry on its first start, after the service key is already present).
|
||||
/// </summary>
|
||||
public const string UninstallRootPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
|
||||
/// <summary>Sub-key name of the RJ entry under <see cref="UninstallRootPath"/>.</summary>
|
||||
public const string UninstallRealmJoinKeyName = "RealmJoin";
|
||||
|
||||
/// <summary>
|
||||
/// RJ's Add/Remove-Programs entry. The in-app updater (<c>RealmJoin.Core.AppUpdater</c>)
|
||||
/// rewrites this key (<c>DisplayVersion</c>, <c>InstallDate</c>) in the same second it
|
||||
/// swaps <c>RealmJoin.exe</c> — the only registry write that reflects an RJ self-update.
|
||||
/// Neither <c>SOFTWARE\RealmJoin</c> nor <c>Services\realmjoin\Parameters</c> carry a version.
|
||||
/// </summary>
|
||||
public const string UninstallRealmJoinPath = UninstallRootPath + @"\" + UninstallRealmJoinKeyName;
|
||||
|
||||
/// <summary>Bare version string under <see cref="UninstallRealmJoinPath"/>, e.g. <c>4.21.18</c> (no channel tag).</summary>
|
||||
public const string DisplayVersionValueName = "DisplayVersion";
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort read of <c>DisplayVersion</c> under <see cref="UninstallRealmJoinPath"/>.
|
||||
/// Fallback for the auto-update watcher when <c>RealmJoin.exe</c> is momentarily locked by
|
||||
/// the updater; carries no release channel. <c>null</c> when absent or unreadable.
|
||||
/// </summary>
|
||||
public static string? TryReadUninstallDisplayVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
||||
using (var key = baseKey.OpenSubKey(UninstallRealmJoinPath, writable: false))
|
||||
{
|
||||
var raw = key?.GetValue(DisplayVersionValueName) as string;
|
||||
return string.IsNullOrWhiteSpace(raw) ? null : raw!.Trim();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release-channel name reported when the version string carries no SemVer prerelease
|
||||
/// tag. Per the RJ developer, only beta/canary builds are tagged — untagged == stable.
|
||||
|
||||
+173
-5
@@ -35,6 +35,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RJ replaced its own binary after detection (tray <c>AppUpdater</c> "New version found …,
|
||||
/// update required" on first start). <see cref="PreviousVersion"/> is the version reported
|
||||
/// on <see cref="RealmJoinDetectedEventArgs"/> (or the last auto-update), <see cref="NewVersion"/>
|
||||
/// the version now installed. Observability-only — the deployment lifecycle is unaffected.
|
||||
/// </summary>
|
||||
public sealed class RealmJoinAutoUpdateDetectedEventArgs : EventArgs
|
||||
{
|
||||
public string PreviousVersion { get; }
|
||||
public string NewVersion { get; }
|
||||
/// <summary>Channel of the new build; <c>null</c> when only the registry fallback was readable.</summary>
|
||||
public string? ReleaseChannel { get; }
|
||||
|
||||
public RealmJoinAutoUpdateDetectedEventArgs(string previousVersion, string newVersion, string? releaseChannel)
|
||||
{
|
||||
PreviousVersion = previousVersion;
|
||||
NewVersion = newVersion;
|
||||
ReleaseChannel = releaseChannel;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RealmJoinResolvedEventArgs : EventArgs
|
||||
{
|
||||
public int DeploymentPhase { get; }
|
||||
@@ -104,13 +125,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal delegate IReadOnlyList<string> EnumeratePackageIdsDelegate(RegistryHive hive, string packagesPath);
|
||||
internal delegate bool TryReadPackageDelegate(RegistryHive hive, string packagesPath, string packageId, out RealmJoinPackageSnapshot snapshot);
|
||||
internal delegate bool KeyExistsDelegate(RegistryHive hive, string subPath);
|
||||
internal delegate RealmJoinVersionInfo ReadVersionInfoDelegate();
|
||||
internal delegate string? ReadUninstallDisplayVersionDelegate();
|
||||
|
||||
/// <summary>
|
||||
/// Debounce for the Uninstall-key watcher. Longer than <see cref="DebounceMilliseconds"/>:
|
||||
/// the updater rewrites several values and swaps <c>RealmJoin.exe</c> in the same window,
|
||||
/// so the version re-read should land after the binary is back in place.
|
||||
/// </summary>
|
||||
internal const int AutoUpdateDebounceMilliseconds = 2000;
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly EnumeratePackageIdsDelegate _enumeratePackageIds;
|
||||
private readonly TryReadPackageDelegate _tryReadPackage;
|
||||
private readonly KeyExistsDelegate _keyExists;
|
||||
private readonly ReadVersionInfoDelegate _readVersionInfo;
|
||||
private readonly ReadUninstallDisplayVersionDelegate _readUninstallDisplayVersion;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
// Auto-update stage (armed once Detected fired, held until Stop).
|
||||
private RegistryWatcher? _uninstallAppearanceWatcher;
|
||||
private RegistryWatcher? _uninstallWatcher;
|
||||
private Timer? _uninstallDebounce;
|
||||
// Version baseline the next Uninstall-key change is compared against. Seeded from the
|
||||
// Detected read; null when the binary was unreadable then — the first successful
|
||||
// re-read seeds it silently instead of reporting a phantom update.
|
||||
private string? _baselineVersion;
|
||||
private string? _baselineChannel;
|
||||
|
||||
// Stage-1 parent-watchers (disposed on appearance).
|
||||
private RegistryWatcher? _servicesAppearanceWatcher;
|
||||
private RegistryWatcher? _machineSoftwareAppearanceWatcher;
|
||||
@@ -149,6 +191,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public event EventHandler<RealmJoinPhaseChangedEventArgs>? RealmJoinPhaseChanged;
|
||||
public event EventHandler<RealmJoinPackageEventArgs>? RealmJoinPackageStarted;
|
||||
public event EventHandler<RealmJoinPackageEventArgs>? RealmJoinPackageCompleted;
|
||||
public event EventHandler<RealmJoinAutoUpdateDetectedEventArgs>? RealmJoinAutoUpdateDetected;
|
||||
|
||||
public RealmJoinWatcher(AgentLogger logger)
|
||||
: this(logger, RealmJoinInfo.EnumeratePackageIds, RealmJoinInfo.TryReadPackage, KeyExistsViaRegistry)
|
||||
@@ -159,12 +202,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
AgentLogger logger,
|
||||
EnumeratePackageIdsDelegate enumeratePackageIds,
|
||||
TryReadPackageDelegate tryReadPackage,
|
||||
KeyExistsDelegate keyExists)
|
||||
KeyExistsDelegate keyExists,
|
||||
ReadVersionInfoDelegate? readVersionInfo = null,
|
||||
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_enumeratePackageIds = enumeratePackageIds ?? throw new ArgumentNullException(nameof(enumeratePackageIds));
|
||||
_tryReadPackage = tryReadPackage ?? throw new ArgumentNullException(nameof(tryReadPackage));
|
||||
_keyExists = keyExists ?? throw new ArgumentNullException(nameof(keyExists));
|
||||
_readVersionInfo = readVersionInfo ?? RealmJoinInfo.TryReadRealmJoinVersionInfo;
|
||||
_readUninstallDisplayVersion = readUninstallDisplayVersion ?? RealmJoinInfo.TryReadUninstallDisplayVersion;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -224,14 +271,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
DisposeAndNull(ref _servicesAppearanceWatcher);
|
||||
DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
|
||||
DisposeAndNull(ref _userSoftwareAppearanceWatcher);
|
||||
DisposeAndNull(ref _uninstallAppearanceWatcher);
|
||||
|
||||
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
|
||||
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
|
||||
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
|
||||
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
|
||||
|
||||
DisposeAndNull(ref _serviceRealmjoinWatcher);
|
||||
DisposeAndNull(ref _machineRealmJoinWatcher);
|
||||
DisposeAndNull(ref _userRealmJoinWatcher);
|
||||
DisposeAndNull(ref _uninstallWatcher);
|
||||
}
|
||||
_logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
}
|
||||
@@ -555,13 +605,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (fireDetected)
|
||||
{
|
||||
var initial = phase ?? 0;
|
||||
// Read RJ version + release channel best-effort (fail-soft to null) ONLY when
|
||||
// the Detected event actually fires — single read per session lifetime keeps
|
||||
// any potential file-system cost to a single hit.
|
||||
var versionInfo = RealmJoinInfo.TryReadRealmJoinVersionInfo();
|
||||
// Read RJ version + release channel best-effort (fail-soft to null) when the
|
||||
// Detected event fires. The result seeds the auto-update baseline; later reads
|
||||
// only happen when the Uninstall key changes (see CheckAutoUpdate).
|
||||
var versionInfo = _readVersionInfo();
|
||||
lock (_stateLock)
|
||||
{
|
||||
_baselineVersion = versionInfo.ProductVersion;
|
||||
_baselineChannel = versionInfo.ReleaseChannel;
|
||||
}
|
||||
_logger.Info($"RealmJoinWatcher: RJ detected (phase={initial}, productVersion={versionInfo.ProductVersion ?? "<unknown>"}, releaseChannel={versionInfo.ReleaseChannel ?? "<unknown>"})");
|
||||
try { RealmJoinDetected?.Invoke(this, new RealmJoinDetectedEventArgs(initial, versionInfo.ProductVersion, versionInfo.ReleaseChannel)); }
|
||||
catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinDetected handler threw", ex); }
|
||||
|
||||
EnsureUninstallStage();
|
||||
}
|
||||
|
||||
if (firePhaseChange && prevPhase.HasValue && phase.HasValue)
|
||||
@@ -593,6 +650,115 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
// ---- auto-update stage (Uninstall\RealmJoin) ---------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Arm the RJ self-update observer. The tray's <c>AppUpdater</c> rewrites
|
||||
/// <see cref="RealmJoinInfo.UninstallRealmJoinPath"/> whenever it swaps the binary — on a
|
||||
/// first start with a stale MSI payload this happens minutes after Detected, so the
|
||||
/// version reported on <see cref="RealmJoinDetected"/> would otherwise be wrong for the
|
||||
/// rest of the session. Same appearance-then-subtree pattern as the other stages; the
|
||||
/// entry is created by the tray on first start, i.e. it may not exist at Detected time.
|
||||
/// </summary>
|
||||
private void EnsureUninstallStage()
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (_keyExists(RegistryHive.LocalMachine, RealmJoinInfo.UninstallRealmJoinPath))
|
||||
{
|
||||
AttachUninstallWatcher();
|
||||
return;
|
||||
}
|
||||
ArmAppearanceParentWatcher(
|
||||
role: "uninstall",
|
||||
hive: RegistryHive.LocalMachine,
|
||||
parentPath: RealmJoinInfo.UninstallRootPath,
|
||||
targetSubKeyName: RealmJoinInfo.UninstallRealmJoinKeyName,
|
||||
targetFullPath: RealmJoinInfo.UninstallRealmJoinPath,
|
||||
slot: w => { lock (_stateLock) { _uninstallAppearanceWatcher = w; } },
|
||||
clearSlot: () => { lock (_stateLock) { _uninstallAppearanceWatcher = null; } },
|
||||
onAppeared: AttachUninstallWatcher);
|
||||
}
|
||||
|
||||
private void AttachUninstallWatcher()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_disposed || _uninstallWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
_uninstallWatcher = new RegistryWatcher(
|
||||
RegistryHive.LocalMachine,
|
||||
RealmJoinInfo.UninstallRealmJoinPath,
|
||||
watchSubtree: false,
|
||||
view: RegistryView.Registry64,
|
||||
filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet,
|
||||
trace: msg => _logger.Trace($"RealmJoinWatcher(uninstall): {msg}"));
|
||||
_uninstallWatcher.Changed += (s, e) => _uninstallDebounce?.Change(AutoUpdateDebounceMilliseconds, Timeout.Infinite);
|
||||
_uninstallWatcher.Error += (s, ex) => _logger.Warning($"RealmJoinWatcher(uninstall): {ex.Message}");
|
||||
_uninstallWatcher.Start();
|
||||
_logger.Info("RealmJoinWatcher: attached auto-update watcher on Uninstall\\RealmJoin");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"RealmJoinWatcher: failed to attach Uninstall\\RealmJoin watcher: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// The key may already carry a newer version than the Detected read (update raced
|
||||
// the appearance), so sweep once on attach — same as the other stages.
|
||||
CheckAutoUpdate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-read the installed RJ version and fire <see cref="RealmJoinAutoUpdateDetected"/>
|
||||
/// when it differs from the baseline. Primary source is the binary's file-version
|
||||
/// resource (carries the channel); the Uninstall key's <c>DisplayVersion</c> is the
|
||||
/// fallback while the updater still holds the file. Unreadable ⇒ no event, baseline kept.
|
||||
/// </summary>
|
||||
private void CheckAutoUpdate()
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = _readVersionInfo();
|
||||
var newVersion = info.ProductVersion;
|
||||
var newChannel = info.ReleaseChannel;
|
||||
if (string.IsNullOrEmpty(newVersion))
|
||||
{
|
||||
newVersion = _readUninstallDisplayVersion();
|
||||
newChannel = null;
|
||||
if (string.IsNullOrEmpty(newVersion)) return;
|
||||
}
|
||||
|
||||
string? previousVersion;
|
||||
string? channelForEvent;
|
||||
lock (_stateLock)
|
||||
{
|
||||
previousVersion = _baselineVersion;
|
||||
if (previousVersion == null)
|
||||
{
|
||||
// Detected could not read the binary — seed silently, nothing to compare.
|
||||
_baselineVersion = newVersion;
|
||||
_baselineChannel = newChannel;
|
||||
return;
|
||||
}
|
||||
if (string.Equals(previousVersion, newVersion, StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
channelForEvent = newChannel ?? _baselineChannel;
|
||||
_baselineVersion = newVersion;
|
||||
_baselineChannel = channelForEvent;
|
||||
}
|
||||
|
||||
_logger.Info($"RealmJoinWatcher: RJ auto-update detected ({previousVersion} -> {newVersion}, releaseChannel={channelForEvent ?? "<unknown>"})");
|
||||
try { RealmJoinAutoUpdateDetected?.Invoke(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion!, channelForEvent)); }
|
||||
catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinAutoUpdateDetected handler threw", ex); }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"RealmJoinWatcher: CheckAutoUpdate threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckMachinePackages()
|
||||
{
|
||||
CheckPackages(
|
||||
@@ -766,6 +932,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
MaybeFirePackageEvents(RealmJoinPackageScope.User, snap, _hkuPackagesStarted, _hkuPackagesCompleted);
|
||||
|
||||
internal void TriggerNotifyRealmJoinPresenceFromTest(int? phase) => NotifyRealmJoinPresence(phase);
|
||||
internal void TriggerCheckAutoUpdateFromTest() => CheckAutoUpdate();
|
||||
internal string? BaselineVersionForTest { get { lock (_stateLock) { return _baselineVersion; } } }
|
||||
|
||||
// Test seam — directly seed the dedup sets without touching the registry, so that a
|
||||
// subsequent MaybeFirePackageEvents observation can verify suppression behavior.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Win32 P/Invoke for per-thread CPU accounting. Used by ImeLogTracker to charge the
|
||||
/// per-line match budget with cycles the matching thread actually executed instead of
|
||||
/// wall-clock time: on a CPU-saturated guest the wall clock keeps running while the
|
||||
/// thread is descheduled, so a wall-based budget measures the scheduler, not the regex
|
||||
/// (sessions 946ccbd6 / b9f1d134, 2026-08-30).
|
||||
/// </summary>
|
||||
internal static class ThreadNativeMethods
|
||||
{
|
||||
// Pseudo handle (-2): always refers to the calling thread, never needs closing.
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr GetCurrentThread();
|
||||
|
||||
// Cycles the thread has executed, accumulated by the kernel at context switches.
|
||||
// Excludes time the thread spent descheduled by the GUEST scheduler; hypervisor-level
|
||||
// vCPU preemption is invisible to the guest kernel and still gets charged — the
|
||||
// measurement is a strict improvement over wall clock, not perfect steal accounting.
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool QueryThreadCycleTime(IntPtr threadHandle, out ulong cycleTime);
|
||||
|
||||
/// <summary>
|
||||
/// Cycle count of the calling thread, or 0 if the call fails. A 0 makes the caller's
|
||||
/// delta accounting charge nothing for the enclosed work — the safe direction (no
|
||||
/// spurious skips); persistent unavailability is ruled out by <see cref="Probe"/>.
|
||||
/// </summary>
|
||||
public static long GetCurrentThreadCycles()
|
||||
{
|
||||
try
|
||||
{
|
||||
ulong cycles;
|
||||
return QueryThreadCycleTime(GetCurrentThread(), out cycles) ? unchecked((long)cycles) : 0L;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One-shot availability check (missing export, denied call).</summary>
|
||||
public static bool Probe()
|
||||
{
|
||||
try
|
||||
{
|
||||
ulong cycles;
|
||||
return QueryThreadCycleTime(GetCurrentThread(), out cycles);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-4
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
private readonly NetworkMetrics _networkMetrics;
|
||||
private readonly ITelemetrySpool? _telemetrySpool;
|
||||
private readonly Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? _imeTrackerHealthProbe;
|
||||
private readonly Func<Orchestration.SignalPipelineHealth?>? _signalPipelineProbe;
|
||||
|
||||
// Previous sample for delta calculations
|
||||
private TimeSpan _prevCpuTime;
|
||||
@@ -53,13 +54,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
string agentVersion = "unknown",
|
||||
int intervalSeconds = 60,
|
||||
ITelemetrySpool? telemetrySpool = null,
|
||||
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null)
|
||||
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null,
|
||||
Func<Orchestration.SignalPipelineHealth?>? signalPipelineProbe = null)
|
||||
: base(sessionId, tenantId, post, logger, intervalSeconds)
|
||||
{
|
||||
_networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics));
|
||||
_agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion;
|
||||
_telemetrySpool = telemetrySpool;
|
||||
_imeTrackerHealthProbe = imeTrackerHealthProbe;
|
||||
_signalPipelineProbe = signalPipelineProbe;
|
||||
}
|
||||
|
||||
protected override void OnBeforeStart()
|
||||
@@ -82,11 +85,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
// Full path writes 29 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// Full path writes 38 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (5) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency) + IME tracker health
|
||||
// (9). cap=29 → HashHelpers.GetPrime(29)=29 buckets → no resize on the last key.
|
||||
var data = new Dictionary<string, object>(capacity: 29, StringComparer.Ordinal)
|
||||
// (9) + signal pipeline (9: 5 counters, stage, applied ordinal, wg level+score).
|
||||
// cap=38 → HashHelpers.GetPrime(38)=41 buckets → no resize on the last key.
|
||||
var data = new Dictionary<string, object>(capacity: 38, StringComparer.Ordinal)
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
@@ -201,6 +205,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
data["ime_entries_matched"] = health.EntriesMatched;
|
||||
data["ime_oversized_lines"] = health.OversizedLines;
|
||||
data["ime_regex_timeouts"] = health.RegexTimeouts;
|
||||
data["ime_regex_timeout_retries"] = health.RegexTimeoutRetries;
|
||||
data["ime_line_budget_breaks"] = health.BudgetBreaks;
|
||||
data["ime_held_tails"] = health.HeldTails;
|
||||
data["ime_unanchored_patterns"] = health.UnanchoredPatterns;
|
||||
@@ -212,6 +217,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
}
|
||||
}
|
||||
|
||||
// --- Signal/decision pipeline health ---
|
||||
// Monotonic totals + a monotonic queue peak, deliberately NO momentary queue
|
||||
// length: a snapshot samples the integral, so any two snapshots prove whether
|
||||
// the ingress worker ran in between. Field motivation (2026-09-01 WG-seal soak):
|
||||
// sessions whose engine never sealed were indistinguishable from a deliberate
|
||||
// Weak classifier verdict without these fields. decision_stage answers the WG
|
||||
// question directly; the wg_sealing_* pair separates "classifier said Weak"
|
||||
// from "classifier never ran".
|
||||
if (_signalPipelineProbe != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pipeline = _signalPipelineProbe();
|
||||
if (pipeline != null)
|
||||
{
|
||||
data["signal_last_ordinal"] = pipeline.LastAssignedSignalOrdinal;
|
||||
data["signal_processed_total"] = pipeline.ProcessedSignalCount;
|
||||
data["signal_pending_count"] = pipeline.PendingSignalCount;
|
||||
data["signal_reentrant_total"] = pipeline.ReentrantPostCount;
|
||||
data["signal_queue_peak"] = pipeline.QueueLengthPeak;
|
||||
if (pipeline.DecisionStage != null)
|
||||
data["decision_stage"] = pipeline.DecisionStage;
|
||||
if (pipeline.LastAppliedSignalOrdinal.HasValue)
|
||||
data["decision_last_applied_ordinal"] = pipeline.LastAppliedSignalOrdinal.Value;
|
||||
if (pipeline.WhiteGloveSealingLevel != null)
|
||||
{
|
||||
data["wg_sealing_level"] = pipeline.WhiteGloveSealingLevel;
|
||||
if (pipeline.WhiteGloveSealingScore.HasValue)
|
||||
data["wg_sealing_score"] = pipeline.WhiteGloveSealingScore.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"Signal pipeline health read failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
{
|
||||
Post.Emit(new EnrollmentEvent
|
||||
|
||||
@@ -204,7 +204,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId));
|
||||
@@ -599,7 +600,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
startupGate: _startupEventGate, // M3 — disk_space_low latch survives restarts
|
||||
// IME tracker health rides the metrics snapshot (queue depth + skip counters);
|
||||
// read-only probe, same shape as the registry observer's trackerStateProbe.
|
||||
imeTrackerHealthProbe: () => imeLogHostRef?.GetTrackerHealth()));
|
||||
imeTrackerHealthProbe: () => imeLogHostRef?.GetTrackerHealth(),
|
||||
// Stage + WG classifier verdict for the snapshot's signal-pipeline block
|
||||
// (2026-09-01 WG-seal soak: without them a frozen engine and a deliberate
|
||||
// Weak sealing verdict were telemetrically indistinguishable).
|
||||
decisionStateProbe: decisionStateProbe));
|
||||
}
|
||||
|
||||
// V1 parity (CollectorCoordinator.StartOptionalCollectors:375-382) — wire the
|
||||
|
||||
@@ -667,7 +667,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var surfaces = _componentFactory.CreateCollectorHosts(
|
||||
_sessionId, _tenantId, _logger, _whiteGloveSealingPatternIds,
|
||||
ingress: _ingress, clock: _clock, telemetrySpool: _spool,
|
||||
timelineEvents: _timelineEvents);
|
||||
timelineEvents: _timelineEvents,
|
||||
// Immutable-state reference read; null-safe against a processor that is
|
||||
// torn down while a late metrics tick still fires.
|
||||
decisionStateProbe: () => _processor?.CurrentState);
|
||||
CollectorSurfaces = surfaces;
|
||||
_collectorHosts = surfaces.Hosts;
|
||||
foreach (var host in _collectorHosts)
|
||||
|
||||
+53
-2
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
|
||||
@@ -56,6 +57,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly ITelemetrySpool? _telemetrySpool;
|
||||
private readonly Persistence.StartupEventGate? _startupGate;
|
||||
private readonly Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? _imeTrackerHealthProbe;
|
||||
// Read-only probe into the reducer's current DecisionState (stage + WG classifier
|
||||
// verdict) for the signal-pipeline block of agent_metrics_snapshot. DecisionState is
|
||||
// immutable, so a cross-thread reference read yields a consistent (possibly slightly
|
||||
// stale) snapshot — same access pattern the orchestrator's own Stop path uses.
|
||||
private readonly Func<DecisionState?>? _decisionStateProbe;
|
||||
|
||||
// Codex Finding 4 — reference to the concrete SignalIngress (when available) so we
|
||||
// can subscribe to its SignalPosted event. This lets us observe activity from
|
||||
@@ -90,9 +96,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string agentVersion,
|
||||
ITelemetrySpool? telemetrySpool = null,
|
||||
Persistence.StartupEventGate? startupGate = null,
|
||||
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null)
|
||||
Func<Monitoring.Enrollment.Ime.ImeTrackerHealth?>? imeTrackerHealthProbe = null,
|
||||
Func<DecisionState?>? decisionStateProbe = null)
|
||||
{
|
||||
_imeTrackerHealthProbe = imeTrackerHealthProbe;
|
||||
_decisionStateProbe = decisionStateProbe;
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_sessionId = sessionId;
|
||||
@@ -198,7 +206,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
_selfMetricsCollector = new AgentSelfMetricsCollector(
|
||||
_sessionId, _tenantId, _post, _networkMetrics, _logger, _agentVersion, _selfMetricsIntervalSeconds, _telemetrySpool,
|
||||
imeTrackerHealthProbe: _imeTrackerHealthProbe);
|
||||
imeTrackerHealthProbe: _imeTrackerHealthProbe,
|
||||
signalPipelineProbe: BuildSignalPipelineProbe());
|
||||
_selfMetricsCollector.Start();
|
||||
}
|
||||
_idleStopped = false;
|
||||
@@ -215,6 +224,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_selfMetricsCollector = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the signal-pipeline probe for the metrics snapshot from whatever is
|
||||
/// available: the concrete <see cref="SignalIngress"/> supplies the monotonic
|
||||
/// worker counters, the decision-state probe supplies stage + WG classifier
|
||||
/// verdict. Null only when NEITHER source exists (pure test fakes) — then the
|
||||
/// snapshot simply omits the block, same degrade pattern as the IME health probe.
|
||||
/// </summary>
|
||||
private Func<SignalPipelineHealth?>? BuildSignalPipelineProbe()
|
||||
{
|
||||
var ingress = _observableIngress;
|
||||
var stateProbe = _decisionStateProbe;
|
||||
if (ingress == null && stateProbe == null) return null;
|
||||
|
||||
return () =>
|
||||
{
|
||||
var health = new SignalPipelineHealth();
|
||||
if (ingress != null)
|
||||
{
|
||||
health.LastAssignedSignalOrdinal = ingress.LastAssignedSignalOrdinal;
|
||||
health.ProcessedSignalCount = ingress.ProcessedSignalCount;
|
||||
health.PendingSignalCount = ingress.PendingSignalCount;
|
||||
health.ReentrantPostCount = ingress.ReentrantPostCount;
|
||||
health.QueueLengthPeak = ingress.QueueLengthPeak;
|
||||
}
|
||||
var state = stateProbe?.Invoke();
|
||||
if (state != null)
|
||||
{
|
||||
health.DecisionStage = state.Stage.ToString();
|
||||
health.LastAppliedSignalOrdinal = state.LastAppliedSignalOrdinal;
|
||||
// Only after the classifier produced at least one verdict — otherwise
|
||||
// every non-WG session would carry a meaningless Unknown/0 pair.
|
||||
var sealing = state.ClassifierOutcomes?.WhiteGloveSealing;
|
||||
if (sealing?.LastClassifierVerdictId != null)
|
||||
{
|
||||
health.WhiteGloveSealingLevel = sealing.Level.ToString();
|
||||
health.WhiteGloveSealingScore = sealing.Score;
|
||||
}
|
||||
}
|
||||
return health;
|
||||
};
|
||||
}
|
||||
|
||||
private void OnRealEvent()
|
||||
{
|
||||
lock (_sync)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
@@ -47,6 +48,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// and the host's phase/event triggers degrade off (startup + interval rules still run).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="decisionStateProbe"/> is a read-only probe into the reducer's current
|
||||
/// immutable <see cref="AutopilotMonitor.DecisionCore.State.DecisionState"/> — it feeds the
|
||||
/// signal-pipeline block of <c>agent_metrics_snapshot</c> (stage + WG classifier verdict).
|
||||
/// Nullable; fakes pass null and the snapshot omits the state-derived fields.
|
||||
/// </remarks>
|
||||
CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
@@ -55,6 +62,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null);
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<AutopilotMonitor.DecisionCore.State.DecisionState?>? decisionStateProbe = null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// next channel item is taken. Only the worker enqueues and dequeues — no lock.
|
||||
private readonly Queue<IngressItem> _workerLocalQueue = new Queue<IngressItem>();
|
||||
private long _reentrantPostCount;
|
||||
private long _queueLengthPeak;
|
||||
private long _nextSignalOrdinal; // worker-thread-only after Start; seeded from SignalLog
|
||||
private int _started; // 0 = not started, 1 = running
|
||||
private int _stopRequested; // 0 = live, 1 = Stop called
|
||||
@@ -156,6 +157,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public long ReentrantPostCount => Interlocked.Read(ref _reentrantPostCount);
|
||||
|
||||
/// <summary>
|
||||
/// Total signals the worker has fully processed (reduce + effects). Monotonic — the
|
||||
/// agent_metrics_snapshot exports this so two snapshots prove whether the pipeline
|
||||
/// worked in between (a frozen worker keeps posting-side counters growing while this
|
||||
/// one stands still; a momentary queue-length sample could never show that).
|
||||
/// </summary>
|
||||
public long ProcessedSignalCount => Interlocked.Read(ref _processedCount);
|
||||
|
||||
/// <summary>
|
||||
/// Highest channel queue length observed at any Post since Start. Monotonic
|
||||
/// non-decreasing, so snapshot sampling cannot alias it away (same rationale as the
|
||||
/// spool's PeakPendingItemCount). Worker-local follow-up items never count here.
|
||||
/// </summary>
|
||||
public long QueueLengthPeak => Interlocked.Read(ref _queueLengthPeak);
|
||||
|
||||
/// <summary>
|
||||
/// Fires synchronously from <see cref="Post"/> after a signal has been accepted into
|
||||
/// the channel (for both the fast-path <see cref="BlockingCollection{T}.TryAdd"/> and
|
||||
@@ -317,6 +333,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
if (added)
|
||||
{
|
||||
UpdateQueueLengthPeak(_channel.Count);
|
||||
RaiseSignalPosted(kind, payload);
|
||||
return;
|
||||
}
|
||||
@@ -324,6 +341,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Slow path: bounded channel full → block, measure duration, notify observer throttled.
|
||||
var blockStartedUtc = _clock.UtcNow;
|
||||
int queueLenAtBlock = _channel.Count;
|
||||
UpdateQueueLengthPeak(queueLenAtBlock);
|
||||
try
|
||||
{
|
||||
_channel.Add(item);
|
||||
@@ -345,6 +363,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
RaiseSignalPosted(kind, payload);
|
||||
}
|
||||
|
||||
/// <summary>Lock-free monotonic max (net48 has no Interlocked max primitive).</summary>
|
||||
private void UpdateQueueLengthPeak(int observedLength)
|
||||
{
|
||||
long observed = observedLength;
|
||||
long current = Interlocked.Read(ref _queueLengthPeak);
|
||||
while (observed > current)
|
||||
{
|
||||
var prior = Interlocked.CompareExchange(ref _queueLengthPeak, observed, current);
|
||||
if (prior == current) return;
|
||||
current = prior;
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
// Snapshot the delegate once — standard pattern to tolerate concurrent subscribe /
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#nullable enable annotations
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Point-in-time read model of the signal/decision pipeline for the
|
||||
/// <c>agent_metrics_snapshot</c> event. Motivated by the 2026-09-01 WG-seal soak
|
||||
/// (memory: project_wg_seal_postfix_soak_residual): sessions whose DecisionEngine never
|
||||
/// sealed were telemetrically indistinguishable from a deliberate Weak classifier verdict,
|
||||
/// because the snapshots carried no signal-pipeline counters at all.
|
||||
/// <para>
|
||||
/// The counters are MONOTONIC totals, not momentary gauges — a snapshot samples the
|
||||
/// integral, so any two snapshots prove whether the worker ran in between (a frozen
|
||||
/// worker keeps <see cref="PendingSignalCount"/> growing while
|
||||
/// <see cref="ProcessedSignalCount"/> / <see cref="LastAppliedSignalOrdinal"/> stand
|
||||
/// still). Momentary queue length is deliberately exported only as a peak.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SignalPipelineHealth
|
||||
{
|
||||
/// <summary>Last SessionSignalOrdinal assigned by the ingress worker; −1 before the first signal.</summary>
|
||||
public long LastAssignedSignalOrdinal { get; set; }
|
||||
|
||||
/// <summary>Total signals fully processed (reduce + effects). Monotonic.</summary>
|
||||
public long ProcessedSignalCount { get; set; }
|
||||
|
||||
/// <summary>Signals accepted by Post but not yet fully processed (backlog gauge; pairs with the totals).</summary>
|
||||
public long PendingSignalCount { get; set; }
|
||||
|
||||
/// <summary>Worker-thread re-entrant posts routed via the worker-local queue. Monotonic.</summary>
|
||||
public long ReentrantPostCount { get; set; }
|
||||
|
||||
/// <summary>Highest channel queue length observed at any Post since Start. Monotonic non-decreasing.</summary>
|
||||
public long QueueLengthPeak { get; set; }
|
||||
|
||||
/// <summary>Current DecisionState stage name (e.g. "EspDeviceSetup", "WhiteGloveSealed"). Null when no state is available.</summary>
|
||||
public string? DecisionStage { get; set; }
|
||||
|
||||
/// <summary>The DecisionState's LastAppliedSignalOrdinal — the reducer-side twin of the ingress counters. Null when no state is available.</summary>
|
||||
public long? LastAppliedSignalOrdinal { get; set; }
|
||||
|
||||
/// <summary>Last WhiteGlove-sealing classifier level ("Weak", "Confirmed", …). Null until the classifier produced a verdict.</summary>
|
||||
public string? WhiteGloveSealingLevel { get; set; }
|
||||
|
||||
/// <summary>Score of the last WhiteGlove-sealing verdict. Null until the classifier produced a verdict.</summary>
|
||||
public int? WhiteGloveSealingScore { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -322,6 +322,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
["file"] = fileName ?? string.Empty,
|
||||
["oversizedLines"] = health.OversizedLines,
|
||||
["regexTimeouts"] = health.RegexTimeouts,
|
||||
["regexTimeoutRetries"] = health.RegexTimeoutRetries,
|
||||
["lineBudgetBreaks"] = health.BudgetBreaks,
|
||||
["heldTails"] = health.HeldTails,
|
||||
["linesRead"] = health.LinesRead,
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
private const string SourceLabel = "RealmJoinWatcher";
|
||||
private const string RegistryKeyHint = @"HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters";
|
||||
private const string UninstallRegistryKeyHint = @"HKLM\" + RealmJoinInfo.UninstallRealmJoinPath;
|
||||
|
||||
private readonly RealmJoinWatcher _watcher;
|
||||
private readonly ISignalIngressSink _ingress;
|
||||
@@ -39,6 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_watcher.RealmJoinPhaseChanged += OnPhaseChanged;
|
||||
_watcher.RealmJoinPackageStarted += OnPackageStarted;
|
||||
_watcher.RealmJoinPackageCompleted += OnPackageCompleted;
|
||||
_watcher.RealmJoinAutoUpdateDetected += OnAutoUpdateDetected;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -48,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_watcher.RealmJoinPhaseChanged -= OnPhaseChanged;
|
||||
_watcher.RealmJoinPackageStarted -= OnPackageStarted;
|
||||
_watcher.RealmJoinPackageCompleted -= OnPackageCompleted;
|
||||
_watcher.RealmJoinAutoUpdateDetected -= OnAutoUpdateDetected;
|
||||
}
|
||||
|
||||
// Test seams
|
||||
@@ -57,6 +60,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
internal void TriggerPhaseChangedFromTest(int prev, int curr) => OnPhaseChanged(this, new RealmJoinPhaseChangedEventArgs(prev, curr));
|
||||
internal void TriggerPackageStartedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageStarted(this, new RealmJoinPackageEventArgs(scope, snap));
|
||||
internal void TriggerPackageCompletedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageCompleted(this, new RealmJoinPackageEventArgs(scope, snap));
|
||||
internal void TriggerAutoUpdateDetectedFromTest(string previousVersion, string newVersion, string? releaseChannel) =>
|
||||
OnAutoUpdateDetected(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion, releaseChannel));
|
||||
|
||||
private void OnDetected(object? sender, RealmJoinDetectedEventArgs e)
|
||||
{
|
||||
@@ -177,6 +182,56 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
occurredAtUtc: _clock.UtcNow);
|
||||
}
|
||||
|
||||
private void OnAutoUpdateDetected(object? sender, RealmJoinAutoUpdateDetectedEventArgs e)
|
||||
{
|
||||
// Dual-emission: (1) the typed signal overrides the persisted version facts so
|
||||
// final-status / replay carry the build that ran the deployment; (2) the timeline
|
||||
// entry makes the self-update visible next to realmjoin_detected.
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = e.PreviousVersion,
|
||||
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = e.NewVersion,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(e.ReleaseChannel))
|
||||
{
|
||||
payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = e.ReleaseChannel!;
|
||||
}
|
||||
|
||||
_ingress.Post(
|
||||
kind: DecisionSignalKind.RealmJoinAutoUpdateDetected,
|
||||
occurredAtUtc: _clock.UtcNow,
|
||||
sourceOrigin: SourceLabel,
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Derived,
|
||||
identifier: "realmjoin-autoupdate-detected-v1",
|
||||
summary: $"RealmJoin self-update observed ({e.PreviousVersion} -> {e.NewVersion}, releaseChannel={e.ReleaseChannel ?? "<unknown>"})",
|
||||
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["registryKey"] = UninstallRegistryKeyHint,
|
||||
}),
|
||||
payload: payload);
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["previousVersion"] = e.PreviousVersion,
|
||||
["newVersion"] = e.NewVersion,
|
||||
["registryKey"] = UninstallRegistryKeyHint,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(e.ReleaseChannel))
|
||||
{
|
||||
data["releaseChannel"] = e.ReleaseChannel!;
|
||||
}
|
||||
var channelTail = string.IsNullOrEmpty(e.ReleaseChannel) ? string.Empty : $", channel={e.ReleaseChannel}";
|
||||
_post.Emit(
|
||||
eventType: SharedConstants.EventTypes.RealmJoinAutoUpdateDetected,
|
||||
source: SourceLabel,
|
||||
message: $"RealmJoin auto-updated {e.PreviousVersion} -> {e.NewVersion}{channelTail}",
|
||||
severity: EventSeverity.Info,
|
||||
immediateUpload: true,
|
||||
data: data,
|
||||
occurredAtUtc: _clock.UtcNow);
|
||||
}
|
||||
|
||||
private void OnPackageStarted(object? sender, RealmJoinPackageEventArgs e)
|
||||
{
|
||||
var displayName = TruncateDisplayName(e.DisplayName);
|
||||
@@ -236,24 +291,21 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = e.PackageId ?? string.Empty,
|
||||
// Same bound the reducer applies (FactStringBounds) — keeps the signal itself,
|
||||
// and therefore every upload carrying it, bounded at the source.
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = FactStringBounds.Bound(e.PackageId) ?? string.Empty,
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DisplayName] = displayName,
|
||||
[DecisionEngine.RealmJoinPayloadKeys.Scope] = e.Scope ?? RealmJoinPackageFact.ScopeMachine,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(e.Version))
|
||||
{
|
||||
payload[DecisionEngine.RealmJoinPayloadKeys.Version] = e.Version!;
|
||||
payload[DecisionEngine.RealmJoinPayloadKeys.Version] = FactStringBounds.Bound(e.Version)!;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static string TruncateDisplayName(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
return value!.Length <= RealmJoinPackageFact.MaxDisplayNameLength
|
||||
? value
|
||||
: value.Substring(0, RealmJoinPackageFact.MaxDisplayNameLength);
|
||||
}
|
||||
private static string TruncateDisplayName(string? value) =>
|
||||
FactStringBounds.Bound(value) ?? string.Empty;
|
||||
|
||||
private static Evidence BuildEvidence(string identifier, string summary) =>
|
||||
new Evidence(
|
||||
|
||||
@@ -579,6 +579,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
["entriesMatched"] = health.EntriesMatched,
|
||||
["oversizedLines"] = health.OversizedLines,
|
||||
["regexTimeouts"] = health.RegexTimeouts,
|
||||
["regexTimeoutRetries"] = health.RegexTimeoutRetries,
|
||||
["lineBudgetBreaks"] = health.BudgetBreaks,
|
||||
["heldTails"] = health.HeldTails,
|
||||
["unanchoredPatterns"] = health.UnanchoredPatterns,
|
||||
|
||||
@@ -837,5 +837,73 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.True(data.ContainsKey("realmjoinDetectedUtc"));
|
||||
Assert.True(data.ContainsKey("realmjoinResolvedUtc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoinAutoUpdateDetected_overrides_version_facts_without_touching_gate_or_deadline()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
|
||||
var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "canary",
|
||||
}));
|
||||
var deadlineBefore = Assert.Single(detected.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
|
||||
// Channel omitted (registry DisplayVersion fallback) — the Detected channel must survive.
|
||||
var updated = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.18",
|
||||
}));
|
||||
|
||||
Assert.Null(updated.Transition.DeadEndReason);
|
||||
Assert.Equal("4.21.18", updated.NewState.RealmJoinFacts.ProductVersion!.Value);
|
||||
Assert.Equal("canary", updated.NewState.RealmJoinFacts.ReleaseChannel!.Value);
|
||||
Assert.Equal(detected.NewState.Stage, updated.NewState.Stage);
|
||||
Assert.False(DecisionEngine.RealmJoinGateOpen(updated.NewState));
|
||||
var deadlineAfter = Assert.Single(updated.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Equal(deadlineBefore.DueAtUtc, deadlineAfter.DueAtUtc);
|
||||
Assert.Null(updated.NewState.RealmJoinFacts.LastActivityUtc);
|
||||
Assert.Empty(updated.Effects);
|
||||
|
||||
// Channel supplied — overrides too.
|
||||
var again = engine.Reduce(updated.NewState, MakeSignal(7, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(15),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.18",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.22.0",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "release",
|
||||
}));
|
||||
Assert.Equal("4.22.0", again.NewState.RealmJoinFacts.ProductVersion!.Value);
|
||||
Assert.Equal("release", again.NewState.RealmJoinFacts.ReleaseChannel!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoinAutoUpdateDetected_without_version_dead_ends_and_keeps_facts()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4",
|
||||
}));
|
||||
|
||||
var step = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4",
|
||||
}));
|
||||
|
||||
Assert.Equal("realmjoin_autoupdate_missing_version", step.Transition.DeadEndReason);
|
||||
Assert.Equal("4.21.4", step.NewState.RealmJoinFacts.ProductVersion!.Value);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
{
|
||||
/// <summary>
|
||||
/// Every payload-derived string that lands in <see cref="DecisionState"/> is bounded by
|
||||
/// <see cref="FactStringBounds"/> at the reducer boundary. The server-side semantic replay
|
||||
/// (<c>ReducerVerifier</c>) folds device-uploaded payloads through the same engine, so an
|
||||
/// unbounded string there is a per-request memory/CPU amplifier. These tests pin that the
|
||||
/// bound is applied BEFORE any dedupe comparison (so a forged multi-hundred-KB key neither
|
||||
/// costs a full-length compare nor lands in state) and that real-sized values pass through
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
public sealed class FactStringBoundsTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static string Long(char fill, int length, char last)
|
||||
=> new string(fill, length - 1) + last;
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helper
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", "")]
|
||||
[InlineData("generic-vlc", "generic-vlc")]
|
||||
public void Bound_passes_null_and_short_values_through(string? input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, FactStringBounds.Bound(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bound_truncates_to_MaxLength()
|
||||
{
|
||||
var bounded = FactStringBounds.Bound(new string('x', FactStringBounds.MaxLength + 1))!;
|
||||
Assert.Equal(FactStringBounds.MaxLength, bounded.Length);
|
||||
Assert.Equal(FactStringBounds.MaxLength, RealmJoinPackageFact.MaxDisplayNameLength);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- RealmJoinFacts
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_package_keys_are_bounded_before_dedupe_so_forged_near_duplicates_collapse()
|
||||
{
|
||||
// Two ~300 KB ids that differ only in the final character — the exploit shape.
|
||||
// After bounding both become the same 256-char key: one row, second start ignored.
|
||||
var idA = Long('a', 300_000, '1');
|
||||
var idB = Long('a', 300_000, '2');
|
||||
|
||||
var facts = RealmJoinFacts.Empty
|
||||
.WithPackageStarted(idA, "A", Long('v', 5000, '1'), RealmJoinPackageFact.ScopeMachine, T0)
|
||||
.WithPackageStarted(idB, "B", null, RealmJoinPackageFact.ScopeMachine, T0.AddSeconds(1));
|
||||
|
||||
var row = Assert.Single(facts.Packages);
|
||||
Assert.Equal(FactStringBounds.MaxLength, row.PackageId.Length);
|
||||
Assert.Equal(FactStringBounds.MaxLength, row.Version!.Length);
|
||||
Assert.Equal("A", row.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_package_completed_matches_started_row_through_the_bound()
|
||||
{
|
||||
var id = Long('b', 100_000, 'x');
|
||||
var facts = RealmJoinFacts.Empty
|
||||
.WithPackageStarted(id, "B", "1.0", RealmJoinPackageFact.ScopeUser, T0)
|
||||
.WithPackageCompleted(id, "B", "1.0", RealmJoinPackageFact.ScopeUser, T0.AddMinutes(1), success: true, lastExitCode: 0);
|
||||
|
||||
var row = Assert.Single(facts.Packages);
|
||||
Assert.True(row.Success);
|
||||
Assert.NotNull(row.CompletedUtc);
|
||||
Assert.Equal(RealmJoinPackageFact.ScopeUser, row.Scope);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("MACHINE")]
|
||||
[InlineData("forged-scope-value")]
|
||||
public void RealmJoin_scope_is_normalised_to_the_two_known_constants(string? scope)
|
||||
{
|
||||
var facts = RealmJoinFacts.Empty.WithPackageStarted("generic-vlc", "VLC", null, scope!, T0);
|
||||
Assert.Equal(RealmJoinPackageFact.ScopeMachine, Assert.Single(facts.Packages).Scope);
|
||||
Assert.Equal(RealmJoinPackageFact.ScopeUser, RealmJoinPackageFact.NormalizeScope("user"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_real_sized_values_are_stored_verbatim()
|
||||
{
|
||||
var facts = RealmJoinFacts.Empty.WithPackageStarted("generic-vlc", "VLC media player", "3.0.21.0", RealmJoinPackageFact.ScopeMachine, T0);
|
||||
var row = Assert.Single(facts.Packages);
|
||||
Assert.Equal("generic-vlc", row.PackageId);
|
||||
Assert.Equal("3.0.21.0", row.Version);
|
||||
Assert.Equal("VLC media player", row.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_replay_of_forged_package_stream_stays_cheap()
|
||||
{
|
||||
// 300 distinct ~200 KB ids against a full 200-row list. Unbounded, this was
|
||||
// ~300 × 200 × 200 KB byte compares (tens of seconds); bounded it is microseconds.
|
||||
// Generous ceiling — a regression is minutes, not milliseconds.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("bounds-sess", "bounds-tenant", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var i = 1; i <= 300; i++)
|
||||
{
|
||||
var id = new string('p', 200_000 - 8) + i.ToString("D8");
|
||||
state = engine.Reduce(state, MakeSignal(i, DecisionSignalKind.RealmJoinPackageStarted, T0.AddSeconds(i),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = id,
|
||||
[DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine,
|
||||
})).NewState;
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
// All 300 ids share their first 256 chars → they collapse to a single bounded row.
|
||||
var row = Assert.Single(state.RealmJoinFacts.Packages);
|
||||
Assert.Equal(FactStringBounds.MaxLength, row.PackageId.Length);
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"replay took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealmJoin_version_override_is_bounded()
|
||||
{
|
||||
var facts = RealmJoinFacts.Empty.WithVersionOverride(Long('v', 10_000, 'x'), Long('c', 10_000, 'y'), 7);
|
||||
Assert.Equal(FactStringBounds.MaxLength, facts.ProductVersion!.Value.Length);
|
||||
Assert.Equal(FactStringBounds.MaxLength, facts.ReleaseChannel!.Value.Length);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- AppInstallFacts
|
||||
|
||||
[Fact]
|
||||
public void AppInstall_failed_app_ids_are_bounded_before_dedupe()
|
||||
{
|
||||
var facts = AppInstallFacts.Empty
|
||||
.WithFailed(Long('a', 50_000, '1'))
|
||||
.WithFailed(Long('a', 50_000, '2'))
|
||||
.WithFailed("real-app-guid");
|
||||
|
||||
Assert.Equal(3, facts.FailedCount);
|
||||
Assert.Equal(2, facts.FailedAppIds.Count);
|
||||
Assert.Equal(FactStringBounds.MaxLength, facts.FailedAppIds[0].Length);
|
||||
Assert.Equal("real-app-guid", facts.FailedAppIds[1]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scalar facts
|
||||
|
||||
[Fact]
|
||||
public void EspAdvisoryFailureCategory_is_bounded()
|
||||
{
|
||||
var builder = DecisionState.CreateInitial("s", "t", T0).ToBuilder()
|
||||
.WithEspAdvisoryFailureRecorded(T0, 1, Long('c', 10_000, 'x'));
|
||||
Assert.Equal(FactStringBounds.MaxLength, builder.EspAdvisoryFailureCategory!.Value.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloOutcome_from_payload_is_bounded_by_the_engine()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("hello-sess", "t", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspExiting, T0.AddMinutes(3))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.HelloResolved, T0.AddMinutes(4),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = Long('h', 100_000, 'x') })).NewState;
|
||||
|
||||
Assert.NotNull(state.HelloOutcome);
|
||||
Assert.Equal(FactStringBounds.MaxLength, state.HelloOutcome!.Value.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeMatchedPatternId_from_payload_is_bounded_by_the_engine()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("ime-sess", "t", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(15, DecisionSignalKind.EspExiting, T0.AddMinutes(16.5))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(27),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = Long('i', 100_000, 'x') })).NewState;
|
||||
|
||||
Assert.NotNull(state.ImeMatchedPatternId);
|
||||
Assert.Equal(FactStringBounds.MaxLength, state.ImeMatchedPatternId!.Value.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Functions/Admin folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class AdminWireParityTests
|
||||
{
|
||||
private const string SampleTenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
|
||||
// ---- GetActiveUsers ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetActiveUsersResponse_matches_the_presence_listing_shape()
|
||||
{
|
||||
var lastSeen = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc);
|
||||
var anonymousUsers = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = SampleTenantId,
|
||||
upn = "admin@contoso.com",
|
||||
userRole = "GlobalAdmin",
|
||||
lastSeen,
|
||||
secondsAgo = 42
|
||||
},
|
||||
}.ToList();
|
||||
var typedUsers = new List<ActiveUserItem>
|
||||
{
|
||||
new ActiveUserItem
|
||||
{
|
||||
TenantId = SampleTenantId,
|
||||
Upn = "admin@contoso.com",
|
||||
UserRole = "GlobalAdmin",
|
||||
LastSeen = lastSeen,
|
||||
SecondsAgo = 42
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
windowMinutes = 5,
|
||||
activeCount = anonymousUsers.Count,
|
||||
users = anonymousUsers
|
||||
},
|
||||
new GetActiveUsersResponse
|
||||
{
|
||||
Success = true,
|
||||
WindowMinutes = 5,
|
||||
ActiveCount = typedUsers.Count,
|
||||
Users = typedUsers
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetAuditLogs / GetGlobalAuditLogs (non-paged + paged share one DTO) -------------
|
||||
|
||||
private static AuditLogEntry SampleAuditLog() => new AuditLogEntry
|
||||
{
|
||||
Id = "20260830120000000_0001",
|
||||
TenantId = SampleTenantId,
|
||||
Action = "CREATE",
|
||||
EntityType = "DeviceBlock",
|
||||
EntityId = "SN-0042",
|
||||
PerformedBy = "admin@contoso.com",
|
||||
Timestamp = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
Details = "{\"Action\":\"Block\"}",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AuditLogListResponse_matches_the_non_paged_shape_without_a_nextLink_key()
|
||||
{
|
||||
var logs = new List<AuditLogEntry> { SampleAuditLog() };
|
||||
|
||||
AssertParity(
|
||||
new { success = true, count = logs.Count, logs },
|
||||
new AuditLogListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = logs.Count,
|
||||
Logs = logs,
|
||||
// NextLink stays null → the key is absent, exactly like the old
|
||||
// non-paged literal that never declared it.
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLogListResponse_matches_the_paged_shape_with_a_nextLink()
|
||||
{
|
||||
IReadOnlyList<AuditLogEntry> items = new List<AuditLogEntry> { SampleAuditLog() };
|
||||
string? nextLink = "/api/audit/logs?pageSize=50&continuation=abc";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
logs = items,
|
||||
nextLink,
|
||||
},
|
||||
new AuditLogListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = items.Count,
|
||||
Logs = items,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLogListResponse_omits_a_null_nextLink_on_the_paged_path()
|
||||
{
|
||||
IReadOnlyList<AuditLogEntry> items = new List<AuditLogEntry>();
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
logs = items,
|
||||
nextLink,
|
||||
},
|
||||
new AuditLogListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = items.Count,
|
||||
Logs = items,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetOpsEvents (non-paged + paged share one DTO) ----------------------------------
|
||||
|
||||
private static OpsEventEntry SampleOpsEvent() => new OpsEventEntry
|
||||
{
|
||||
Id = "20260830113000000_0007",
|
||||
Category = "Maintenance",
|
||||
EventType = "maintenance_completed",
|
||||
Severity = "Info",
|
||||
TenantId = SampleTenantId,
|
||||
UserId = "admin@contoso.com",
|
||||
Message = "Maintenance run completed",
|
||||
Details = "{\"durationMs\":1200}",
|
||||
Timestamp = new DateTime(2026, 8, 30, 11, 30, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void OpsEventListResponse_matches_the_non_paged_shape_without_a_nextLink_key()
|
||||
{
|
||||
var filtered = new List<OpsEventEntry> { SampleOpsEvent() };
|
||||
|
||||
AssertParity(
|
||||
new { success = true, count = filtered.Count, events = filtered },
|
||||
new OpsEventListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = filtered.Count,
|
||||
Events = filtered,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpsEventListResponse_matches_the_paged_shape_with_a_nextLink()
|
||||
{
|
||||
var pageItems = new List<OpsEventEntry> { SampleOpsEvent() };
|
||||
string? nextLink = "/api/global/ops-events?pageSize=100&continuation=tok";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = pageItems.Count,
|
||||
events = pageItems,
|
||||
nextLink,
|
||||
},
|
||||
new OpsEventListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = pageItems.Count,
|
||||
Events = pageItems,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpsEventListResponse_omits_a_null_nextLink_on_the_paged_path()
|
||||
{
|
||||
var pageItems = new List<OpsEventEntry>();
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = pageItems.Count,
|
||||
events = pageItems,
|
||||
nextLink,
|
||||
},
|
||||
new OpsEventListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = pageItems.Count,
|
||||
Events = pageItems,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetAutopilotDeviceValidationConsentUrl ------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AutopilotConsentUrlResponse_matches_the_consent_url_shape()
|
||||
{
|
||||
var consentUrl = "https://login.example/6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d/adminconsent?client_id=x";
|
||||
var funnel = true;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
consentUrl,
|
||||
willAutoFlipHoming = funnel
|
||||
},
|
||||
new AutopilotConsentUrlResponse
|
||||
{
|
||||
ConsentUrl = consentUrl,
|
||||
WillAutoFlipHoming = funnel
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetAutopilotDeviceValidationConsentStatus ---------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AutopilotConsentStatusResponse_matches_the_consent_status_shape()
|
||||
{
|
||||
string? message = "Consent verified";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
isConsented = true,
|
||||
message,
|
||||
homingFlipped = false
|
||||
},
|
||||
new AutopilotConsentStatusResponse
|
||||
{
|
||||
IsConsented = true,
|
||||
Message = message,
|
||||
HomingFlipped = false
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutopilotConsentStatusResponse_omits_a_null_message()
|
||||
{
|
||||
string? message = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
isConsented = false,
|
||||
message,
|
||||
homingFlipped = false
|
||||
},
|
||||
new AutopilotConsentStatusResponse
|
||||
{
|
||||
IsConsented = false,
|
||||
Message = null,
|
||||
HomingFlipped = false
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetAutopilotDeviceValidationAccessCheck -----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AutopilotAccessCheckResponse_matches_the_access_check_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
accessPresent = true,
|
||||
isTransient = false,
|
||||
requiredPermission = "DeviceManagementServiceConfig.Read.All",
|
||||
homingFlipped = true,
|
||||
},
|
||||
new AutopilotAccessCheckResponse
|
||||
{
|
||||
AccessPresent = true,
|
||||
IsTransient = false,
|
||||
RequiredPermission = "DeviceManagementServiceConfig.Read.All",
|
||||
HomingFlipped = true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- BackfillOccurredUtc / ReclassifyLegacySessions (shared envelope) ----------------
|
||||
|
||||
[Fact]
|
||||
public void BackfillJobRunResponse_matches_the_backfill_shape()
|
||||
{
|
||||
var result = new BackfillResult
|
||||
{
|
||||
Table = "audit",
|
||||
DryRun = true,
|
||||
RowsExamined = 250,
|
||||
WouldWrite = 12,
|
||||
Written = 0,
|
||||
SkippedAlreadySet = 230,
|
||||
SkippedUndecodable = 8,
|
||||
Errors = 0,
|
||||
NextContinuation = "token123",
|
||||
};
|
||||
var userEmail = "admin@contoso.com";
|
||||
var triggeredAt = new DateTime(2026, 8, 30, 13, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
result,
|
||||
triggeredBy = userEmail,
|
||||
triggeredAt,
|
||||
},
|
||||
new BackfillJobRunResponse
|
||||
{
|
||||
Success = true,
|
||||
Result = result,
|
||||
TriggeredBy = userEmail,
|
||||
TriggeredAt = triggeredAt,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReclassifyJobRunResponse_matches_the_reclassify_shape()
|
||||
{
|
||||
var result = new ReclassificationResult
|
||||
{
|
||||
Mode = "legacy_timeouts",
|
||||
DryRun = false,
|
||||
TenantsExamined = 3,
|
||||
SessionsExamined = 120,
|
||||
WouldChange = 0,
|
||||
Changed = 7,
|
||||
ToSucceeded = 5,
|
||||
ToIncomplete = 2,
|
||||
KeptFailed = 110,
|
||||
Skipped = 3,
|
||||
Errors = 0,
|
||||
CapReached = false,
|
||||
};
|
||||
var userEmail = "admin@contoso.com";
|
||||
var triggeredAt = new DateTime(2026, 8, 30, 13, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
result,
|
||||
triggeredBy = userEmail,
|
||||
triggeredAt,
|
||||
},
|
||||
new ReclassifyJobRunResponse
|
||||
{
|
||||
Success = true,
|
||||
Result = result,
|
||||
TriggeredBy = userEmail,
|
||||
TriggeredAt = triggeredAt,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- TriggerMaintenance --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TriggerMaintenanceResponse_matches_the_manual_trigger_shape()
|
||||
{
|
||||
var result = new MaintenanceResult
|
||||
{
|
||||
Success = true,
|
||||
TriggeredBy = "admin@contoso.com",
|
||||
TriggeredAt = new DateTime(2026, 8, 30, 13, 10, 0, DateTimeKind.Utc),
|
||||
DurationMs = 1234,
|
||||
StalledSessionsChecked = true,
|
||||
MetricsAggregated = true,
|
||||
AggregatedDate = "2026-08-29",
|
||||
DataCleanupExecuted = false,
|
||||
PlatformStatsRecomputed = true,
|
||||
DevicesBlockedForExcessiveData = 0,
|
||||
ContactEmailsBackfilled = 2,
|
||||
};
|
||||
var userEmail = "admin@contoso.com";
|
||||
var triggeredAt = new DateTime(2026, 8, 30, 13, 10, 5, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Maintenance tasks completed",
|
||||
result = result,
|
||||
triggeredBy = userEmail,
|
||||
triggeredAt
|
||||
},
|
||||
new TriggerMaintenanceResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Maintenance tasks completed",
|
||||
Result = result,
|
||||
TriggeredBy = userEmail,
|
||||
TriggeredAt = triggeredAt
|
||||
});
|
||||
}
|
||||
|
||||
// ---- CustomsArchive (list runs / list entries / get entry / delete run) --------------
|
||||
// The run/entry item classes moved verbatim (same property names + declaration order)
|
||||
// from CustomsArchiveQueryFunction.RunSummary/EntrySummary into the Shared models, so
|
||||
// the same instances serve as the item payload on both sides of the envelope proof.
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveRunListResponse_matches_the_run_listing_shape()
|
||||
{
|
||||
var runs = new List<CustomsArchiveRunSummary>
|
||||
{
|
||||
new CustomsArchiveRunSummary
|
||||
{
|
||||
PartitionKey = $"{SampleTenantId}_20260829120000",
|
||||
TenantId = SampleTenantId,
|
||||
HistoryRowKey = "20260829120000",
|
||||
ArchivedAt = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc),
|
||||
GatherRulesCount = 4,
|
||||
AnalyzeRulesCount = 2,
|
||||
ImeLogPatternsCount = 1,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, count = runs.Count, runs },
|
||||
new CustomsArchiveRunListResponse { Success = true, Count = runs.Count, Runs = runs });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveEntryListResponse_matches_the_entry_listing_shape()
|
||||
{
|
||||
var items = new List<CustomsArchiveEntrySummary>
|
||||
{
|
||||
new CustomsArchiveEntrySummary
|
||||
{
|
||||
PartitionKey = $"{SampleTenantId}_20260829120000",
|
||||
RowKey = "GatherRules_R0FUSEVSLTE",
|
||||
OriginalTable = "GatherRules",
|
||||
OriginalRowKey = "GATHER-1",
|
||||
ArchivedAt = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc),
|
||||
EntityJsonPreview = "{\"RuleId\":\"GATHER-1\"}",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, count = items.Count, entries = items },
|
||||
new CustomsArchiveEntryListResponse { Success = true, Count = items.Count, Entries = items });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveEntryResponse_matches_the_full_entry_shape()
|
||||
{
|
||||
var entry = new TenantOffboardingCustomsArchiveEntry
|
||||
{
|
||||
PartitionKey = $"{SampleTenantId}_20260829120000",
|
||||
RowKey = "AnalyzeRules_QU5BTFlaRS0x",
|
||||
TenantId = SampleTenantId,
|
||||
OriginalTable = "AnalyzeRules",
|
||||
OriginalPartitionKey = SampleTenantId,
|
||||
OriginalRowKey = "ANALYZE-1",
|
||||
EntityJson = "{\"RuleId\":\"ANALYZE-1\",\"Name\":\"Sample\"}",
|
||||
HistoryRowKey = "20260829120000",
|
||||
ArchivedAt = new DateTime(2026, 8, 29, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, entry },
|
||||
new CustomsArchiveEntryResponse { Success = true, Entry = entry });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveDeleteEntry_success_body_matches_SuccessOnlyResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomsArchiveDeleteRunResponse_matches_the_bulk_delete_shape()
|
||||
{
|
||||
var deleted = 7;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, deleted },
|
||||
new CustomsArchiveDeleteRunResponse { Success = true, Deleted = deleted });
|
||||
}
|
||||
|
||||
// ---- DelegatedAdminManagement --------------------------------------------------------
|
||||
|
||||
private static DelegatedAdminEntry SampleDelegatedAdmin() => new DelegatedAdminEntry
|
||||
{
|
||||
Upn = "msp@fabrikam.com",
|
||||
TenantId = SampleTenantId,
|
||||
Role = "DelegatedReader",
|
||||
IsEnabled = true,
|
||||
Status = "Active",
|
||||
Source = "OperatorGranted",
|
||||
GrantedAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
|
||||
GrantedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void DelegatedAdminListResponse_matches_the_assignments_shape()
|
||||
{
|
||||
var assignments = new List<DelegatedAdminEntry> { SampleDelegatedAdmin() };
|
||||
|
||||
AssertParity(
|
||||
new { assignments },
|
||||
new DelegatedAdminListResponse { Assignments = assignments });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DelegatedAdminGrantResponse_matches_the_single_assignment_shape()
|
||||
{
|
||||
var entry = SampleDelegatedAdmin();
|
||||
|
||||
AssertParity(
|
||||
new { assignment = entry },
|
||||
new DelegatedAdminGrantResponse { Assignment = entry });
|
||||
}
|
||||
|
||||
// ---- DeviceBlock / GetAllBlockedDevices ----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BlockedDeviceListResponse_matches_the_blocked_listing_shape()
|
||||
{
|
||||
var blocked = new List<BlockedDeviceEntry>
|
||||
{
|
||||
new BlockedDeviceEntry
|
||||
{
|
||||
TenantId = SampleTenantId,
|
||||
SerialNumber = "SN-0042",
|
||||
BlockedAt = new DateTime(2026, 8, 30, 8, 0, 0, DateTimeKind.Utc),
|
||||
UnblockAt = new DateTime(2026, 8, 30, 20, 0, 0, DateTimeKind.Utc),
|
||||
BlockedByEmail = "admin@contoso.com",
|
||||
DurationHours = 12,
|
||||
Reason = "Excessive data",
|
||||
Action = "Block",
|
||||
BlockedSessionIds = null,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, blocked },
|
||||
new BlockedDeviceListResponse { Success = true, Blocked = blocked });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockDeviceResponse_matches_the_block_acknowledgement_shape()
|
||||
{
|
||||
var serialNumber = "SN-0042";
|
||||
var durationHours = 12;
|
||||
var isKill = false;
|
||||
var action = "Block";
|
||||
var unblockAt = new DateTime(2026, 8, 30, 20, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = isKill
|
||||
? $"Device {serialNumber} issued remote kill signal for {durationHours} hours."
|
||||
: $"Device {serialNumber} blocked for {durationHours} hours.",
|
||||
unblockAt,
|
||||
action
|
||||
},
|
||||
new BlockDeviceResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Device {serialNumber} blocked for {durationHours} hours.",
|
||||
UnblockAt = unblockAt,
|
||||
Action = action
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnblockDevice_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var serialNumber = "SN-0042";
|
||||
|
||||
AssertParity(
|
||||
new { success = true, message = $"Device {serialNumber} unblocked." },
|
||||
new SuccessMessageResponse { Success = true, Message = $"Device {serialNumber} unblocked." });
|
||||
}
|
||||
|
||||
// ---- EmailTemplates ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateResponse_matches_the_effective_template_shape_with_an_override()
|
||||
{
|
||||
string? updatedBy = "admin@contoso.com";
|
||||
DateTime? updatedUtc = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
kind = "welcome",
|
||||
subject = "Welcome to Autopilot Monitor",
|
||||
isOverridden = true,
|
||||
html = "<html>override</html>",
|
||||
builtInHtml = "<html>{{domainName}}</html>",
|
||||
updatedBy,
|
||||
updatedUtc,
|
||||
placeholder = "{{domainName}}",
|
||||
maxLength = 30_000,
|
||||
},
|
||||
new EmailTemplateResponse
|
||||
{
|
||||
Kind = "welcome",
|
||||
Subject = "Welcome to Autopilot Monitor",
|
||||
IsOverridden = true,
|
||||
Html = "<html>override</html>",
|
||||
BuiltInHtml = "<html>{{domainName}}</html>",
|
||||
UpdatedBy = updatedBy,
|
||||
UpdatedUtc = updatedUtc,
|
||||
Placeholder = "{{domainName}}",
|
||||
MaxLength = 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateResponse_omits_updatedBy_and_updatedUtc_without_an_override()
|
||||
{
|
||||
string? updatedBy = null;
|
||||
DateTime? updatedUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
kind = "farewell",
|
||||
subject = "Farewell from Autopilot Monitor",
|
||||
isOverridden = false,
|
||||
html = "<html>{{domainName}}</html>",
|
||||
builtInHtml = "<html>{{domainName}}</html>",
|
||||
updatedBy,
|
||||
updatedUtc,
|
||||
placeholder = "{{domainName}}",
|
||||
maxLength = 30_000,
|
||||
},
|
||||
new EmailTemplateResponse
|
||||
{
|
||||
Kind = "farewell",
|
||||
Subject = "Farewell from Autopilot Monitor",
|
||||
IsOverridden = false,
|
||||
Html = "<html>{{domainName}}</html>",
|
||||
BuiltInHtml = "<html>{{domainName}}</html>",
|
||||
UpdatedBy = null,
|
||||
UpdatedUtc = null,
|
||||
Placeholder = "{{domainName}}",
|
||||
MaxLength = 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateSaveResponse_matches_the_save_acknowledgement_shape()
|
||||
{
|
||||
var updatedUtc = new DateTime(2026, 8, 30, 10, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new { kind = "welcome", isOverridden = true, updatedBy = "admin@contoso.com", updatedUtc },
|
||||
new EmailTemplateSaveResponse { Kind = "welcome", IsOverridden = true, UpdatedBy = "admin@contoso.com", UpdatedUtc = updatedUtc });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateResetResponse_matches_the_reset_acknowledgement_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new { kind = "farewell", isOverridden = false },
|
||||
new EmailTemplateResetResponse { Kind = "farewell", IsOverridden = false });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmailTemplateTestSendResponse_matches_the_test_send_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new { sentTo = "it@contoso.com", domainName = "contoso.com", draft = true },
|
||||
new EmailTemplateTestSendResponse { SentTo = "it@contoso.com", DomainName = "contoso.com", Draft = true });
|
||||
}
|
||||
|
||||
// ---- IdentityBindingManagement -------------------------------------------------------
|
||||
|
||||
private static AdminIdentityBinding SampleBinding() => new AdminIdentityBinding
|
||||
{
|
||||
Upn = "msp@fabrikam.com",
|
||||
TenantId = "0a0a35a2-30b2-4f2f-9a1b-6d9f1a2b3c01",
|
||||
ObjectId = "b1b1b1b1-1111-4222-8333-444455556666",
|
||||
BoundBy = "admin@contoso.com",
|
||||
BoundAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
|
||||
ObjectIdPinnedAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void IdentityBindingListResponse_matches_the_bindings_shape()
|
||||
{
|
||||
var bindings = new List<AdminIdentityBinding> { SampleBinding() };
|
||||
|
||||
AssertParity(
|
||||
new { bindings },
|
||||
new IdentityBindingListResponse { Bindings = bindings });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityBindingResponse_matches_the_single_binding_shape()
|
||||
{
|
||||
var binding = SampleBinding();
|
||||
|
||||
AssertParity(
|
||||
new { binding },
|
||||
new IdentityBindingResponse { Binding = binding });
|
||||
}
|
||||
|
||||
// ---- ReseedFromGitHub ----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ReseedFromGitHubResponse_matches_the_reseed_report_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Reseed from GitHub complete",
|
||||
gather = new { deleted = 10, written = 12, orphanStatesGcd = 1, sunsetSkipped = 0 },
|
||||
analyze = new { deleted = 8, written = 8, orphanStatesGcd = 0, sunsetSkipped = 1 },
|
||||
ime = new { deleted = 79, written = 79 },
|
||||
cpeCommunityMappings = new { deleted = 5, written = 6 },
|
||||
cpeSeedMappings = new { deleted = 100, written = 100 }
|
||||
},
|
||||
new ReseedFromGitHubResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Reseed from GitHub complete",
|
||||
Gather = new ReseedRuleCountsNode { Deleted = 10, Written = 12, OrphanStatesGcd = 1, SunsetSkipped = 0 },
|
||||
Analyze = new ReseedRuleCountsNode { Deleted = 8, Written = 8, OrphanStatesGcd = 0, SunsetSkipped = 1 },
|
||||
Ime = new ReseedTableCountsNode { Deleted = 79, Written = 79 },
|
||||
CpeCommunityMappings = new ReseedTableCountsNode { Deleted = 5, Written = 6 },
|
||||
CpeSeedMappings = new ReseedTableCountsNode { Deleted = 100, Written = 100 }
|
||||
});
|
||||
}
|
||||
|
||||
// ---- SubmitOffboardingFeedback -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SubmitOffboardingFeedback_success_body_matches_SuccessOnlyResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
|
||||
// ---- TenantAdminManagement (AddTenantAdmin) ------------------------------------------
|
||||
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): the row is TenantAdminRow, not
|
||||
// the raw TenantAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
|
||||
// These pins fix the NEW shape exactly (key names, order, table-key absence).
|
||||
|
||||
[Fact]
|
||||
public void TenantAdminCreatedResponse_pins_the_created_member_shape()
|
||||
{
|
||||
var newAdmin = new TenantAdminRow
|
||||
{
|
||||
TenantId = SampleTenantId,
|
||||
Upn = "operator@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 30, 14, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
Role = "Operator",
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
"{\"admin\":{"
|
||||
+ $"\"tenantId\":\"{SampleTenantId}\","
|
||||
+ "\"upn\":\"operator@contoso.com\","
|
||||
+ "\"isEnabled\":true,"
|
||||
+ "\"addedDate\":\"2026-08-30T14:00:00Z\","
|
||||
+ "\"addedBy\":\"admin@contoso.com\","
|
||||
+ "\"role\":\"Operator\","
|
||||
+ "\"canManageBootstrapTokens\":false}}",
|
||||
TestWire.Serialize(new TenantAdminCreatedResponse { Admin = newAdmin }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantAdminRow_omits_a_null_role_and_never_emits_table_keys()
|
||||
{
|
||||
// Legacy pre-role rows have Role = null → the key vanishes (WhenWritingNull), and the
|
||||
// ITableEntity keys of the storage entity must never reappear on the wire.
|
||||
var row = new TenantAdminRow
|
||||
{
|
||||
TenantId = SampleTenantId,
|
||||
Upn = "legacy@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 30, 14, 5, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
Role = null,
|
||||
};
|
||||
|
||||
var json = TestWire.Serialize(row);
|
||||
Assert.Equal(
|
||||
$"{{\"tenantId\":\"{SampleTenantId}\","
|
||||
+ "\"upn\":\"legacy@contoso.com\","
|
||||
+ "\"isEnabled\":true,"
|
||||
+ "\"addedDate\":\"2026-08-30T14:05:00Z\","
|
||||
+ "\"addedBy\":\"admin@contoso.com\","
|
||||
+ "\"canManageBootstrapTokens\":false}",
|
||||
json);
|
||||
Assert.DoesNotContain("partitionKey", json);
|
||||
Assert.DoesNotContain("rowKey", json);
|
||||
Assert.DoesNotContain("eTag", json);
|
||||
Assert.DoesNotContain("timestamp", json);
|
||||
}
|
||||
|
||||
// ---- TenantGroupManagement -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TenantGroupListResponse_matches_the_groups_shape()
|
||||
{
|
||||
var groups = new List<TenantGroup>
|
||||
{
|
||||
new TenantGroup
|
||||
{
|
||||
GroupId = "c2c2c2c2-2222-4333-8444-555566667777",
|
||||
Name = "EU Customers",
|
||||
CreatedBy = "admin@contoso.com",
|
||||
CreatedAt = new DateTime(2026, 8, 27, 8, 0, 0, DateTimeKind.Utc),
|
||||
TenantIds = new List<string> { SampleTenantId },
|
||||
AssigneeCount = 1,
|
||||
Assignees = new List<TenantGroupAssignment>
|
||||
{
|
||||
new TenantGroupAssignment
|
||||
{
|
||||
Upn = "msp@fabrikam.com",
|
||||
GroupId = "c2c2c2c2-2222-4333-8444-555566667777",
|
||||
Role = "DelegatedReader",
|
||||
IsEnabled = true,
|
||||
AssignedBy = "admin@contoso.com",
|
||||
AssignedAt = new DateTime(2026, 8, 27, 8, 5, 0, DateTimeKind.Utc),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { groups },
|
||||
new TenantGroupListResponse { Groups = groups });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTenantGroupResponse_matches_the_created_group_shape()
|
||||
{
|
||||
var groupId = "c2c2c2c2-2222-4333-8444-555566667777";
|
||||
var name = "EU Customers";
|
||||
|
||||
AssertParity(
|
||||
new { groupId, name },
|
||||
new CreateTenantGroupResponse { GroupId = groupId, Name = name });
|
||||
}
|
||||
|
||||
// ---- VersionBlock --------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void BlockedVersionListResponse_matches_the_rules_shape()
|
||||
{
|
||||
var rules = new List<BlockedVersionEntry>
|
||||
{
|
||||
new BlockedVersionEntry
|
||||
{
|
||||
VersionPattern = "1.4.*",
|
||||
Action = "Block",
|
||||
CreatedByEmail = "admin@contoso.com",
|
||||
CreatedAt = new DateTime(2026, 8, 30, 7, 0, 0, DateTimeKind.Utc),
|
||||
Reason = "Known crash loop",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, rules },
|
||||
new BlockedVersionListResponse { Success = true, Rules = rules });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockVersionResponse_matches_the_block_acknowledgement_shape()
|
||||
{
|
||||
var versionPattern = "1.4.*";
|
||||
var normalizedAction = "Kill";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Version pattern '{versionPattern}' set to {normalizedAction}.",
|
||||
versionPattern,
|
||||
action = normalizedAction
|
||||
},
|
||||
new BlockVersionResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Version pattern '{versionPattern}' set to {normalizedAction}.",
|
||||
VersionPattern = versionPattern,
|
||||
Action = normalizedAction
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnblockVersion_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var versionPattern = "1.4.*";
|
||||
|
||||
AssertParity(
|
||||
new { success = true, message = $"Version pattern '{versionPattern}' unblocked." },
|
||||
new SuccessMessageResponse { Success = true, Message = $"Version pattern '{versionPattern}' unblocked." });
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Annotations function folder (GetSessionAnnotations,
|
||||
/// ListSessionAnnotations, ListTenantSessionAnnotations, UpsertSessionAnnotation).
|
||||
/// The anonymous literals below are copied verbatim from the pre-migration call sites
|
||||
/// (including the item shapes built by AnnotationWire.ToWire/ToWireWithScope) and act as
|
||||
/// the independent fixture against the new typed DTOs.
|
||||
/// </summary>
|
||||
public class AnnotationsWireParityTests
|
||||
{
|
||||
private static readonly DateTime Created = new DateTime(2026, 8, 29, 9, 15, 42, DateTimeKind.Utc);
|
||||
private static readonly DateTime Updated = new DateTime(2026, 8, 30, 11, 22, 33, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void GetSessionAnnotations_success_shape_is_wire_identical()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
annotations = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
lane = "operator",
|
||||
verdict = (string?)"root_cause_confirmed",
|
||||
note = (string?)"Driver package blocked ESP; matches APP-017.",
|
||||
authorUpn = "ops@contoso.com",
|
||||
authorDisplayName = "Contoso Ops",
|
||||
createdByUpn = "ops@contoso.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string> { "APP-017", "NET-001" },
|
||||
},
|
||||
},
|
||||
writableLanes = new List<string> { "operator", "tenantadmin" },
|
||||
},
|
||||
new GetSessionAnnotationsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
Annotations = new List<SessionAnnotationItem>
|
||||
{
|
||||
new SessionAnnotationItem
|
||||
{
|
||||
Lane = "operator",
|
||||
Verdict = "root_cause_confirmed",
|
||||
Note = "Driver package blocked ESP; matches APP-017.",
|
||||
AuthorUpn = "ops@contoso.com",
|
||||
AuthorDisplayName = "Contoso Ops",
|
||||
CreatedByUpn = "ops@contoso.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string> { "APP-017", "NET-001" },
|
||||
},
|
||||
},
|
||||
WritableLanes = new List<string> { "operator", "tenantadmin" },
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionAnnotations_null_verdict_and_note_vanish_identically()
|
||||
{
|
||||
// A lane row can be verdict-only or note-only; null slots must drop their keys
|
||||
// on both sides (WhenWritingNull). Also covers the empty-annotations page.
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
annotations = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
lane = "tenantadmin",
|
||||
verdict = (string?)null,
|
||||
note = (string?)null,
|
||||
authorUpn = "admin@fabrikam.com",
|
||||
authorDisplayName = "Fabrikam Admin",
|
||||
createdByUpn = "admin@fabrikam.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
writableLanes = new List<string>(),
|
||||
},
|
||||
new GetSessionAnnotationsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
Annotations = new List<SessionAnnotationItem>
|
||||
{
|
||||
new SessionAnnotationItem
|
||||
{
|
||||
Lane = "tenantadmin",
|
||||
Verdict = null,
|
||||
Note = null,
|
||||
AuthorUpn = "admin@fabrikam.com",
|
||||
AuthorDisplayName = "Fabrikam Admin",
|
||||
CreatedByUpn = "admin@fabrikam.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
WritableLanes = new List<string>(),
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionAnnotationList_success_shape_is_wire_identical()
|
||||
{
|
||||
// Shared by ListSessionAnnotations (global) and ListTenantSessionAnnotations —
|
||||
// both sites emit { success, count, annotations(scoped), nextLink }.
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = 1,
|
||||
annotations = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
lane = "globaladmin",
|
||||
verdict = (string?)"analysis_wrong",
|
||||
note = (string?)"Rule fired on the wrong phase.",
|
||||
authorUpn = "ga@contoso.com",
|
||||
authorDisplayName = "Platform GA",
|
||||
createdByUpn = "ga@contoso.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string> { "ESP-004" },
|
||||
},
|
||||
},
|
||||
nextLink = (string?)"/api/global/session-annotations?pageSize=50&continuation=abc123",
|
||||
},
|
||||
new SessionAnnotationListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = 1,
|
||||
Annotations = new List<SessionAnnotationScopedItem>
|
||||
{
|
||||
new SessionAnnotationScopedItem
|
||||
{
|
||||
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
Lane = "globaladmin",
|
||||
Verdict = "analysis_wrong",
|
||||
Note = "Rule fired on the wrong phase.",
|
||||
AuthorUpn = "ga@contoso.com",
|
||||
AuthorDisplayName = "Platform GA",
|
||||
CreatedByUpn = "ga@contoso.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string> { "ESP-004" },
|
||||
},
|
||||
},
|
||||
NextLink = "/api/global/session-annotations?pageSize=50&continuation=abc123",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionAnnotationList_null_nextLink_and_null_item_slots_vanish_identically()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = 1,
|
||||
annotations = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
sessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
lane = "operator",
|
||||
verdict = (string?)null,
|
||||
note = (string?)"Note-only annotation.",
|
||||
authorUpn = "ops@fabrikam.com",
|
||||
authorDisplayName = "Fabrikam Ops",
|
||||
createdByUpn = "ops@fabrikam.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
nextLink = (string?)null,
|
||||
},
|
||||
new SessionAnnotationListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = 1,
|
||||
Annotations = new List<SessionAnnotationScopedItem>
|
||||
{
|
||||
new SessionAnnotationScopedItem
|
||||
{
|
||||
TenantId = "0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0",
|
||||
SessionId = "6f0a2d9c-1b3e-4c8f-9d21-7e5a4b6c8d90",
|
||||
Lane = "operator",
|
||||
Verdict = null,
|
||||
Note = "Note-only annotation.",
|
||||
AuthorUpn = "ops@fabrikam.com",
|
||||
AuthorDisplayName = "Fabrikam Ops",
|
||||
CreatedByUpn = "ops@fabrikam.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertSessionAnnotation_deleted_shape_is_wire_identical()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, deleted = true },
|
||||
new UpsertSessionAnnotationDeletedResponse { Success = true, Deleted = true });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertSessionAnnotation_success_shape_is_wire_identical()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
annotation = (object)new
|
||||
{
|
||||
lane = "operator",
|
||||
verdict = (string?)"different_problem",
|
||||
note = (string?)"Actually a proxy issue, not the app.",
|
||||
authorUpn = "ops@contoso.com",
|
||||
authorDisplayName = "Contoso Ops",
|
||||
createdByUpn = "admin@contoso.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Updated,
|
||||
ruleIds = new List<string> { "NET-001" },
|
||||
},
|
||||
},
|
||||
new UpsertSessionAnnotationResponse
|
||||
{
|
||||
Success = true,
|
||||
Annotation = new SessionAnnotationItem
|
||||
{
|
||||
Lane = "operator",
|
||||
Verdict = "different_problem",
|
||||
Note = "Actually a proxy issue, not the app.",
|
||||
AuthorUpn = "ops@contoso.com",
|
||||
AuthorDisplayName = "Contoso Ops",
|
||||
CreatedByUpn = "admin@contoso.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Updated,
|
||||
RuleIds = new List<string> { "NET-001" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpsertSessionAnnotation_verdict_only_null_note_vanishes_identically()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
annotation = (object)new
|
||||
{
|
||||
lane = "tenantadmin",
|
||||
verdict = (string?)"inconclusive",
|
||||
note = (string?)null,
|
||||
authorUpn = "admin@fabrikam.com",
|
||||
authorDisplayName = "Fabrikam Admin",
|
||||
createdByUpn = "admin@fabrikam.com",
|
||||
createdAtUtc = Created,
|
||||
updatedAtUtc = Created,
|
||||
ruleIds = new List<string>(),
|
||||
},
|
||||
},
|
||||
new UpsertSessionAnnotationResponse
|
||||
{
|
||||
Success = true,
|
||||
Annotation = new SessionAnnotationItem
|
||||
{
|
||||
Lane = "tenantadmin",
|
||||
Verdict = "inconclusive",
|
||||
Note = null,
|
||||
AuthorUpn = "admin@fabrikam.com",
|
||||
AuthorDisplayName = "Fabrikam Admin",
|
||||
CreatedByUpn = "admin@fabrikam.com",
|
||||
CreatedAtUtc = Created,
|
||||
UpdatedAtUtc = Created,
|
||||
RuleIds = new List<string>(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the anonymous-object → typed-DTO migration: for every converted
|
||||
/// call site there is a case here serializing the OLD anonymous literal (copied from the diff)
|
||||
/// and the NEW DTO with the same values, comparing the JSON strings ordinally — key names,
|
||||
/// key ORDER and presence/absence (WhenWritingNull) all must match, because MCP hands raw
|
||||
/// response JSON to an LLM. Each site gets a populated case and, where a slot can be null at
|
||||
/// runtime, a null-slot case (the key must vanish identically on both sides).
|
||||
/// </summary>
|
||||
public class ApiResponseWireParityTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions WireOptions = ApiJsonOptions.Create();
|
||||
|
||||
/// <summary>
|
||||
/// Serialize both shapes with the production wire options and compare ordinally.
|
||||
/// The typed side serializes as its runtime type — serializing as IApiResponse would
|
||||
/// emit no properties at all.
|
||||
/// </summary>
|
||||
internal static void AssertWireIdentical(object anonymousLiteral, IApiResponse typed)
|
||||
{
|
||||
var expected = JsonSerializer.Serialize(anonymousLiteral, anonymousLiteral.GetType(), WireOptions);
|
||||
var actual = JsonSerializer.Serialize(typed, typed.GetType(), WireOptions);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessMessageResponse_matches_the_canonical_anonymous_shape()
|
||||
{
|
||||
AssertWireIdentical(
|
||||
new { success = true, message = "Rule created" },
|
||||
new SuccessMessageResponse { Success = true, Message = "Rule created" });
|
||||
|
||||
AssertWireIdentical(
|
||||
new { success = false, message = "Failed to create rule" },
|
||||
new SuccessMessageResponse { Success = false, Message = "Failed to create rule" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessOnlyResponse_matches_the_canonical_anonymous_shape()
|
||||
{
|
||||
AssertWireIdentical(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -79,13 +79,7 @@ public class AppHomingServiceTests
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
|
||||
.Returns(Task.CompletedTask);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfigMock.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfigMock.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
_sut = new AppHomingService(
|
||||
|
||||
+4
-4
@@ -265,7 +265,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
Summary("Company Portal", status: "Failed", collision: true),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
|
||||
var root = TestWire.SerializeToElement(MetricsMath.BuildAppMetricsPayload(summaries));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("totalApps").GetInt32()); // collided name forms no group
|
||||
Assert.Equal(4, root.GetProperty("totalInstalls").GetInt32()); // raw row count unchanged
|
||||
@@ -282,7 +282,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
Summary("Company Portal", collision: true),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var apps = root.GetProperty("apps").EnumerateArray().ToList();
|
||||
@@ -305,7 +305,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
Summary("Company Portal", status: "Failed", collision: true), // the second identity's failure
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
@@ -324,7 +324,7 @@ public class AppInstallIdentityAndEspBlockingTests
|
||||
|
||||
var summaries = new List<AppInstallSummary> { Summary("Company Portal", collision: true) };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, repo.Object, "Company Portal", days: 30));
|
||||
|
||||
Assert.Equal(1, root.GetProperty("collisionExcluded").GetInt32());
|
||||
|
||||
@@ -363,7 +363,7 @@ public class AppVersionRegressionRadarTests
|
||||
Install("1.0", 50, status: "Failed"), // failed — excluded from durations
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
rows, sessionRepo.Object, App, days: 30));
|
||||
|
||||
var version = root.GetProperty("versionBreakdown")[0];
|
||||
@@ -383,19 +383,18 @@ public class AppVersionRegressionRadarTests
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var alert = Alert();
|
||||
// The Functions host serializes camelCase on the wire; mirror that here so the
|
||||
// TestWire serializes with the REAL wire options (ApiJsonOptions — camelCase), so the
|
||||
// asserted property names match what the web client actually parses.
|
||||
var wireOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
|
||||
// Empty-data early return still surfaces the block (episodes can outlive window data).
|
||||
var emptyRoot = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var emptyRoot = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }), wireOptions);
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
var root = TestWire.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }), wireOptions);
|
||||
new List<AppVersionRegressionAlert> { alert }));
|
||||
|
||||
var regression = root.GetProperty("versionRegressions")[0];
|
||||
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
|
||||
|
||||
@@ -53,7 +53,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
A("InProgress"),
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
@@ -67,7 +67,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
{
|
||||
var summaries = new List<AppInstallSummary> { A("InProgress"), A("InProgress") };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
Assert.Equal(0d, root.GetProperty("apps")[0].GetProperty("failureRate").GetDouble());
|
||||
@@ -90,7 +90,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
for (var i = 0; i < 2; i++) summaries.Add(A("Succeeded", startedAt: secondHalf));
|
||||
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: secondHalf));
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
@@ -109,12 +109,13 @@ public class AppsAnalyticsFailureRateTests
|
||||
for (var i = 0; i < 4; i++) summaries.Add(A("Failed", startedAt: now.AddDays(-5)));
|
||||
for (var i = 0; i < 10; i++) summaries.Add(A("InProgress", startedAt: now.AddDays(-5)));
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
|
||||
var app = root.GetProperty("apps")[0];
|
||||
Assert.Equal("stable", app.GetProperty("trend").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, app.GetProperty("trendDelta").ValueKind);
|
||||
// Wire options omit null keys (WhenWritingNull): a withheld delta has no key at all.
|
||||
Assert.False(app.TryGetProperty("trendDelta", out _));
|
||||
}
|
||||
|
||||
// ── /apps/{appName}/analytics ───────────────────────────────────────────
|
||||
@@ -131,7 +132,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
}
|
||||
var result = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
summaries, sessionRepo, "Contoso App", days: 30);
|
||||
return JsonSerializer.SerializeToElement(result);
|
||||
return TestWire.SerializeToElement(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -176,7 +177,7 @@ public class AppsAnalyticsFailureRateTests
|
||||
|
||||
var summaries = new List<AppInstallSummary> { A("Failed"), A("Succeeded"), skip, unmeasured };
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(
|
||||
var root = TestWire.SerializeToElement(
|
||||
AppsAnalyticsHelper.BuildAppsListResponse(summaries, days: 30));
|
||||
var app = root.GetProperty("apps")[0];
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public class AppsAnalyticsHelperPagingTests
|
||||
}
|
||||
|
||||
private static JsonElement Build(int? pageSize, int skip = 0) =>
|
||||
JsonSerializer.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
|
||||
TestWire.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse(
|
||||
FiveApps(), days: 30, pageSize: pageSize, skip: skip,
|
||||
nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}"));
|
||||
|
||||
@@ -55,12 +55,13 @@ public class AppsAnalyticsHelperPagingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PageSize_LastPage_HasNullNextLink()
|
||||
public void PageSize_LastPage_OmitsNextLink()
|
||||
{
|
||||
var root = Build(pageSize: 2, skip: 4);
|
||||
|
||||
Assert.Equal(1, root.GetProperty("count").GetInt32());
|
||||
Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, root.GetProperty("nextLink").ValueKind);
|
||||
// Wire options omit null keys (WhenWritingNull): no further page → no nextLink key.
|
||||
Assert.False(root.TryGetProperty("nextLink", out _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ public class AuthFunctionSideEffectTests
|
||||
{
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminEntity());
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
|
||||
@@ -473,7 +473,7 @@ public class AuthFunctionSideEffectTests
|
||||
{
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminEntity());
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
|
||||
|
||||
@@ -471,7 +471,8 @@ public class AuthFunctionTests
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Converts anonymous object to dynamic for property access in tests.
|
||||
/// Exposes a body (typed DTO or anonymous error shape) as dynamic under its WIRE key names
|
||||
/// (camelCase), so the asserts read the same casing a client would.
|
||||
/// </summary>
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
@@ -479,7 +480,7 @@ public class AuthFunctionTests
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
dict[System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(prop.Name)] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
@@ -6,31 +6,44 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Pins the CMTrace time-skew regression tripwire (CmTraceSkewTripwire.Evaluate): fires only
|
||||
/// when the IME-vs-other median delta divergence sits on the 15-minute offset grid (residual
|
||||
/// < 2 min) AND both sides have enough samples over enough distinct upload batches. The
|
||||
/// tripwire's goal state is to never fire, so the must-stay-silent directions are the primary
|
||||
/// cases: off-grid replays, sub-grid divergence, thin samples, batch-composition artifacts,
|
||||
/// and common-mode spool latency must all pass without a hit.
|
||||
/// < 2 min) AND both sides have enough samples over enough distinct upload batches, judged
|
||||
/// over the session's most recent ingest era. The tripwire's goal state is to never fire, so
|
||||
/// the must-stay-silent directions are the primary cases: off-grid replays, sub-grid
|
||||
/// divergence, thin samples, batch-composition artifacts, common-mode spool latency, and the
|
||||
/// skewed-but-ancient leg of a resumed pre-provisioning session must all pass without a hit.
|
||||
/// </summary>
|
||||
public class CmTraceSkewTripwireTests
|
||||
{
|
||||
/// <summary>Reference ingest era used by every single-era scan below.</summary>
|
||||
private static readonly DateTime EraBase = new DateTime(2026, 9, 1, 8, 20, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>Scan with the given per-side medians, comfortably above all sample/batch minimums.</summary>
|
||||
private static SessionSkewScan MakeScan(
|
||||
double imeDeltaMinutes, double otherDeltaMinutes,
|
||||
int imeSamples = 30, int otherSamples = 30,
|
||||
int imeBatches = 5, int otherBatches = 5)
|
||||
{
|
||||
var scan = new SessionSkewScan
|
||||
{
|
||||
ImeDistinctBatchCount = imeBatches,
|
||||
OtherDistinctBatchCount = otherBatches,
|
||||
};
|
||||
for (int i = 0; i < imeSamples; i++)
|
||||
scan.ImeDeltaMinutes.Add(imeDeltaMinutes);
|
||||
for (int i = 0; i < otherSamples; i++)
|
||||
scan.OtherDeltaMinutes.Add(otherDeltaMinutes);
|
||||
var scan = new SessionSkewScan();
|
||||
AddSamples(scan, isIme: true, imeDeltaMinutes, imeSamples, imeBatches, EraBase);
|
||||
AddSamples(scan, isIme: false, otherDeltaMinutes, otherSamples, otherBatches, EraBase);
|
||||
return scan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds <paramref name="count"/> identical samples spread round-robin over
|
||||
/// <paramref name="batches"/> ReceivedAt stamps one minute apart, starting at
|
||||
/// <paramref name="eraStart"/> — one continuous ingest era.
|
||||
/// </summary>
|
||||
private static void AddSamples(
|
||||
SessionSkewScan scan, bool isIme, double deltaMinutes, int count, int batches, DateTime eraStart)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
scan.Add(isIme, deltaMinutes, eraStart.AddMinutes(i % Math.Max(batches, 1)));
|
||||
}
|
||||
|
||||
private static List<double> ImeDeltas(SessionSkewScan scan)
|
||||
=> scan.ImeSamples.Select(s => s.DeltaMinutes).ToList();
|
||||
|
||||
// ── Must fire ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -106,6 +119,7 @@ public class CmTraceSkewTripwireTests
|
||||
{
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(null));
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(new SessionSkewScan()));
|
||||
Assert.Null(CmTraceSkewTripwire.ResolveEraStartUtc(new SessionSkewScan()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -114,8 +128,8 @@ public class CmTraceSkewTripwireTests
|
||||
// A handful of replayed old lines (huge off-grid deltas) inside an otherwise healthy
|
||||
// IME stream must not drag the median onto the grid.
|
||||
var scan = MakeScan(imeDeltaMinutes: 0.5, otherDeltaMinutes: 0.4);
|
||||
scan.ImeDeltaMinutes.Add(-1327.0);
|
||||
scan.ImeDeltaMinutes.Add(-738.0);
|
||||
scan.Add(isIme: true, -1327.0, EraBase);
|
||||
scan.Add(isIme: true, -738.0, EraBase);
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
|
||||
}
|
||||
|
||||
@@ -126,21 +140,23 @@ public class CmTraceSkewTripwireTests
|
||||
// re-tailed the IME log and uploaded 508 lines in one burst with a continuum of ages
|
||||
// (46…733 min, quartiles 52 / 92 / 100) — median 91 min, i.e. 6×15 by pure chance.
|
||||
// The other side was live. The per-sample grid-conformity guard must keep this silent.
|
||||
// The burst arrives in ONE batch, inside the same era as the live traffic.
|
||||
var scan = MakeScan(imeDeltaMinutes: 0.1, otherDeltaMinutes: 0.1, imeSamples: 100, imeBatches: 80);
|
||||
var burstReceivedAt = EraBase.AddMinutes(80);
|
||||
void AddRamp(double from, double to, int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
scan.ImeDeltaMinutes.Add(from + i * (to - from) / count);
|
||||
scan.Add(isIme: true, from + i * (to - from) / count, burstReceivedAt);
|
||||
}
|
||||
AddRamp(46.0, 88.0, 125);
|
||||
AddRamp(88.0, 100.0, 250);
|
||||
AddRamp(100.0, 733.0, 125);
|
||||
|
||||
var medianIme = scan.ImeDeltaMinutes.OrderBy(v => v).ElementAt(scan.ImeDeltaMinutes.Count / 2);
|
||||
var medianIme = ImeDeltas(scan).OrderBy(v => v).ElementAt(scan.ImeSamples.Count / 2);
|
||||
Assert.InRange(medianIme, 88.0, 92.0); // sanity: the median really does sit near 6×15
|
||||
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
|
||||
Assert.True(CmTraceSkewTripwire.ComputeGridConformantFraction(scan.ImeDeltaMinutes, 0.1) < 0.5);
|
||||
Assert.True(CmTraceSkewTripwire.ComputeGridConformantFraction(ImeDeltas(scan), 0.1) < 0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -150,7 +166,7 @@ public class CmTraceSkewTripwireTests
|
||||
// zone. Every line is individually on the grid, so the conformity guard must NOT mask it.
|
||||
var scan = MakeScan(imeDeltaMinutes: -420.0, otherDeltaMinutes: 0.0, imeSamples: 65);
|
||||
for (int i = 0; i < 35; i++)
|
||||
scan.ImeDeltaMinutes.Add(120.0);
|
||||
scan.Add(isIme: true, 120.0, EraBase);
|
||||
|
||||
var result = CmTraceSkewTripwire.Evaluate(scan);
|
||||
Assert.NotNull(result);
|
||||
@@ -166,6 +182,105 @@ public class CmTraceSkewTripwireTests
|
||||
Assert.Equal(0, CmTraceSkewTripwire.ComputeGridConformantFraction(new List<double>(), 0.0));
|
||||
}
|
||||
|
||||
// ── Ingest-era window ──────────────────────────────────────────────────
|
||||
|
||||
private static readonly DateTime PartOneLeg = new DateTime(2026, 8, 20, 8, 20, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime PartTwoLeg = new DateTime(2026, 9, 1, 8, 20, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Silent_OnPreProvisioningHandoverWhereOnlyTheOldLegWasSkewed()
|
||||
{
|
||||
// Field 2026-09-01, three sessions of one tenant (e797117b / c06d639d / d7c8032b):
|
||||
// Part 1 ran on 2026-08-20 under agent 2.0.1409 — before per-line anchoring — and left
|
||||
// 26 IME samples at exactly −60 min; Part 2 ran 12 days later on 2.0.1445 and is clean.
|
||||
// The stale leg outnumbers the fresh one 26:9 and is perfectly grid-conformant, so
|
||||
// nothing but the era window can separate them (event rows carry no agent version).
|
||||
var scan = new SessionSkewScan();
|
||||
AddSamples(scan, isIme: true, -60.0, count: 26, batches: 20, eraStart: PartOneLeg);
|
||||
AddSamples(scan, isIme: false, 0.0, count: 116, batches: 60, eraStart: PartOneLeg);
|
||||
AddSamples(scan, isIme: true, -0.16, count: 9, batches: 6, eraStart: PartTwoLeg);
|
||||
AddSamples(scan, isIme: false, -0.26, count: 95, batches: 40, eraStart: PartTwoLeg);
|
||||
|
||||
Assert.Null(CmTraceSkewTripwire.Evaluate(scan));
|
||||
Assert.Equal(PartTwoLeg, CmTraceSkewTripwire.ResolveEraStartUtc(scan));
|
||||
|
||||
// …and the old leg really is what used to fire: judged on its own it still does.
|
||||
var oldLegOnly = new SessionSkewScan();
|
||||
AddSamples(oldLegOnly, isIme: true, -60.0, count: 26, batches: 20, eraStart: PartOneLeg);
|
||||
AddSamples(oldLegOnly, isIme: false, 0.0, count: 116, batches: 60, eraStart: PartOneLeg);
|
||||
var legacy = CmTraceSkewTripwire.Evaluate(oldLegOnly);
|
||||
Assert.NotNull(legacy);
|
||||
Assert.Equal(-4, legacy!.GridSteps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fires_WhenTheCurrentEraIsSkewed_AndReportsWhatTheOlderLegContributed()
|
||||
{
|
||||
// The window must not become a blanket mute for resumed sessions: a skew in the leg the
|
||||
// running agent just produced still fires, and the report says how much history it left
|
||||
// out so an operator can tell a fresh regression from an inherited one.
|
||||
var scan = new SessionSkewScan();
|
||||
AddSamples(scan, isIme: true, 0.1, count: 30, batches: 10, eraStart: PartOneLeg);
|
||||
AddSamples(scan, isIme: false, 0.1, count: 30, batches: 10, eraStart: PartOneLeg);
|
||||
AddSamples(scan, isIme: true, -60.0, count: 25, batches: 8, eraStart: PartTwoLeg);
|
||||
AddSamples(scan, isIme: false, 0.0, count: 40, batches: 12, eraStart: PartTwoLeg);
|
||||
|
||||
var result = CmTraceSkewTripwire.Evaluate(scan);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(-4, result!.GridSteps);
|
||||
Assert.Equal(-60.0, result.DiffMinutes, 3);
|
||||
Assert.Equal(25, result.ImeSampleCount);
|
||||
Assert.Equal(40, result.OtherSampleCount);
|
||||
Assert.Equal(8, result.ImeBatchCount);
|
||||
Assert.Equal(12, result.OtherBatchCount);
|
||||
Assert.Equal(PartTwoLeg, result.EraStartUtc);
|
||||
Assert.Equal(30, result.ImeSamplesOutsideEra);
|
||||
Assert.Equal(30, result.OtherSamplesOutsideEra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EraWindow_SplitsOnGapsWiderThanTheThreshold_NotOnTotalSpan()
|
||||
{
|
||||
// A long leg that keeps uploading is ONE era even when it spans far more than the gap
|
||||
// threshold end to end — the boundary is the gap between consecutive batches.
|
||||
var start = PartTwoLeg;
|
||||
var continuous = new SessionSkewScan();
|
||||
for (int i = 0; i < 8; i++)
|
||||
continuous.Add(isIme: false, 0.0, start.AddMinutes(30 * i)); // 3.5 h span, 30 min gaps
|
||||
Assert.Equal(start, CmTraceSkewTripwire.ResolveEraStartUtc(continuous));
|
||||
|
||||
// Exactly at the threshold is still one era (the split needs a STRICTLY wider gap).
|
||||
var atThreshold = new SessionSkewScan();
|
||||
atThreshold.Add(isIme: false, 0.0, start);
|
||||
atThreshold.Add(isIme: false, 0.0, start.AddHours(CmTraceSkewTripwire.EraGapHours));
|
||||
Assert.Equal(start, CmTraceSkewTripwire.ResolveEraStartUtc(atThreshold));
|
||||
|
||||
// One silence past the threshold ends the era, and the boundary is found across BOTH
|
||||
// sides — an IME-only batch after the gap must still open the new era.
|
||||
var split = new SessionSkewScan();
|
||||
split.Add(isIme: false, 0.0, start);
|
||||
split.Add(isIme: true, 0.0, start.AddHours(3));
|
||||
Assert.Equal(start.AddHours(3), CmTraceSkewTripwire.ResolveEraStartUtc(split));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleCap_KeepsTheNewestSamples()
|
||||
{
|
||||
// The cap is a memory backstop for pathological sessions. Since the era window judges
|
||||
// the NEWEST leg, the buffer must drop the oldest entries — keeping the head instead
|
||||
// would blind the detector on exactly the sessions big enough to reach the cap.
|
||||
var scan = new SessionSkewScan();
|
||||
int adds = SessionSkewScan.MaxSamplesPerSide + 5_000;
|
||||
for (int i = 0; i < adds; i++)
|
||||
scan.Add(isIme: true, i, EraBase.AddSeconds(i));
|
||||
|
||||
Assert.True(scan.ImeSamples.Count >= SessionSkewScan.MaxSamplesPerSide);
|
||||
Assert.True(scan.ImeSamples.Count < SessionSkewScan.MaxSamplesPerSide * 2);
|
||||
Assert.Equal((double)(adds - 1), scan.ImeSamples[scan.ImeSamples.Count - 1].DeltaMinutes);
|
||||
Assert.True(scan.ImeSamples[0].DeltaMinutes > 0); // the oldest adds were trimmed away
|
||||
}
|
||||
|
||||
// ── Bias suppression predicate ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -30,12 +30,7 @@ public class CollectLogsQuickConfigOpsEventTests
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch), saved);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Config function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class ConfigWireParityTests
|
||||
{
|
||||
// ---- GetAllTenantConfigurations (delegated one-shot page + GA paginated mode) --------
|
||||
|
||||
[Fact]
|
||||
public void GetAllTenantConfigurationsResponse_matches_the_paginated_projection_shape()
|
||||
{
|
||||
// TenantConfigProjection.ProjectAll returns List<Dictionary<string, object?>>.
|
||||
var tenants = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["tenantId"] = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
["domainName"] = "contoso.com",
|
||||
["planTier"] = "pro",
|
||||
},
|
||||
};
|
||||
string? nextLink = "/api/config/all?pageSize=1&continuation=tok";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
count = tenants.Count,
|
||||
tenants,
|
||||
nextLink,
|
||||
},
|
||||
new GetAllTenantConfigurationsResponse
|
||||
{
|
||||
Count = tenants.Count,
|
||||
Tenants = tenants,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllTenantConfigurationsResponse_omits_the_null_nextLink_of_the_delegated_one_shot_page()
|
||||
{
|
||||
var projected = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["tenantId"] = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e",
|
||||
["domainName"] = "fabrikam.com",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
count = projected.Count,
|
||||
tenants = projected,
|
||||
nextLink = (string?)null,
|
||||
},
|
||||
new GetAllTenantConfigurationsResponse
|
||||
{
|
||||
Count = projected.Count,
|
||||
Tenants = projected,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- UpdateTenantAppHoming -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UpdateTenantAppHomingResponse_matches_the_flip_result_shape()
|
||||
{
|
||||
var changed = true;
|
||||
string? homedAppClientId = "0f9e8d7c-6b5a-4433-2211-aabbccddeeff";
|
||||
string? lastAuthClientId = "0f9e8d7c-6b5a-4433-2211-aabbccddeeff";
|
||||
DateTime? lastAuthClientIdSince = new DateTime(2026, 8, 29, 6, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
changed,
|
||||
homedApp = "primary",
|
||||
homedAppClientId,
|
||||
lastAuthClientId,
|
||||
lastAuthClientIdSince,
|
||||
// Old ProbePayload helper built this anonymous shape.
|
||||
probe = new
|
||||
{
|
||||
attempted = true,
|
||||
succeeded = true,
|
||||
isTransient = false,
|
||||
},
|
||||
},
|
||||
new UpdateTenantAppHomingResponse
|
||||
{
|
||||
Success = true,
|
||||
Changed = changed,
|
||||
HomedApp = "primary",
|
||||
HomedAppClientId = homedAppClientId,
|
||||
LastAuthClientId = lastAuthClientId,
|
||||
LastAuthClientIdSince = lastAuthClientIdSince,
|
||||
Probe = new AppHomingProbeWire
|
||||
{
|
||||
Attempted = true,
|
||||
Succeeded = true,
|
||||
IsTransient = false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTenantAppHomingResponse_omits_null_homing_slots()
|
||||
{
|
||||
// Legacy-homed tenant that never authenticated: pin and last-auth fields are null.
|
||||
string? homedAppClientId = null;
|
||||
string? lastAuthClientId = null;
|
||||
DateTime? lastAuthClientIdSince = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
changed = false,
|
||||
homedApp = "legacy",
|
||||
homedAppClientId,
|
||||
lastAuthClientId,
|
||||
lastAuthClientIdSince,
|
||||
probe = new
|
||||
{
|
||||
attempted = false,
|
||||
succeeded = false,
|
||||
isTransient = false,
|
||||
},
|
||||
},
|
||||
new UpdateTenantAppHomingResponse
|
||||
{
|
||||
Success = true,
|
||||
Changed = false,
|
||||
HomedApp = "legacy",
|
||||
HomedAppClientId = null,
|
||||
LastAuthClientId = null,
|
||||
LastAuthClientIdSince = null,
|
||||
Probe = new AppHomingProbeWire
|
||||
{
|
||||
Attempted = false,
|
||||
Succeeded = false,
|
||||
IsTransient = false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetLatestVersions ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetLatestVersionsResponse_matches_the_versions_shape()
|
||||
{
|
||||
string? agentVersion = "2.14.3.0";
|
||||
string? bootstrapVersion = "1.9";
|
||||
string? sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
DateTimeOffset? fetchedAtUtc = new DateTimeOffset(2026, 8, 30, 14, 0, 0, TimeSpan.Zero);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
latestAgentVersion = agentVersion,
|
||||
latestBootstrapScriptVersion = bootstrapVersion,
|
||||
latestAgentSha256 = sha256,
|
||||
fetchedAtUtc,
|
||||
source = "cache"
|
||||
},
|
||||
new GetLatestVersionsResponse
|
||||
{
|
||||
LatestAgentVersion = agentVersion,
|
||||
LatestBootstrapScriptVersion = bootstrapVersion,
|
||||
LatestAgentSha256 = sha256,
|
||||
FetchedAtUtc = fetchedAtUtc,
|
||||
Source = "cache"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLatestVersionsResponse_omits_all_null_slots_when_the_version_blob_was_unavailable()
|
||||
{
|
||||
// versions == null at the site: every ?. projection is null, source falls back to "blob".
|
||||
string? agentVersion = null;
|
||||
string? bootstrapVersion = null;
|
||||
string? sha256 = null;
|
||||
DateTimeOffset? fetchedAtUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
latestAgentVersion = agentVersion,
|
||||
latestBootstrapScriptVersion = bootstrapVersion,
|
||||
latestAgentSha256 = sha256,
|
||||
fetchedAtUtc,
|
||||
source = "blob"
|
||||
},
|
||||
new GetLatestVersionsResponse
|
||||
{
|
||||
LatestAgentVersion = null,
|
||||
LatestBootstrapScriptVersion = null,
|
||||
LatestAgentSha256 = null,
|
||||
FetchedAtUtc = null,
|
||||
Source = "blob"
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetTenantConfigFieldsSchema -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetTenantConfigFieldsSchemaResponse_matches_the_schema_shape()
|
||||
{
|
||||
IReadOnlyList<TenantConfigFieldSchema> schema = new List<TenantConfigFieldSchema>
|
||||
{
|
||||
new TenantConfigFieldSchema(
|
||||
name: "contactEmail",
|
||||
type: "string",
|
||||
format: "email",
|
||||
nullable: true,
|
||||
writable: true,
|
||||
reason: null,
|
||||
gaOnly: false,
|
||||
revertProtected: false),
|
||||
new TenantConfigFieldSchema(
|
||||
name: "homedAppClientId",
|
||||
type: "string",
|
||||
format: null,
|
||||
nullable: true,
|
||||
writable: false,
|
||||
reason: "system-owned",
|
||||
gaOnly: true,
|
||||
revertProtected: true),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
count = schema.Count,
|
||||
writableCount = schema.Count(f => f.Writable),
|
||||
fields = schema,
|
||||
},
|
||||
new GetTenantConfigFieldsSchemaResponse
|
||||
{
|
||||
Count = schema.Count,
|
||||
WritableCount = schema.Count(f => f.Writable),
|
||||
Fields = schema,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- ListTenantConfigBackups ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ListTenantConfigBackupsResponse_matches_the_backup_listing_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var backupId = "2519968812345678901-a1b2c3d4";
|
||||
var backupTakenAt = new DateTime(2026, 8, 30, 9, 45, 0, DateTimeKind.Utc);
|
||||
var changedBy = "admin@contoso.com";
|
||||
var source = "mcp-patch";
|
||||
string? reason = "enable webhook notifications";
|
||||
Dictionary<string, string>? diff = new Dictionary<string, string>
|
||||
{
|
||||
["WebhookUrl"] = "(none) -> https://***MASKED***",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
backups = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
backupId,
|
||||
backupTakenAt,
|
||||
changedBy,
|
||||
source,
|
||||
reason,
|
||||
diff,
|
||||
},
|
||||
},
|
||||
},
|
||||
new ListTenantConfigBackupsResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Backups = new List<TenantConfigBackupItem>
|
||||
{
|
||||
new TenantConfigBackupItem
|
||||
{
|
||||
BackupId = backupId,
|
||||
BackupTakenAt = backupTakenAt,
|
||||
ChangedBy = changedBy,
|
||||
Source = source,
|
||||
Reason = reason,
|
||||
Diff = diff,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListTenantConfigBackupsResponse_omits_null_reason_and_diff()
|
||||
{
|
||||
var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e";
|
||||
var backupId = "2519968800000000000-e5f6a7b8";
|
||||
var backupTakenAt = new DateTime(2026, 8, 29, 22, 10, 0, DateTimeKind.Utc);
|
||||
string? reason = null;
|
||||
Dictionary<string, string>? diff = null; // unparseable stored DiffJson → TryParseDiff returns null
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
backups = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
backupId,
|
||||
backupTakenAt,
|
||||
changedBy = "system",
|
||||
source = "portal-put",
|
||||
reason,
|
||||
diff,
|
||||
},
|
||||
},
|
||||
},
|
||||
new ListTenantConfigBackupsResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Backups = new List<TenantConfigBackupItem>
|
||||
{
|
||||
new TenantConfigBackupItem
|
||||
{
|
||||
BackupId = backupId,
|
||||
BackupTakenAt = backupTakenAt,
|
||||
ChangedBy = "system",
|
||||
Source = "portal-put",
|
||||
Reason = null,
|
||||
Diff = null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- PatchTenantConfigurationFields / RevertTenantConfiguration (shared WriteOutcome) -
|
||||
|
||||
[Fact]
|
||||
public void TenantConfigPatchOutcomeResponse_matches_the_applied_patch_shape()
|
||||
{
|
||||
IReadOnlyCollection<string> appliedFields = new List<string> { "ContactEmail", "NotificationsEnabled" };
|
||||
Dictionary<string, string>? diff = new Dictionary<string, string>
|
||||
{
|
||||
["ContactEmail"] = "(none) -> it@contoso.com",
|
||||
["NotificationsEnabled"] = "False -> True",
|
||||
};
|
||||
string? backupId = "2519968812345678901-a1b2c3d4";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
appliedFields,
|
||||
diff,
|
||||
backupId,
|
||||
noOp = appliedFields.Count == 0,
|
||||
},
|
||||
new TenantConfigPatchOutcomeResponse
|
||||
{
|
||||
Success = true,
|
||||
AppliedFields = appliedFields,
|
||||
Diff = diff,
|
||||
BackupId = backupId,
|
||||
NoOp = appliedFields.Count == 0,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TenantConfigPatchOutcomeResponse_omits_null_diff_and_backupId_on_a_noop()
|
||||
{
|
||||
IReadOnlyCollection<string> appliedFields = new List<string>();
|
||||
Dictionary<string, string>? diff = null;
|
||||
string? backupId = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
appliedFields,
|
||||
diff,
|
||||
backupId,
|
||||
noOp = appliedFields.Count == 0,
|
||||
},
|
||||
new TenantConfigPatchOutcomeResponse
|
||||
{
|
||||
Success = true,
|
||||
AppliedFields = appliedFields,
|
||||
Diff = null,
|
||||
BackupId = null,
|
||||
NoOp = true,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- SetTenantPlanTier ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetTenantPlanTierResponse_matches_the_plan_state_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
DateTime? trialExpiresUtc = new DateTime(2026, 9, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
DateTime? retentionGraceEndsUtc = new DateTime(2026, 10, 14, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
planTier = "pro",
|
||||
trialExpiresUtc,
|
||||
trialConsumed = true,
|
||||
effectiveEdition = "pro",
|
||||
retentionGraceEndsUtc
|
||||
},
|
||||
new SetTenantPlanTierResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanTier = "pro",
|
||||
TrialExpiresUtc = trialExpiresUtc,
|
||||
TrialConsumed = true,
|
||||
EffectiveEdition = "pro",
|
||||
RetentionGraceEndsUtc = retentionGraceEndsUtc
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetTenantPlanTierResponse_omits_null_trial_and_grace_slots()
|
||||
{
|
||||
var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e";
|
||||
DateTime? trialExpiresUtc = null;
|
||||
DateTime? retentionGraceEndsUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
planTier = "community",
|
||||
trialExpiresUtc,
|
||||
trialConsumed = false,
|
||||
effectiveEdition = "community",
|
||||
retentionGraceEndsUtc
|
||||
},
|
||||
new SetTenantPlanTierResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanTier = "community",
|
||||
TrialExpiresUtc = null,
|
||||
TrialConsumed = false,
|
||||
EffectiveEdition = "community",
|
||||
RetentionGraceEndsUtc = null
|
||||
});
|
||||
}
|
||||
|
||||
// ---- StartTenantTrial ----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void StartTenantTrialResponse_matches_the_trial_start_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
DateTime? trialStartedUtc = new DateTime(2026, 8, 30, 15, 0, 0, DateTimeKind.Utc);
|
||||
DateTime? trialExpiresUtc = trialStartedUtc.Value.AddDays(30);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
trialStartedUtc,
|
||||
trialExpiresUtc,
|
||||
effectiveEdition = "pro"
|
||||
},
|
||||
new StartTenantTrialResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TrialStartedUtc = trialStartedUtc,
|
||||
TrialExpiresUtc = trialExpiresUtc,
|
||||
EffectiveEdition = "pro"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartTenantTrialResponse_omits_null_trial_slots()
|
||||
{
|
||||
// The site always sets both dates before responding — this pins that a null slot
|
||||
// would vanish identically on both sides anyway (compile-time type is DateTime?).
|
||||
var tenantId = "1b1b46b3-41c3-4a3a-8b2c-7e0a2b3c4d5e";
|
||||
DateTime? trialStartedUtc = null;
|
||||
DateTime? trialExpiresUtc = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
trialStartedUtc,
|
||||
trialExpiresUtc,
|
||||
effectiveEdition = "pro"
|
||||
},
|
||||
new StartTenantTrialResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TrialStartedUtc = null,
|
||||
TrialExpiresUtc = null,
|
||||
EffectiveEdition = "pro"
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetPlanTierDefinitions / SetPlanTierDefinitions ---------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PlanTierDefinitionsResponse_matches_the_tiers_shape()
|
||||
{
|
||||
var tiers = new List<PlanTierDefinition>
|
||||
{
|
||||
new PlanTierDefinition
|
||||
{
|
||||
Name = "community",
|
||||
DailyRequestLimit = 100,
|
||||
MonthlyRequestLimit = 3000,
|
||||
Description = "Free tier",
|
||||
},
|
||||
new PlanTierDefinition
|
||||
{
|
||||
Name = "pro",
|
||||
DailyRequestLimit = 1000,
|
||||
MonthlyRequestLimit = 30000,
|
||||
Description = "Paid tier",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { tiers },
|
||||
new PlanTierDefinitionsResponse { Tiers = tiers });
|
||||
}
|
||||
|
||||
// ---- TestWebhookNotification (dual-purpose: success carries the delivery verdict) ----
|
||||
|
||||
[Fact]
|
||||
public void TestWebhookNotificationResponse_matches_the_delivery_verdict_shape()
|
||||
{
|
||||
int? statusCode = 200;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
statusCode,
|
||||
message = "Test notification sent successfully"
|
||||
},
|
||||
new TestWebhookNotificationResponse
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = statusCode,
|
||||
Message = "Test notification sent successfully"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestWebhookNotificationResponse_omits_a_null_statusCode_when_the_send_got_no_response()
|
||||
{
|
||||
int? statusCode = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = false,
|
||||
statusCode,
|
||||
message = "The webhook endpoint could not be reached."
|
||||
},
|
||||
new TestWebhookNotificationResponse
|
||||
{
|
||||
Success = false,
|
||||
StatusCode = null,
|
||||
Message = "The webhook endpoint could not be reached."
|
||||
});
|
||||
}
|
||||
|
||||
// ---- UpdateAdminConfiguration --------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UpdateAdminConfigurationResponse_matches_the_ack_plus_config_shape()
|
||||
{
|
||||
var config = new AdminConfiguration
|
||||
{
|
||||
PartitionKey = "GlobalConfig",
|
||||
RowKey = "config",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Admin configuration updated successfully",
|
||||
config = config
|
||||
},
|
||||
new UpdateAdminConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Admin configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
}
|
||||
|
||||
// ---- UpdateTenantConfiguration -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UpdateTenantConfigurationResponse_matches_the_ack_plus_config_shape()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = "Configuration updated successfully",
|
||||
config = config
|
||||
},
|
||||
new UpdateTenantConfigurationResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Configuration updated successfully",
|
||||
Config = config
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -159,16 +159,18 @@ public class DeleteSessionFunctionTests
|
||||
|
||||
// ── Anonymous-object reflection helpers ───────────────────────────────────────────────
|
||||
|
||||
// IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the
|
||||
// wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here.
|
||||
private static void AssertProperty(object body, string propertyName, object? expected)
|
||||
{
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
Assert.NotNull(prop);
|
||||
Assert.Equal(expected, prop!.GetValue(body));
|
||||
}
|
||||
|
||||
private static T GetProperty<T>(object body, string propertyName)
|
||||
{
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
Assert.NotNull(prop);
|
||||
var value = prop!.GetValue(body);
|
||||
return (T)value!;
|
||||
|
||||
@@ -702,7 +702,7 @@ public class DeviceJourneyAndFtrTests
|
||||
},
|
||||
};
|
||||
|
||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(daily);
|
||||
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(daily);
|
||||
|
||||
Assert.Equal(3, totals.CompletedJourneys);
|
||||
Assert.Equal(2, totals.FirstTimeRight);
|
||||
@@ -716,7 +716,7 @@ public class DeviceJourneyAndFtrTests
|
||||
[Fact]
|
||||
public void SumAggregates_NoCompletedJourneys_RateIsNull_NeverZero()
|
||||
{
|
||||
var totals = DeviceJourneyMetricsResponse.SumAggregates(new List<DeviceJourneyDailyAggregate>
|
||||
var totals = DeviceJourneyMetricsResponseBuilder.SumAggregates(new List<DeviceJourneyDailyAggregate>
|
||||
{
|
||||
new() { CompletedJourneyCount = 0, FirstTimeRightCount = 0, ExcludedSessionCount = 3 },
|
||||
});
|
||||
@@ -734,7 +734,7 @@ public class DeviceJourneyAndFtrTests
|
||||
[InlineData("999", 180)] // clamped to aggregate retention
|
||||
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponse.ClampDays(raw));
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -743,8 +743,8 @@ public class DeviceJourneyAndFtrTests
|
||||
// Codex review: both range ends are inclusive, so "today - days" returned N+1 keys —
|
||||
// days=1 summed yesterday AND today.
|
||||
var today = new DateTime(2026, 7, 27);
|
||||
Assert.Equal(today, DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 1));
|
||||
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponse.InclusiveWindowStart(today, 30));
|
||||
Assert.Equal(today, DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 1));
|
||||
Assert.Equal(today.AddDays(-29), DeviceJourneyMetricsResponseBuilder.InclusiveWindowStart(today, 30));
|
||||
}
|
||||
|
||||
private static DeviceHistory RepeaterHistory(
|
||||
@@ -780,7 +780,7 @@ public class DeviceJourneyAndFtrTests
|
||||
Ref("w2", SessionStatus.Succeeded, T0.AddDays(-2))),
|
||||
};
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(2, selected.Count);
|
||||
Assert.Equal("hot-1", selected[0].History.SerialKey); // attempts desc
|
||||
@@ -799,7 +799,7 @@ public class DeviceJourneyAndFtrTests
|
||||
Ref($"r-{i}", SessionStatus.Failed, T0.AddDays(-i))));
|
||||
}
|
||||
|
||||
var selected = DeviceJourneyMetricsResponse.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
var selected = DeviceJourneyMetricsResponseBuilder.SelectRepeatDevices(histories, T0.AddDays(-30));
|
||||
|
||||
Assert.Equal(10, selected.Count);
|
||||
Assert.Equal("dev-11", selected[0].History.SerialKey); // highest attempts first
|
||||
|
||||
+5
-2
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -43,10 +44,12 @@ public class GatherRulesFunctionAuthorEndpointTests
|
||||
|
||||
private static FunctionContext BuildContext(ClaimsPrincipal? principal)
|
||||
{
|
||||
// WriteAsJsonAsync resolves the serializer from InstanceServices.
|
||||
// WriteAsJsonAsync resolves the serializer from InstanceServices — use the production
|
||||
// wire settings so assertions run against the shape the deployed worker serializes.
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
|
||||
services.Configure<WorkerOptions>(o => o.Serializer =
|
||||
new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object>();
|
||||
|
||||
+20
-30
@@ -60,8 +60,8 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
Assert.Single(aggregated);
|
||||
Assert.Equal(3, totalRawReports);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,10 +91,10 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal("Lenovo", (string)item.manufacturer);
|
||||
Assert.Equal("ThinkPad T14", (string)item.model);
|
||||
Assert.Equal("SN1", (string)item.serialNumber);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal("Lenovo", (string)item.Manufacturer);
|
||||
Assert.Equal("ThinkPad T14", (string)item.Model);
|
||||
Assert.Equal("SN1", (string)item.SerialNumber);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -117,9 +117,9 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(t1, (DateTime)item.firstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.lastSeen);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(t1, (DateTime)item.FirstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.LastSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -135,12 +135,12 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Equal(3, aggregated.Count);
|
||||
var first = ToDynamic(aggregated[0]);
|
||||
var second = ToDynamic(aggregated[1]);
|
||||
var third = ToDynamic(aggregated[2]);
|
||||
var first = aggregated[0];
|
||||
var second = aggregated[1];
|
||||
var third = aggregated[2];
|
||||
|
||||
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
|
||||
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
|
||||
Assert.True((DateTime)first.LastSeen > (DateTime)second.LastSeen);
|
||||
Assert.True((DateTime)second.LastSeen > (DateTime)third.LastSeen);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -160,8 +160,8 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -182,8 +182,8 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.True((bool)item.isCloudPc);
|
||||
var item = aggregated[0];
|
||||
Assert.True((bool)item.IsCloudPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -196,8 +196,8 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.False((bool)item.isCloudPc);
|
||||
var item = aggregated[0];
|
||||
Assert.False((bool)item.IsCloudPc);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -219,14 +219,4 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
IsCloudPc = isCloudPc,
|
||||
};
|
||||
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ public class GetHardwareRejectedFunctionTests
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -77,8 +77,8 @@ public class GetHardwareRejectedFunctionTests
|
||||
Assert.Equal(4, totalRawReports);
|
||||
Assert.Equal(2, aggregated.Count);
|
||||
|
||||
var dell = ToDynamic(aggregated.First(a => ((string)ToDynamic(a).manufacturer).Equals("Dell", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.Equal(3, (int)dell.attemptCount);
|
||||
var dell = aggregated.First(a => a.Manufacturer.Equals("Dell", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal(3, (int)dell.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -94,9 +94,9 @@ public class GetHardwareRejectedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(2, (int)item.uniqueSerials);
|
||||
Assert.Equal(4, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(2, (int)item.UniqueSerials);
|
||||
Assert.Equal(4, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -108,10 +108,10 @@ public class GetHardwareRejectedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
var samples = (IEnumerable<object>)item.sampleSerialNumbers;
|
||||
var item = aggregated[0];
|
||||
var samples = (IEnumerable<object>)item.SampleSerialNumbers;
|
||||
Assert.Equal(5, samples.Count());
|
||||
Assert.Equal(8, (int)item.uniqueSerials);
|
||||
Assert.Equal(8, (int)item.UniqueSerials);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -134,9 +134,9 @@ public class GetHardwareRejectedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(t1, (DateTime)item.firstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.lastSeen);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(t1, (DateTime)item.FirstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.LastSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -152,12 +152,12 @@ public class GetHardwareRejectedFunctionTests
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Equal(3, aggregated.Count);
|
||||
var first = ToDynamic(aggregated[0]);
|
||||
var second = ToDynamic(aggregated[1]);
|
||||
var third = ToDynamic(aggregated[2]);
|
||||
var first = aggregated[0];
|
||||
var second = aggregated[1];
|
||||
var third = aggregated[2];
|
||||
|
||||
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
|
||||
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
|
||||
Assert.True((DateTime)first.LastSeen > (DateTime)second.LastSeen);
|
||||
Assert.True((DateTime)second.LastSeen > (DateTime)third.LastSeen);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -176,10 +176,10 @@ public class GetHardwareRejectedFunctionTests
|
||||
var (aggregated, _) = GetHardwareRejectedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal("", (string)item.manufacturer);
|
||||
Assert.Equal("", (string)item.model);
|
||||
Assert.Equal(2, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal("", (string)item.Manufacturer);
|
||||
Assert.Equal("", (string)item.Model);
|
||||
Assert.Equal(2, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -199,14 +199,4 @@ public class GetHardwareRejectedFunctionTests
|
||||
AgentTimestamp = ingestedAt,
|
||||
};
|
||||
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
private static JsonElement Serialize(TenantConfiguration config)
|
||||
{
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var json = TestWire.Serialize(payload);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"isTrial",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
"trialExpiresUtc",
|
||||
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
|
||||
// wire options omit null keys (WhenWritingNull) — exactly what production always
|
||||
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
|
||||
"unrestrictedMode",
|
||||
"validateAutopilotDevice",
|
||||
}, fieldNames);
|
||||
@@ -162,7 +164,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-sas-token", json);
|
||||
Assert.DoesNotContain("secret-team-hook", json);
|
||||
@@ -270,7 +272,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
[Fact]
|
||||
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
|
||||
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
|
||||
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-contact", json);
|
||||
|
||||
@@ -60,8 +60,8 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
|
||||
Assert.Single(aggregated);
|
||||
Assert.Equal(3, totalRawReports);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,10 +91,10 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal("Lenovo", (string)item.manufacturer);
|
||||
Assert.Equal("ThinkCentre M720q", (string)item.model);
|
||||
Assert.Equal("SN1", (string)item.serialNumber);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal("Lenovo", (string)item.Manufacturer);
|
||||
Assert.Equal("ThinkCentre M720q", (string)item.Model);
|
||||
Assert.Equal("SN1", (string)item.SerialNumber);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -117,9 +117,9 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(t1, (DateTime)item.firstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.lastSeen);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(t1, (DateTime)item.FirstSeen);
|
||||
Assert.Equal(t3, (DateTime)item.LastSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -135,12 +135,12 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Equal(3, aggregated.Count);
|
||||
var first = ToDynamic(aggregated[0]);
|
||||
var second = ToDynamic(aggregated[1]);
|
||||
var third = ToDynamic(aggregated[2]);
|
||||
var first = aggregated[0];
|
||||
var second = aggregated[1];
|
||||
var third = aggregated[2];
|
||||
|
||||
Assert.True((DateTime)first.lastSeen > (DateTime)second.lastSeen);
|
||||
Assert.True((DateTime)second.lastSeen > (DateTime)third.lastSeen);
|
||||
Assert.True((DateTime)first.LastSeen > (DateTime)second.LastSeen);
|
||||
Assert.True((DateTime)second.LastSeen > (DateTime)third.LastSeen);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -160,8 +160,8 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
var (aggregated, _) = GetTpmPssUnsupportedFunction.BuildAggregatedResult(reports);
|
||||
|
||||
Assert.Single(aggregated);
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
var item = aggregated[0];
|
||||
Assert.Equal(3, (int)item.AttemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -181,14 +181,4 @@ public class GetTpmPssUnsupportedFunctionTests
|
||||
AgentTimestamp = ingestedAt,
|
||||
};
|
||||
|
||||
private static dynamic ToDynamic(object obj)
|
||||
{
|
||||
var type = obj.GetType();
|
||||
var dict = new System.Dynamic.ExpandoObject() as IDictionary<string, object?>;
|
||||
foreach (var prop in type.GetProperties())
|
||||
{
|
||||
dict[prop.Name] = prop.GetValue(obj);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Graph function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class GraphWireParityTests
|
||||
{
|
||||
// ---- GetScriptDisplayNames -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_matches_the_resolved_shape_with_malformed_tokens()
|
||||
{
|
||||
// Unresolved refs keep their key with an explicit null VALUE — dictionary values are
|
||||
// not subject to WhenWritingNull (only properties are), on both sides.
|
||||
var payload = new Dictionary<string, string?>
|
||||
{
|
||||
["Platform:1f9d3a52-4c1e-4b7a-9a01-52f3aaaa1001"] = "Install VPN client",
|
||||
["Remediation:2eab4b63-5d2f-4c8b-8b12-52f3aaaa1002"] = null,
|
||||
};
|
||||
var malformed = new List<string> { "NotARef", "Platform:" };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
refs = payload,
|
||||
malformed = malformed.Count > 0 ? (object)malformed : null,
|
||||
},
|
||||
new GetScriptDisplayNamesResponse
|
||||
{
|
||||
Refs = payload,
|
||||
Malformed = malformed,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_omits_malformed_when_all_tokens_parsed()
|
||||
{
|
||||
var payload = new Dictionary<string, string?>
|
||||
{
|
||||
["Platform:3fbc5c74-6e30-4d9c-9c23-52f3aaaa1003"] = "Set timezone",
|
||||
};
|
||||
var malformed = new List<string>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
refs = payload,
|
||||
malformed = malformed.Count > 0 ? (object)malformed : null,
|
||||
},
|
||||
new GetScriptDisplayNamesResponse
|
||||
{
|
||||
Refs = payload,
|
||||
Malformed = malformed.Count > 0 ? malformed : null,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_matches_the_empty_body_early_exit_shape()
|
||||
{
|
||||
// Old early-exit sites (empty body / empty refs list) never declared a malformed key
|
||||
// at all: `new { refs = new Dictionary<string, string?>() }`. The typed side leaves
|
||||
// Malformed null so WhenWritingNull removes the key identically.
|
||||
AssertParity(
|
||||
new { refs = new Dictionary<string, string?>() },
|
||||
new GetScriptDisplayNamesResponse { Refs = new Dictionary<string, string?>() });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScriptDisplayNamesResponse_dictionary_keys_are_not_camel_cased()
|
||||
{
|
||||
// ApiJsonOptions sets PropertyNamingPolicy = CamelCase but NO DictionaryKeyPolicy:
|
||||
// property names camel-case, dictionary keys (canonical script refs with a PascalCase
|
||||
// type prefix) serialize verbatim. Pin that asymmetry so a future serializer change
|
||||
// (e.g. adding DictionaryKeyPolicy = CamelCase) fails loudly here.
|
||||
var typed = new GetScriptDisplayNamesResponse
|
||||
{
|
||||
Refs = new Dictionary<string, string?>
|
||||
{
|
||||
["Platform:4acd6d85-7f41-4ead-8d34-52f3aaaa1004"] = "Map drives",
|
||||
["Remediation:5bde7e96-8052-4fbe-9e45-52f3aaaa1005"] = null,
|
||||
},
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(typed, ApiJsonOptions.Create());
|
||||
|
||||
Assert.Contains("\"refs\":", json); // property name IS camel-cased
|
||||
Assert.Contains("\"Platform:4acd6d85-7f41-4ead-8d34-52f3aaaa1004\"", json);
|
||||
Assert.Contains("\"Remediation:5bde7e96-8052-4fbe-9e45-52f3aaaa1005\":null", json);
|
||||
Assert.DoesNotContain("\"platform:", json);
|
||||
Assert.DoesNotContain("\"remediation:", json);
|
||||
}
|
||||
|
||||
// ---- GetGraphPermissionsStatus -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGraphPermissionsStatusResponse_matches_the_authoritative_shape()
|
||||
{
|
||||
var clientId = "6cef8fa7-9163-40cf-af56-52f3aaaa1006";
|
||||
var grantedRoles = new[] { "DeviceManagementConfiguration.Read.All" };
|
||||
var features = new List<GraphFeatureStatusItem>
|
||||
{
|
||||
new GraphFeatureStatusItem
|
||||
{
|
||||
Name = "ScriptDisplayNames",
|
||||
Granted = true,
|
||||
RequiredPermissions = new[] { "DeviceManagementConfiguration.Read.All" },
|
||||
},
|
||||
new GraphFeatureStatusItem
|
||||
{
|
||||
Name = "W365CloudPcValidation",
|
||||
Granted = false,
|
||||
RequiredPermissions = new[] { "CloudPC.Read.All" },
|
||||
},
|
||||
};
|
||||
var anonymousFeatures = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "ScriptDisplayNames",
|
||||
granted = (bool?)true,
|
||||
requiredPermissions = (IReadOnlyList<string>)new[] { "DeviceManagementConfiguration.Read.All" },
|
||||
},
|
||||
new
|
||||
{
|
||||
name = "W365CloudPcValidation",
|
||||
granted = (bool?)false,
|
||||
requiredPermissions = (IReadOnlyList<string>)new[] { "CloudPC.Read.All" },
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
clientId,
|
||||
isTransient = false,
|
||||
grantedRoles,
|
||||
features = anonymousFeatures,
|
||||
},
|
||||
new GetGraphPermissionsStatusResponse
|
||||
{
|
||||
ClientId = clientId,
|
||||
IsTransient = false,
|
||||
GrantedRoles = grantedRoles,
|
||||
Features = features,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGraphPermissionsStatusResponse_omits_granted_on_a_transient_snapshot()
|
||||
{
|
||||
// Old site: `granted = snapshot.IsTransient ? (bool?)null : ...` — WhenWritingNull
|
||||
// removed the key per feature row; the typed bool? must vanish identically.
|
||||
var anonymousFeatures = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "ScriptDisplayNames",
|
||||
granted = (bool?)null,
|
||||
requiredPermissions = (IReadOnlyList<string>)new[] { "DeviceManagementConfiguration.Read.All" },
|
||||
},
|
||||
};
|
||||
var typedFeatures = new List<GraphFeatureStatusItem>
|
||||
{
|
||||
new GraphFeatureStatusItem
|
||||
{
|
||||
Name = "ScriptDisplayNames",
|
||||
Granted = null,
|
||||
RequiredPermissions = new[] { "DeviceManagementConfiguration.Read.All" },
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
clientId = string.Empty,
|
||||
isTransient = true,
|
||||
grantedRoles = Array.Empty<string>(),
|
||||
features = anonymousFeatures,
|
||||
},
|
||||
new GetGraphPermissionsStatusResponse
|
||||
{
|
||||
ClientId = string.Empty,
|
||||
IsTransient = true,
|
||||
GrantedRoles = Array.Empty<string>(),
|
||||
Features = typedFeatures,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- RefreshGraphPermissions ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RefreshGraphPermissions_success_body_matches_SuccessOnlyResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Infrastructure / Global / Notifications / Bootstrap /
|
||||
/// Diagnostics function folders plus McpQuotaEnforcementMiddleware (anonymous-object →
|
||||
/// typed-DTO migration). Each fact serializes the OLD anonymous literal exactly as it stood
|
||||
/// at the call site (copied from the pre-migration code, filled with realistic sample
|
||||
/// values) against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class InfrastructureWireParityTests
|
||||
{
|
||||
// ---- AuthFunction: auth/me success body ----------------------------------------------
|
||||
// Left side: the exact anonymous literal BuildAuthResult produced before AuthMeResponse.
|
||||
|
||||
[Fact]
|
||||
public void AuthMeResponse_matches_the_success_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var upn = "admin@contoso.com";
|
||||
var displayName = "Admin User";
|
||||
var objectId = "0b6f7a37-1111-4d61-9c93-0aa111111111";
|
||||
var managedTenantIds = new[] { "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
upn,
|
||||
displayName,
|
||||
objectId,
|
||||
isGlobalAdmin = false,
|
||||
isGlobalReader = false,
|
||||
isTenantAdmin = true,
|
||||
isDelegated = true,
|
||||
delegatedTenantIds = managedTenantIds,
|
||||
role = "Admin",
|
||||
canManageBootstrapTokens = true,
|
||||
hasMcpAccess = true,
|
||||
homedApp = "primary",
|
||||
bootstrapTokenEnabled = true,
|
||||
unrestrictedModeEnabled = false
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
DisplayName = displayName,
|
||||
ObjectId = objectId,
|
||||
IsGlobalAdmin = false,
|
||||
IsGlobalReader = false,
|
||||
IsTenantAdmin = true,
|
||||
IsDelegated = true,
|
||||
DelegatedTenantIds = managedTenantIds,
|
||||
Role = "Admin",
|
||||
CanManageBootstrapTokens = true,
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "primary",
|
||||
BootstrapTokenEnabled = true,
|
||||
UnrestrictedModeEnabled = false
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthMeResponse_omits_a_null_role()
|
||||
{
|
||||
// A roleless caller (e.g. pure GlobalReader): role was null in the anonymous literal,
|
||||
// so the key vanished — the DTO must vanish it identically.
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var upn = "reader@contoso.com";
|
||||
var displayName = "Reader User";
|
||||
var objectId = "0b6f7a37-2222-4d61-9c93-0aa222222222";
|
||||
string? role = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
upn,
|
||||
displayName,
|
||||
objectId,
|
||||
isGlobalAdmin = false,
|
||||
isGlobalReader = true,
|
||||
isTenantAdmin = false,
|
||||
isDelegated = false,
|
||||
delegatedTenantIds = Array.Empty<string>(),
|
||||
role,
|
||||
canManageBootstrapTokens = false,
|
||||
hasMcpAccess = true,
|
||||
homedApp = "legacy",
|
||||
bootstrapTokenEnabled = false,
|
||||
unrestrictedModeEnabled = false
|
||||
},
|
||||
new AuthMeResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
DisplayName = displayName,
|
||||
ObjectId = objectId,
|
||||
IsGlobalAdmin = false,
|
||||
IsGlobalReader = true,
|
||||
IsTenantAdmin = false,
|
||||
IsDelegated = false,
|
||||
DelegatedTenantIds = Array.Empty<string>(),
|
||||
Role = null,
|
||||
CanManageBootstrapTokens = false,
|
||||
HasMcpAccess = true,
|
||||
HomedApp = "legacy",
|
||||
BootstrapTokenEnabled = false,
|
||||
UnrestrictedModeEnabled = false
|
||||
});
|
||||
}
|
||||
|
||||
// ---- AuthFunction: IsGlobalAdmin -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void IsGlobalAdminResponse_matches_the_flag_shape()
|
||||
{
|
||||
var isAdmin = true;
|
||||
string? upn = "admin@contoso.com";
|
||||
|
||||
AssertParity(
|
||||
new { isGlobalAdmin = isAdmin, upn },
|
||||
new IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = upn });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsGlobalAdminResponse_omits_a_null_upn()
|
||||
{
|
||||
var isAdmin = false;
|
||||
string? upn = null;
|
||||
|
||||
AssertParity(
|
||||
new { isGlobalAdmin = isAdmin, upn },
|
||||
new IsGlobalAdminResponse { IsGlobalAdmin = isAdmin, Upn = null });
|
||||
}
|
||||
|
||||
// ---- McpUserFunction: CheckMcpAccess (auth/mcp) --------------------------------------
|
||||
// Left side: the exact Dictionary<string, object?> the site built before the DTO —
|
||||
// insertion order == wire order, conditional keys only present when the tier applies.
|
||||
|
||||
[Fact]
|
||||
public void CheckMcpAccessResponse_matches_the_plain_user_shape()
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["allowed"] = true,
|
||||
["upn"] = "analyst@contoso.com",
|
||||
["accessGrant"] = "whitelist",
|
||||
["reason"] = "whitelisted",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
payload,
|
||||
new CheckMcpAccessResponse
|
||||
{
|
||||
Allowed = true,
|
||||
Upn = "analyst@contoso.com",
|
||||
AccessGrant = "whitelist",
|
||||
Reason = "whitelisted",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckMcpAccessResponse_matches_the_platform_and_delegated_shape()
|
||||
{
|
||||
var delegatedTenantIds = new[] { "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" };
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["allowed"] = true,
|
||||
["upn"] = "admin@contoso.com",
|
||||
["accessGrant"] = "global-admin",
|
||||
["reason"] = "platform role",
|
||||
["isGlobalAdmin"] = true,
|
||||
["globalRole"] = "GlobalAdmin",
|
||||
["delegatedTenantIds"] = delegatedTenantIds,
|
||||
["delegatedRole"] = "DelegatedAdmin",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
payload,
|
||||
new CheckMcpAccessResponse
|
||||
{
|
||||
Allowed = true,
|
||||
Upn = "admin@contoso.com",
|
||||
AccessGrant = "global-admin",
|
||||
Reason = "platform role",
|
||||
IsGlobalAdmin = true,
|
||||
GlobalRole = "GlobalAdmin",
|
||||
DelegatedTenantIds = delegatedTenantIds,
|
||||
DelegatedRole = "DelegatedAdmin",
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckMcpAccessResponse_matches_the_denied_shape()
|
||||
{
|
||||
// 403 body: same shape, allowed=false, no conditional keys. isGlobalAdmin must never
|
||||
// appear as false — the DTO's null slot omits it exactly like the old dictionary did.
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["allowed"] = false,
|
||||
["upn"] = "user@contoso.com",
|
||||
["accessGrant"] = "",
|
||||
["reason"] = "not whitelisted",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
payload,
|
||||
new CheckMcpAccessResponse
|
||||
{
|
||||
Allowed = false,
|
||||
Upn = "user@contoso.com",
|
||||
AccessGrant = "",
|
||||
Reason = "not whitelisted",
|
||||
IsGlobalAdmin = null,
|
||||
GlobalRole = null,
|
||||
DelegatedTenantIds = null,
|
||||
DelegatedRole = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- AuthFunction: GetGlobalAdmins / AddGlobalAdmin ----------------------------------
|
||||
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows are GlobalAdminRow, not the
|
||||
// raw GlobalAdminEntity — partitionKey/rowKey/eTag/timestamp are gone from the wire.
|
||||
// These pins fix the NEW shape exactly (key names, order, table-key absence).
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalAdminsResponse_pins_the_row_listing_shape()
|
||||
{
|
||||
var admins = new List<GlobalAdminRow>
|
||||
{
|
||||
new GlobalAdminRow
|
||||
{
|
||||
Upn = "admin@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "root@fabrikam.com",
|
||||
Role = "GlobalAdmin",
|
||||
},
|
||||
new GlobalAdminRow
|
||||
{
|
||||
Upn = "reader@fabrikam.com",
|
||||
IsEnabled = false,
|
||||
AddedDate = new DateTime(2026, 8, 2, 9, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
Role = "GlobalReader",
|
||||
},
|
||||
};
|
||||
|
||||
var json = TestWire.Serialize(new GetGlobalAdminsResponse { Admins = admins });
|
||||
Assert.Equal(
|
||||
"{\"admins\":["
|
||||
+ "{\"upn\":\"admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-01T09:00:00Z\",\"addedBy\":\"root@fabrikam.com\",\"role\":\"GlobalAdmin\"},"
|
||||
+ "{\"upn\":\"reader@fabrikam.com\",\"isEnabled\":false,\"addedDate\":\"2026-08-02T09:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalReader\"}"
|
||||
+ "]}",
|
||||
json);
|
||||
Assert.DoesNotContain("partitionKey", json);
|
||||
Assert.DoesNotContain("rowKey", json);
|
||||
Assert.DoesNotContain("eTag", json);
|
||||
Assert.DoesNotContain("timestamp", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddGlobalAdminResponse_pins_the_created_row_shape()
|
||||
{
|
||||
var newAdmin = new GlobalAdminRow
|
||||
{
|
||||
Upn = "new.admin@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedDate = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
Role = "GlobalAdmin",
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
"{\"admin\":{\"upn\":\"new.admin@contoso.com\",\"isEnabled\":true,\"addedDate\":\"2026-08-30T12:00:00Z\",\"addedBy\":\"admin@contoso.com\",\"role\":\"GlobalAdmin\"}}",
|
||||
TestWire.Serialize(new AddGlobalAdminResponse { Admin = newAdmin }));
|
||||
}
|
||||
|
||||
// ---- HealthCheckFunction: HealthCheck ------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void HealthCheckResponse_matches_the_liveness_shape()
|
||||
{
|
||||
var timestamp = new DateTime(2026, 8, 30, 13, 0, 0, DateTimeKind.Utc);
|
||||
var buildUtc = new DateTime(2026, 8, 29, 20, 15, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
status = "healthy",
|
||||
service = "Autopilot Monitor API",
|
||||
timestamp,
|
||||
version = "1.2.3",
|
||||
commitHash = "abc1234",
|
||||
buildUtc
|
||||
},
|
||||
new HealthCheckResponse
|
||||
{
|
||||
Status = "healthy",
|
||||
Service = "Autopilot Monitor API",
|
||||
Timestamp = timestamp,
|
||||
Version = "1.2.3",
|
||||
CommitHash = "abc1234",
|
||||
BuildUtc = buildUtc
|
||||
});
|
||||
}
|
||||
|
||||
// ---- HealthCheckFunction: DetailedHealthCheck ----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DetailedHealthCheckResponse_matches_the_report_shape()
|
||||
{
|
||||
var timestamp = new DateTime(2026, 8, 30, 13, 5, 0, DateTimeKind.Utc);
|
||||
var buildUtc = new DateTime(2026, 8, 29, 20, 15, 0, DateTimeKind.Utc);
|
||||
var visibleChecks = new List<HealthCheck>
|
||||
{
|
||||
new HealthCheck
|
||||
{
|
||||
Name = "Table Storage",
|
||||
Description = "Round-trip against the sessions table",
|
||||
Status = "healthy",
|
||||
Message = "OK",
|
||||
Details = new Dictionary<string, object> { ["latencyMs"] = 12 },
|
||||
},
|
||||
new HealthCheck
|
||||
{
|
||||
Name = "SignalR",
|
||||
Description = "Service reachability",
|
||||
Status = "degraded",
|
||||
Message = "Slow response",
|
||||
// Details stays null → the key vanishes inside the item on both sides.
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
service = "Autopilot Monitor API",
|
||||
timestamp,
|
||||
overallStatus = "degraded",
|
||||
checks = visibleChecks,
|
||||
version = "1.2.3",
|
||||
commitHash = "abc1234",
|
||||
buildUtc
|
||||
},
|
||||
new DetailedHealthCheckResponse
|
||||
{
|
||||
Service = "Autopilot Monitor API",
|
||||
Timestamp = timestamp,
|
||||
OverallStatus = "degraded",
|
||||
Checks = visibleChecks,
|
||||
Version = "1.2.3",
|
||||
CommitHash = "abc1234",
|
||||
BuildUtc = buildUtc
|
||||
});
|
||||
}
|
||||
|
||||
// ---- HealthCheckFunction: McpHealthCheck ---------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void McpHealthCheckResponse_matches_the_probe_shape()
|
||||
{
|
||||
var timestamp = new DateTime(2026, 8, 30, 13, 10, 0, DateTimeKind.Utc);
|
||||
var check = new HealthCheck
|
||||
{
|
||||
Name = "MCP Server",
|
||||
Description = "Remote MCP server reachability",
|
||||
Status = "healthy",
|
||||
Message = "Responded in 480ms",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
timestamp,
|
||||
check
|
||||
},
|
||||
new McpHealthCheckResponse
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
Check = check
|
||||
});
|
||||
}
|
||||
|
||||
// ---- McpUserFunction: GetMcpUsers ----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetMcpUsersResponse_matches_the_whitelist_shape()
|
||||
{
|
||||
var users = new List<McpUserEntry>
|
||||
{
|
||||
new McpUserEntry
|
||||
{
|
||||
Upn = "analyst@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedAt = new DateTime(2026, 8, 10, 8, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
UsagePlan = "pro",
|
||||
},
|
||||
new McpUserEntry
|
||||
{
|
||||
Upn = "viewer@fabrikam.com",
|
||||
IsEnabled = false,
|
||||
AddedAt = new DateTime(2026, 8, 11, 8, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
// UsagePlan stays null → the key vanishes inside the item on both sides.
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { policy = "Whitelist", users },
|
||||
new GetMcpUsersResponse { Policy = "Whitelist", Users = users });
|
||||
}
|
||||
|
||||
// ---- McpUserFunction: AddMcpUser -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddMcpUserResponse_matches_the_created_entry_shape()
|
||||
{
|
||||
var user = new McpUserEntry
|
||||
{
|
||||
Upn = "new.analyst@contoso.com",
|
||||
IsEnabled = true,
|
||||
AddedAt = new DateTime(2026, 8, 30, 14, 0, 0, DateTimeKind.Utc),
|
||||
AddedBy = "admin@contoso.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { user },
|
||||
new AddMcpUserResponse { User = user });
|
||||
}
|
||||
|
||||
// ---- McpUserFunction: SetMcpUserUsagePlan --------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetMcpUserUsagePlanResponse_matches_the_plan_shape()
|
||||
{
|
||||
var upn = "analyst@contoso.com";
|
||||
string? usagePlan = "pro";
|
||||
|
||||
AssertParity(
|
||||
new { upn, usagePlan = usagePlan ?? "(inherit)" },
|
||||
new SetMcpUserUsagePlanResponse { Upn = upn, UsagePlan = usagePlan ?? "(inherit)" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetMcpUserUsagePlanResponse_renders_the_inherit_fallback()
|
||||
{
|
||||
var upn = "analyst@contoso.com";
|
||||
string? usagePlan = null;
|
||||
|
||||
AssertParity(
|
||||
new { upn, usagePlan = usagePlan ?? "(inherit)" },
|
||||
new SetMcpUserUsagePlanResponse { Upn = upn, UsagePlan = usagePlan ?? "(inherit)" });
|
||||
}
|
||||
|
||||
// ---- SignalRNegotiateFunction --------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SignalRNegotiateResponse_matches_the_negotiate_protocol_shape()
|
||||
{
|
||||
var url = "https://signalr.service.example/client/?hub=notifications";
|
||||
var accessToken = "eyJhbGciOiJIUzI1NiJ9.sample.token";
|
||||
|
||||
AssertParity(
|
||||
new { url, accessToken },
|
||||
new SignalRNegotiateResponse { Url = url, AccessToken = accessToken });
|
||||
}
|
||||
|
||||
// ---- SignalRAddToGroup / SignalRRemoveFromGroup --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddToGroup_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var groupName = "tenant-6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Added to group {groupName}"
|
||||
},
|
||||
new SuccessMessageResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Added to group {groupName}"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveFromGroup_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var groupName = "session-6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d-0b6f7a37-1111-4d61-9c93-0aa111111111";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Removed from group {groupName}"
|
||||
},
|
||||
new SuccessMessageResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Removed from group {groupName}"
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GlobalNotificationsFunction / TenantNotificationsFunction: list -----------------
|
||||
|
||||
[Fact]
|
||||
public void NotificationListResponse_matches_the_active_notification_shape()
|
||||
{
|
||||
var notifications = new List<GlobalNotificationDto>
|
||||
{
|
||||
new GlobalNotificationDto
|
||||
{
|
||||
Id = "8f1c9d55-aaaa-4e0e-8888-52f3aaaa0001",
|
||||
Type = "preview_signup",
|
||||
Title = "New Tenant Signup",
|
||||
Message = "Tenant 6a6a35a2 (contoso.com), UPN: admin@contoso.com",
|
||||
Href = "/admin/tenants/management?tenantId=6a6a35a2",
|
||||
CreatedAt = new DateTime(2026, 8, 30, 9, 30, 0, DateTimeKind.Utc),
|
||||
},
|
||||
new GlobalNotificationDto
|
||||
{
|
||||
Id = "8f1c9d55-bbbb-4e0e-8888-52f3aaaa0002",
|
||||
Type = "info",
|
||||
Title = "Maintenance",
|
||||
Message = "Nightly cleanup completed",
|
||||
// Href stays null → the key vanishes inside the item on both sides.
|
||||
CreatedAt = new DateTime(2026, 8, 30, 4, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, notifications },
|
||||
new NotificationListResponse { Success = true, Notifications = notifications });
|
||||
}
|
||||
|
||||
// ---- Global/Tenant notifications: dismiss single -------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DismissNotification_success_body_matches_SuccessOnlyResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
|
||||
// ---- Global/Tenant notifications: dismiss-all ----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DismissAllNotificationsResponse_matches_the_count_shape()
|
||||
{
|
||||
var dismissedCount = 7;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, dismissedCount },
|
||||
new DismissAllNotificationsResponse { Success = true, DismissedCount = dismissedCount });
|
||||
}
|
||||
|
||||
// ---- RevokeBootstrapSessionFunction --------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RevokeBootstrapSession_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
AssertParity(
|
||||
new { success = true, message = "Bootstrap session revoked" },
|
||||
new SuccessMessageResponse { Success = true, Message = "Bootstrap session revoked" });
|
||||
}
|
||||
|
||||
// ---- DiagnosticsDownloadTicketFunction -----------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticsDownloadTicketResponse_matches_the_ticket_shape()
|
||||
{
|
||||
var ticket = "dGVzdC10aWNrZXQ=.sig==";
|
||||
var expiresAt = new DateTime(2026, 8, 30, 15, 10, 0, DateTimeKind.Utc);
|
||||
var blobName = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d/0b6f7a37-1111-4d61-9c93-0aa111111111/diagnostics.zip";
|
||||
var destinationLabel = "Hosted";
|
||||
long? sizeBytes = 1048576;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
|
||||
expiresAt,
|
||||
blobName,
|
||||
destination = destinationLabel,
|
||||
sizeBytes,
|
||||
},
|
||||
new DiagnosticsDownloadTicketResponse
|
||||
{
|
||||
Success = true,
|
||||
Url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
|
||||
ExpiresAt = expiresAt,
|
||||
BlobName = blobName,
|
||||
Destination = destinationLabel,
|
||||
SizeBytes = sizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticsDownloadTicketResponse_omits_a_null_sizeBytes()
|
||||
{
|
||||
var ticket = "dGVzdC10aWNrZXQy.sig==";
|
||||
var expiresAt = new DateTime(2026, 8, 30, 15, 20, 0, DateTimeKind.Utc);
|
||||
var blobName = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d/0b6f7a37-2222-4d61-9c93-0aa222222222/diagnostics.zip";
|
||||
var destinationLabel = "CustomerSas";
|
||||
long? sizeBytes = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
|
||||
expiresAt,
|
||||
blobName,
|
||||
destination = destinationLabel,
|
||||
sizeBytes,
|
||||
},
|
||||
new DiagnosticsDownloadTicketResponse
|
||||
{
|
||||
Success = true,
|
||||
Url = $"/api/diagnostics/download?t={Uri.EscapeDataString(ticket)}",
|
||||
ExpiresAt = expiresAt,
|
||||
BlobName = blobName,
|
||||
Destination = destinationLabel,
|
||||
SizeBytes = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- McpQuotaEnforcementMiddleware: 429 quota body -----------------------------------
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_matches_the_daily_blocked_shape()
|
||||
{
|
||||
var decision = new McpQuotaDecision
|
||||
{
|
||||
Allowed = false,
|
||||
Plan = "community",
|
||||
Scope = "daily",
|
||||
DailyLimit = 200,
|
||||
MonthlyLimit = 2000,
|
||||
DailyUsed = 200,
|
||||
MonthlyUsed = 940,
|
||||
ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
quotaExceeded = true,
|
||||
plan = decision.Plan,
|
||||
scope = decision.Scope,
|
||||
limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
QuotaExceeded = true,
|
||||
Plan = decision.Plan,
|
||||
Scope = decision.Scope,
|
||||
Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_matches_the_monthly_blocked_shape()
|
||||
{
|
||||
var decision = new McpQuotaDecision
|
||||
{
|
||||
Allowed = false,
|
||||
Plan = "pro",
|
||||
Scope = "monthly",
|
||||
DailyLimit = 1000,
|
||||
MonthlyLimit = 10000,
|
||||
DailyUsed = 120,
|
||||
MonthlyUsed = 10000,
|
||||
ResetUtc = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
quotaExceeded = true,
|
||||
plan = decision.Plan,
|
||||
scope = decision.Scope,
|
||||
limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
QuotaExceeded = true,
|
||||
Plan = decision.Plan,
|
||||
Scope = decision.Scope,
|
||||
Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_omits_a_null_scope()
|
||||
{
|
||||
// Unreachable at the 429 site (a blocked decision always names the exceeded window),
|
||||
// but the DTO slot is nullable — prove the key vanishes identically on both sides.
|
||||
string? scope = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
quotaExceeded = true,
|
||||
plan = "community",
|
||||
scope,
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
QuotaExceeded = true,
|
||||
Plan = "community",
|
||||
Scope = null,
|
||||
Limit = 200,
|
||||
Used = 200,
|
||||
ResetUtc = "2026-08-31T00:00:00Z",
|
||||
Message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -181,13 +181,7 @@ public class KillSwitchEvaluatorTests
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
var evaluator = new KillSwitchEvaluator(
|
||||
|
||||
@@ -18,7 +18,7 @@ public class MetricsMathAppPayloadTests
|
||||
private static JsonElement Build(IEnumerable<AppInstallSummary> summaries)
|
||||
{
|
||||
var payload = MetricsMath.BuildAppMetricsPayload(summaries);
|
||||
return JsonSerializer.SerializeToElement(payload);
|
||||
return TestWire.SerializeToElement(payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Metrics function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class MetricsWireParityTests
|
||||
{
|
||||
// ---- GetDeviceHistory ----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetDeviceHistoryResponse_matches_the_history_shape()
|
||||
{
|
||||
DeviceHistory? history = new DeviceHistory
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SerialKey = "sn-0042",
|
||||
SerialNumber = "SN-0042",
|
||||
Manufacturer = "Fabrikam Inc.",
|
||||
Model = "Latitude 9999",
|
||||
Chain = new List<DeviceSessionRef>
|
||||
{
|
||||
new DeviceSessionRef
|
||||
{
|
||||
SessionId = "0b6f7a37-1111-4d61-9c93-0aa111111111",
|
||||
StartedAt = new DateTime(2026, 8, 28, 9, 0, 0, DateTimeKind.Utc),
|
||||
CompletedAt = new DateTime(2026, 8, 28, 10, 5, 0, DateTimeKind.Utc),
|
||||
Status = "Failed",
|
||||
EnrollmentType = "v1",
|
||||
IsPreProvisioned = false,
|
||||
DurationSeconds = 3900,
|
||||
AdminMarked = false,
|
||||
},
|
||||
new DeviceSessionRef
|
||||
{
|
||||
SessionId = "0b6f7a37-2222-4d61-9c93-0aa222222222",
|
||||
StartedAt = new DateTime(2026, 8, 28, 11, 0, 0, DateTimeKind.Utc),
|
||||
CompletedAt = new DateTime(2026, 8, 28, 11, 50, 0, DateTimeKind.Utc),
|
||||
Status = "Succeeded",
|
||||
EnrollmentType = "v1",
|
||||
IsPreProvisioned = false,
|
||||
DurationSeconds = 3000,
|
||||
AdminMarked = false,
|
||||
},
|
||||
},
|
||||
CurrentJourneyAttempts = 2,
|
||||
JourneyCount = 1,
|
||||
JourneyVersion = 1,
|
||||
LastUpdated = new DateTime(2026, 8, 28, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
int? attemptNumber = 2;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, history, attemptNumber },
|
||||
new GetDeviceHistoryResponse
|
||||
{
|
||||
Success = true,
|
||||
History = history,
|
||||
AttemptNumber = attemptNumber,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDeviceHistoryResponse_omits_a_null_history_and_attemptNumber()
|
||||
{
|
||||
DeviceHistory? history = null;
|
||||
int? attemptNumber = null;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, history, attemptNumber },
|
||||
new GetDeviceHistoryResponse
|
||||
{
|
||||
Success = true,
|
||||
History = null,
|
||||
AttemptNumber = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetGeographicLocationSessions / GetGlobalGeographicLocationSessions -------------
|
||||
|
||||
private static LocationSessionRow SampleLocationRow(
|
||||
string sessionId, DateTime? completedAt, int? durationSeconds) => new LocationSessionRow
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SerialNumber = "SN-0042",
|
||||
DeviceName = "DESKTOP-CONTOSO1",
|
||||
Manufacturer = "Fabrikam Inc.",
|
||||
Model = "Latitude 9999",
|
||||
StartedAt = new DateTime(2026, 8, 30, 8, 0, 0, DateTimeKind.Utc),
|
||||
CompletedAt = completedAt,
|
||||
Status = SessionStatus.Succeeded,
|
||||
FailureReason = string.Empty,
|
||||
DurationSeconds = durationSeconds,
|
||||
EnrollmentType = "v1",
|
||||
GeoCountry = "DE",
|
||||
GeoRegion = "Hessen",
|
||||
GeoCity = "Frankfurt",
|
||||
TotalAppCount = 7,
|
||||
HasDoTelemetry = true,
|
||||
DoPercentPeerCaching = 42.5,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void GeographicLocationSessionsResponse_matches_the_full_shape()
|
||||
{
|
||||
var rows = new List<LocationSessionRow>
|
||||
{
|
||||
SampleLocationRow(
|
||||
"3f1c9d55-aaaa-4e0e-8888-52f3aaaa0001",
|
||||
new DateTime(2026, 8, 30, 9, 10, 0, DateTimeKind.Utc),
|
||||
4200),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessions = rows, totalCount = rows.Count },
|
||||
new GeographicLocationSessionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Sessions = rows,
|
||||
TotalCount = rows.Count,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeographicLocationSessionsLeanResponse_matches_the_lean_projection_shape()
|
||||
{
|
||||
var r = SampleLocationRow(
|
||||
"3f1c9d55-bbbb-4e0e-8888-52f3aaaa0002",
|
||||
new DateTime(2026, 8, 30, 9, 40, 0, DateTimeKind.Utc),
|
||||
5100);
|
||||
|
||||
// Old site: ToLeanRow returned `object` — an anonymous projection of the row.
|
||||
var lean = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
sessionId = r.SessionId,
|
||||
tenantId = r.TenantId,
|
||||
serialNumber = r.SerialNumber,
|
||||
deviceName = r.DeviceName,
|
||||
manufacturer = r.Manufacturer,
|
||||
model = r.Model,
|
||||
startedAt = r.StartedAt,
|
||||
completedAt = r.CompletedAt,
|
||||
status = r.Status,
|
||||
failureReason = r.FailureReason,
|
||||
durationSeconds = r.DurationSeconds,
|
||||
enrollmentType = r.EnrollmentType,
|
||||
geoCountry = r.GeoCountry,
|
||||
geoCity = r.GeoCity,
|
||||
totalAppCount = r.TotalAppCount,
|
||||
hasDoTelemetry = r.HasDoTelemetry,
|
||||
doPercentPeerCaching = r.DoPercentPeerCaching,
|
||||
},
|
||||
};
|
||||
var leanTyped = new List<LocationSessionLeanRow>
|
||||
{
|
||||
new LocationSessionLeanRow
|
||||
{
|
||||
SessionId = r.SessionId,
|
||||
TenantId = r.TenantId,
|
||||
SerialNumber = r.SerialNumber,
|
||||
DeviceName = r.DeviceName,
|
||||
Manufacturer = r.Manufacturer,
|
||||
Model = r.Model,
|
||||
StartedAt = r.StartedAt,
|
||||
CompletedAt = r.CompletedAt,
|
||||
Status = r.Status,
|
||||
FailureReason = r.FailureReason,
|
||||
DurationSeconds = r.DurationSeconds,
|
||||
EnrollmentType = r.EnrollmentType,
|
||||
GeoCountry = r.GeoCountry,
|
||||
GeoCity = r.GeoCity,
|
||||
TotalAppCount = r.TotalAppCount,
|
||||
HasDoTelemetry = r.HasDoTelemetry,
|
||||
DoPercentPeerCaching = r.DoPercentPeerCaching,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessions = lean, totalCount = lean.Count },
|
||||
new GeographicLocationSessionsLeanResponse
|
||||
{
|
||||
Success = true,
|
||||
Sessions = leanTyped,
|
||||
TotalCount = leanTyped.Count,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GeographicLocationSessionsLeanResponse_omits_null_completedAt_and_durationSeconds()
|
||||
{
|
||||
var r = SampleLocationRow("3f1c9d55-cccc-4e0e-8888-52f3aaaa0003", null, null);
|
||||
|
||||
var lean = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
sessionId = r.SessionId,
|
||||
tenantId = r.TenantId,
|
||||
serialNumber = r.SerialNumber,
|
||||
deviceName = r.DeviceName,
|
||||
manufacturer = r.Manufacturer,
|
||||
model = r.Model,
|
||||
startedAt = r.StartedAt,
|
||||
completedAt = r.CompletedAt,
|
||||
status = r.Status,
|
||||
failureReason = r.FailureReason,
|
||||
durationSeconds = r.DurationSeconds,
|
||||
enrollmentType = r.EnrollmentType,
|
||||
geoCountry = r.GeoCountry,
|
||||
geoCity = r.GeoCity,
|
||||
totalAppCount = r.TotalAppCount,
|
||||
hasDoTelemetry = r.HasDoTelemetry,
|
||||
doPercentPeerCaching = r.DoPercentPeerCaching,
|
||||
},
|
||||
};
|
||||
var leanTyped = new List<LocationSessionLeanRow>
|
||||
{
|
||||
new LocationSessionLeanRow
|
||||
{
|
||||
SessionId = r.SessionId,
|
||||
TenantId = r.TenantId,
|
||||
SerialNumber = r.SerialNumber,
|
||||
DeviceName = r.DeviceName,
|
||||
Manufacturer = r.Manufacturer,
|
||||
Model = r.Model,
|
||||
StartedAt = r.StartedAt,
|
||||
CompletedAt = null,
|
||||
Status = r.Status,
|
||||
FailureReason = r.FailureReason,
|
||||
DurationSeconds = null,
|
||||
EnrollmentType = r.EnrollmentType,
|
||||
GeoCountry = r.GeoCountry,
|
||||
GeoCity = r.GeoCity,
|
||||
TotalAppCount = r.TotalAppCount,
|
||||
HasDoTelemetry = r.HasDoTelemetry,
|
||||
DoPercentPeerCaching = r.DoPercentPeerCaching,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessions = lean, totalCount = lean.Count },
|
||||
new GeographicLocationSessionsLeanResponse
|
||||
{
|
||||
Success = true,
|
||||
Sessions = leanTyped,
|
||||
TotalCount = leanTyped.Count,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetPlatformStats ----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetPlatformStatsResponse_matches_the_zero_shape()
|
||||
{
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
totalEnrollments = 0,
|
||||
totalUsers = 0,
|
||||
totalTenants = 0,
|
||||
uniqueDeviceModels = 0,
|
||||
totalEventsProcessed = 0,
|
||||
successfulEnrollments = 0,
|
||||
issuesDetected = 0,
|
||||
lastUpdated = (DateTime?)null
|
||||
},
|
||||
new GetPlatformStatsResponse
|
||||
{
|
||||
TotalEnrollments = 0,
|
||||
TotalUsers = 0,
|
||||
TotalTenants = 0,
|
||||
// TotalSignedUpTenants stays null → the key is absent, exactly like the old
|
||||
// literal that never declared it.
|
||||
UniqueDeviceModels = 0,
|
||||
TotalEventsProcessed = 0,
|
||||
SuccessfulEnrollments = 0,
|
||||
IssuesDetected = 0,
|
||||
LastUpdated = null
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlatformStatsResponse_matches_the_computed_stats_shape()
|
||||
{
|
||||
var stats = new PlatformStats
|
||||
{
|
||||
TotalEnrollments = 123456,
|
||||
TotalUsers = 789,
|
||||
TotalTenants = 321,
|
||||
TotalSignedUpTenants = 345,
|
||||
UniqueDeviceModels = 210,
|
||||
TotalEventsProcessed = 9876543,
|
||||
SuccessfulEnrollments = 111111,
|
||||
IssuesDetected = 2222,
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
totalEnrollments = stats.TotalEnrollments,
|
||||
totalUsers = stats.TotalUsers,
|
||||
totalTenants = stats.TotalTenants,
|
||||
totalSignedUpTenants = stats.TotalSignedUpTenants,
|
||||
uniqueDeviceModels = stats.UniqueDeviceModels,
|
||||
totalEventsProcessed = stats.TotalEventsProcessed,
|
||||
successfulEnrollments = stats.SuccessfulEnrollments,
|
||||
issuesDetected = stats.IssuesDetected
|
||||
},
|
||||
new GetPlatformStatsResponse
|
||||
{
|
||||
TotalEnrollments = stats.TotalEnrollments,
|
||||
TotalUsers = stats.TotalUsers,
|
||||
TotalTenants = stats.TotalTenants,
|
||||
TotalSignedUpTenants = stats.TotalSignedUpTenants,
|
||||
UniqueDeviceModels = stats.UniqueDeviceModels,
|
||||
TotalEventsProcessed = stats.TotalEventsProcessed,
|
||||
SuccessfulEnrollments = stats.SuccessfulEnrollments,
|
||||
IssuesDetected = stats.IssuesDetected
|
||||
// LastUpdated stays null → the key is absent, exactly like the old literal
|
||||
// that never declared it.
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionTimeAttribution -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionTimeAttributionResponse_matches_the_breakdown_shape()
|
||||
{
|
||||
SessionTimeBreakdown? breakdown = new SessionTimeBreakdown
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = "5c8e1b20-dddd-49a2-8bd1-52f3aaaa0004",
|
||||
AttributionVersion = 2,
|
||||
EventCountAtCompute = 250,
|
||||
WallClockSeconds = 3600,
|
||||
UnattributedSeconds = 120,
|
||||
RebootSeconds = 90,
|
||||
SleepSeconds = 0,
|
||||
BlockingAppCount = 3,
|
||||
EspAppsOccupancySeconds = 1500,
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, breakdown },
|
||||
new GetSessionTimeAttributionResponse
|
||||
{
|
||||
Success = true,
|
||||
Breakdown = breakdown,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionTimeAttributionResponse_omits_a_null_breakdown()
|
||||
{
|
||||
SessionTimeBreakdown? breakdown = null;
|
||||
|
||||
AssertParity(
|
||||
new { success = true, breakdown },
|
||||
new GetSessionTimeAttributionResponse
|
||||
{
|
||||
Success = true,
|
||||
Breakdown = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetMyMcpUsage -------------------------------------------------------------------
|
||||
|
||||
private static List<UserUsageRecord> SampleUsageRecords(string userId) => new List<UserUsageRecord>
|
||||
{
|
||||
new UserUsageRecord
|
||||
{
|
||||
UserId = userId,
|
||||
UserPrincipalName = "admin@contoso.com",
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
Endpoint = "get_session_summary",
|
||||
Date = "20260830",
|
||||
RequestCount = 12,
|
||||
LastRequestAt = new DateTime(2026, 8, 30, 14, 30, 0, DateTimeKind.Utc),
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void GetMyMcpUsageResponse_matches_the_self_service_shape()
|
||||
{
|
||||
var userId = "7aa20c11-0001-4b7c-a1d2-52f3aaaa0007";
|
||||
string? upn = "admin@contoso.com";
|
||||
string? usagePlan = "pro";
|
||||
var effectivePlan = "pro";
|
||||
var dailyLimit = 500;
|
||||
var monthlyLimit = 10000;
|
||||
long dailyUsed = 42;
|
||||
long monthlyUsed = 950;
|
||||
var resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc);
|
||||
var records = SampleUsageRecords(userId);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
userId,
|
||||
upn,
|
||||
usagePlan,
|
||||
effectivePlan,
|
||||
quota = new
|
||||
{
|
||||
dailyLimit,
|
||||
monthlyLimit,
|
||||
dailyUsed,
|
||||
monthlyUsed,
|
||||
resetUtc
|
||||
},
|
||||
records
|
||||
},
|
||||
new GetMyMcpUsageResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Upn = upn,
|
||||
UsagePlan = usagePlan,
|
||||
EffectivePlan = effectivePlan,
|
||||
Quota = new McpUsageQuotaNode
|
||||
{
|
||||
DailyLimit = dailyLimit,
|
||||
MonthlyLimit = monthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
ResetUtc = resetUtc
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMyMcpUsageResponse_omits_null_upn_and_usagePlan()
|
||||
{
|
||||
var userId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0008";
|
||||
string? upn = null;
|
||||
string? usagePlan = null;
|
||||
var effectivePlan = "free";
|
||||
var records = new List<UserUsageRecord>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
userId,
|
||||
upn,
|
||||
usagePlan,
|
||||
effectivePlan,
|
||||
quota = new
|
||||
{
|
||||
dailyLimit = 50,
|
||||
monthlyLimit = 1000,
|
||||
dailyUsed = 0L,
|
||||
monthlyUsed = 0L,
|
||||
resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
records
|
||||
},
|
||||
new GetMyMcpUsageResponse
|
||||
{
|
||||
UserId = userId,
|
||||
Upn = null,
|
||||
UsagePlan = null,
|
||||
EffectivePlan = effectivePlan,
|
||||
Quota = new McpUsageQuotaNode
|
||||
{
|
||||
DailyLimit = 50,
|
||||
MonthlyLimit = 1000,
|
||||
DailyUsed = 0,
|
||||
MonthlyUsed = 0,
|
||||
ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetMcpUserUsage -----------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetMcpUserUsageResponse_matches_the_user_usage_shape()
|
||||
{
|
||||
var userId = "8bb31d22-0003-4b7c-a1d2-52f3aaaa0009";
|
||||
var records = SampleUsageRecords(userId);
|
||||
|
||||
AssertParity(
|
||||
new { userId, records },
|
||||
new GetMcpUserUsageResponse { UserId = userId, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var records = new List<UserUsageRecord>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, records },
|
||||
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
|
||||
}
|
||||
|
||||
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
|
||||
{
|
||||
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var summaries = new List<UserUsageDailySummary>
|
||||
{
|
||||
new UserUsageDailySummary
|
||||
{
|
||||
Date = "20260830",
|
||||
TenantId = tenantId,
|
||||
TotalRequests = 321,
|
||||
UniqueUsers = 4,
|
||||
UniqueEndpoints = 11,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
|
||||
{
|
||||
string? tenantId = null;
|
||||
var summaries = new List<UserUsageDailySummary>();
|
||||
|
||||
AssertParity(
|
||||
new { tenantId, summaries },
|
||||
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
|
||||
}
|
||||
|
||||
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void MetricsSummaryResponse_matches_the_tally_shape()
|
||||
{
|
||||
var days = 30;
|
||||
// Left: the exact anonymous per-tenant row GetMetricsSummaryAsync built before the
|
||||
// MetricsSummaryTenantItem substitution — proves the DTO item is wire-identical
|
||||
// (incl. the windowDays duplicate on every item).
|
||||
var summary = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
totalSessions = 10,
|
||||
succeeded = 6,
|
||||
failed = 2,
|
||||
inProgress = 1,
|
||||
pending = 0,
|
||||
stalled = 0,
|
||||
awaitingUser = 0,
|
||||
incomplete = 1,
|
||||
other = 0,
|
||||
failureRate = 25.0,
|
||||
windowDays = days
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, summary, windowDays = days },
|
||||
new MetricsSummaryResponse
|
||||
{
|
||||
Success = true,
|
||||
Summary = new List<MetricsSummaryTenantItem>
|
||||
{
|
||||
new MetricsSummaryTenantItem
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
TotalSessions = 10,
|
||||
Succeeded = 6,
|
||||
Failed = 2,
|
||||
InProgress = 1,
|
||||
Pending = 0,
|
||||
Stalled = 0,
|
||||
AwaitingUser = 0,
|
||||
Incomplete = 1,
|
||||
Other = 0,
|
||||
FailureRate = 25.0,
|
||||
WindowDays = days,
|
||||
},
|
||||
},
|
||||
WindowDays = days,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetRuleHitSessions --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetRuleHitSessionsResponse_matches_the_hit_listing_shape()
|
||||
{
|
||||
var ruleId = "APP-017";
|
||||
var days = 14;
|
||||
var sessionIds = new List<string>
|
||||
{
|
||||
"add53f44-0005-4b7c-a1d2-52f3aaaa0011",
|
||||
"bee64055-0006-4b7c-a1d2-52f3aaaa0012",
|
||||
};
|
||||
const int maxSessionIds = 2000;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
ruleId,
|
||||
days,
|
||||
sessionIds,
|
||||
truncated = sessionIds.Count >= maxSessionIds
|
||||
},
|
||||
new GetRuleHitSessionsResponse
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Days = days,
|
||||
SessionIds = sessionIds,
|
||||
Truncated = sessionIds.Count >= maxSessionIds
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
+1
-7
@@ -585,13 +585,7 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
}
|
||||
|
||||
|
||||
+1
-7
@@ -451,13 +451,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
return new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -277,13 +277,7 @@ public class TenantOffboardingWorkerPoisonTests
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
return new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
return TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet against NEW anonymous-object API responses: <c>req.OkAsync(new { ... })</c> ships a
|
||||
/// shape no type checks, no manifest exports, and the web mirrors by hand — the drift class the
|
||||
/// 2026-08-13 fragility audit found across 44 call sites. New/changed endpoints return typed
|
||||
/// response DTOs (AutopilotMonitor.Shared.Models, picked up by SharedManifestParityTests);
|
||||
/// the existing sites are frozen as a per-file baseline that may only SHRINK:
|
||||
/// - a new anonymous OkAsync (new file, or count above baseline) fails,
|
||||
/// - converting a site to a typed DTO fails too until its baseline entry is lowered/removed,
|
||||
/// so the baseline always reflects reality and the debt is visible in the diff.
|
||||
/// Typed object initializers (<c>OkAsync(new SomeResponse { ... })</c>) do not match.
|
||||
/// </summary>
|
||||
public class OkAsyncBaselineGuardTests
|
||||
{
|
||||
private static readonly Regex AnonymousOkAsync = new(@"OkAsync\(\s*new\s*\{", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Frozen 2026-08-13 baseline (44 sites / 34 files), relative to the Functions project.
|
||||
/// Only ever lower a count or delete an entry — never raise or add.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> Baseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Admin/GetActiveUsersFunction.cs"] = 1,
|
||||
["Functions/Admin/GetAuditLogsFunction.cs"] = 2,
|
||||
["Functions/Admin/GetGlobalAuditLogsFunction.cs"] = 2,
|
||||
["Functions/Admin/GetOpsEventsFunction.cs"] = 2,
|
||||
["Functions/Annotations/GetSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/ListSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/ListTenantSessionAnnotationsFunction.cs"] = 1,
|
||||
["Functions/Annotations/UpsertSessionAnnotationFunction.cs"] = 2,
|
||||
["Functions/Config/GetAllTenantConfigurationsFunction.cs"] = 2,
|
||||
["Functions/Graph/GetGraphPermissionsStatusFunction.cs"] = 1,
|
||||
["Functions/Graph/GetScriptDisplayNamesFunction.cs"] = 3,
|
||||
["Functions/Graph/RefreshGraphPermissionsFunction.cs"] = 1,
|
||||
["Functions/Raw/QueryRawEventsFunction.cs"] = 2,
|
||||
["Functions/Raw/QueryRawSessionsFunction.cs"] = 1,
|
||||
["Functions/Reports/GetDeviceNotRegisteredFunction.cs"] = 1,
|
||||
["Functions/Reports/GetHardwareRejectedFunction.cs"] = 1,
|
||||
["Functions/Reports/GetSessionReportsFunction.cs"] = 2,
|
||||
["Functions/Reports/GetTpmPssUnsupportedFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetAllSessionStatsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetAllSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionDecisionGraphFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionDeletionsListFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionEventsFunction.cs"] = 2,
|
||||
["Functions/Sessions/GetSessionFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionReducerVerificationFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionSignalsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionStatsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetTenantDeletionManifestsFunction.cs"] = 1,
|
||||
["Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs"] = 1,
|
||||
["Functions/Sessions/QuickSearchSessionsFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsByCveFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsByEventFunction.cs"] = 1,
|
||||
["Functions/Sessions/SearchSessionsFunction.cs"] = 1,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_OkAsync_sites_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var actual = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.Contains("/bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.Contains("/obj/", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var count = AnonymousOkAsync.Matches(File.ReadAllText(file)).Count;
|
||||
if (count > 0)
|
||||
actual[relative] = count;
|
||||
}
|
||||
|
||||
var violations = new List<string>();
|
||||
|
||||
foreach (var (file, count) in actual.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!Baseline.TryGetValue(file, out var allowed))
|
||||
violations.Add($"{file}: {count} NEW anonymous OkAsync site(s) — return a typed response DTO instead.");
|
||||
else if (count > allowed)
|
||||
violations.Add($"{file}: {count} anonymous OkAsync site(s), baseline allows {allowed} — return a typed response DTO for the new one(s).");
|
||||
}
|
||||
|
||||
// Ratchet: a converted site must also lower its baseline entry, so the frozen debt
|
||||
// list stays truthful (and its shrink is visible in the diff).
|
||||
foreach (var (file, allowed) in Baseline.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
actual.TryGetValue(file, out var count);
|
||||
if (count < allowed)
|
||||
violations.Add($"{file}: baseline says {allowed} but only {count} remain — lower/remove its entry in {nameof(OkAsyncBaselineGuardTests)} (ratchet down).");
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
"Anonymous OkAsync(new { ... }) baseline violated:\n " + string.Join("\n ", violations));
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ops-event payload delivery: the structured Details object reaches outbound channels, both as
|
||||
/// readable facts (card + Telegram formats) and verbatim under "data" (generic JSON consumers).
|
||||
/// Before this, an alert carried only category/event/severity and a tenant GUID.
|
||||
/// </summary>
|
||||
public class OpsAlertPayloadTests
|
||||
{
|
||||
// ── Fact flattening ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FlattenDetails_ProjectsTopLevelScalars()
|
||||
{
|
||||
var facts = OpsAlertDispatchService.FlattenDetails(
|
||||
"""{"domainName":"contoso.example","daysLeft":3,"selfService":true}""");
|
||||
|
||||
Assert.Equal(3, facts.Count);
|
||||
Assert.Equal("Domain Name", facts[0].Name);
|
||||
Assert.Equal("contoso.example", facts[0].Value);
|
||||
Assert.Equal("Days Left", facts[1].Name);
|
||||
Assert.Equal("3", facts[1].Value);
|
||||
Assert.Equal("Self Service", facts[2].Name);
|
||||
Assert.Equal("true", facts[2].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlattenDetails_SkipsNullsAndNestedValues()
|
||||
{
|
||||
var facts = OpsAlertDispatchService.FlattenDetails(
|
||||
"""{"domainName":null,"nested":{"a":1},"list":[1,2],"keep":"yes"}""");
|
||||
|
||||
Assert.Equal(new[] { "Keep" }, facts.Select(f => f.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlattenDetails_CapsFactCount()
|
||||
{
|
||||
var props = string.Join(",", Enumerable.Range(0, 30).Select(i => $"\"p{i}\":\"v\""));
|
||||
|
||||
Assert.Equal(OpsAlertDispatchService.MaxDetailFacts,
|
||||
OpsAlertDispatchService.FlattenDetails("{" + props + "}").Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlattenDetails_TruncatesLongValues()
|
||||
{
|
||||
var longValue = new string('x', 400);
|
||||
|
||||
var fact = Assert.Single(OpsAlertDispatchService.FlattenDetails($$"""{"note":"{{longValue}}"}"""));
|
||||
|
||||
Assert.Equal(OpsAlertDispatchService.MaxDetailValueLength + 1, fact.Value.Length); // + ellipsis
|
||||
Assert.EndsWith("…", fact.Value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("not-json")]
|
||||
[InlineData("[1,2,3]")] // array root
|
||||
[InlineData("\"a string\"")] // scalar root
|
||||
public void FlattenDetails_MalformedOrNonObjectPayloadCostsNothing(string? json)
|
||||
=> Assert.Empty(OpsAlertDispatchService.FlattenDetails(json));
|
||||
|
||||
[Theory]
|
||||
[InlineData("domainName", "Domain Name")]
|
||||
[InlineData("trialExpiresUtc", "Trial Expires Utc")]
|
||||
[InlineData("id", "Id")]
|
||||
[InlineData("URL", "URL")] // consecutive capitals are not split
|
||||
public void Humanize_TitleCasesCamelCaseKeys(string input, string expected)
|
||||
=> Assert.Equal(expected, OpsAlertDispatchService.Humanize(input));
|
||||
|
||||
// ── Generic JSON renderer contract ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_EmbedsDataAsRealJsonNotAString()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
Title = "t", Summary = "s", EventType = "TenantTrialStarted",
|
||||
DataJson = """{"domainName":"contoso.example","selfService":true}""",
|
||||
};
|
||||
|
||||
var payload = JObject.Parse(new GenericJsonRenderer().RenderToJson(alert));
|
||||
|
||||
Assert.Equal(JTokenType.Object, payload["data"]!.Type);
|
||||
Assert.Equal("contoso.example", payload["data"]!["domainName"]!.Value<string>());
|
||||
Assert.True(payload["data"]!["selfService"]!.Value<bool>());
|
||||
Assert.Equal("TenantTrialStarted", payload["eventType"]!.Value<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_OmitsDataWhenAbsentOrUnusable()
|
||||
{
|
||||
var renderer = new GenericJsonRenderer();
|
||||
|
||||
Assert.Null(JObject.Parse(renderer.RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s" }))["data"]);
|
||||
|
||||
// Malformed or non-object payloads must not change the shape consumers parse against.
|
||||
Assert.Null(JObject.Parse(renderer.RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s", DataJson = "not-json" }))["data"]);
|
||||
Assert.Null(JObject.Parse(renderer.RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s", DataJson = "[1,2]" }))["data"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenericJsonRenderer_SchemaVersionUnchangedByTheAdditiveDataKey()
|
||||
=> Assert.Equal("1.0", JObject.Parse(new GenericJsonRenderer().RenderToJson(
|
||||
new NotificationAlert { Title = "t", Summary = "s", DataJson = """{"a":1}""" }))["schemaVersion"]!.Value<string>());
|
||||
|
||||
// ── Telegram text carries the same information ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TelegramRendering_IncludesFlattenedFacts()
|
||||
{
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
Title = "Ops Alert: Tenant/TenantTrialStarted",
|
||||
Summary = "Pro trial started",
|
||||
Severity = NotificationSeverity.Info,
|
||||
Facts = OpsAlertDispatchService.FlattenDetails("""{"domainName":"contoso.example"}"""),
|
||||
};
|
||||
|
||||
var text = TelegramNotificationService.RenderAlertText(alert);
|
||||
|
||||
Assert.Contains("Domain Name: contoso.example", text);
|
||||
Assert.Contains("Pro trial started", text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The cross-category PAGED read fans out over a fixed category list. A category that is written
|
||||
/// but not listed is invisible to every paged reader (MCP get_ops_events, the ops dashboard) while
|
||||
/// the unpaged full-table path still returns it — a silent, one-sided blind spot. That happened to
|
||||
/// Platform (Azure Monitor alerts via the ops alert webhook), which was recorded from day one and
|
||||
/// never fanned out. These tests fail the build when a new OpsEventCategory constant is added
|
||||
/// without extending the fan-out list.
|
||||
/// </summary>
|
||||
public class OpsEventCategoryCoverageTests
|
||||
{
|
||||
private static string[] DeclaredCategories() =>
|
||||
typeof(OpsEventCategory)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
|
||||
.Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToArray();
|
||||
|
||||
[Fact]
|
||||
public void All_CoversEveryDeclaredCategoryConstant()
|
||||
{
|
||||
var declared = DeclaredCategories();
|
||||
|
||||
Assert.NotEmpty(declared);
|
||||
Assert.Equal(declared.OrderBy(c => c), OpsEventCategory.All.OrderBy(c => c));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PagedFanOut_UsesTheFullCategoryVocabulary()
|
||||
{
|
||||
// Same array instance on purpose: the fan-out must not keep a private copy that can drift.
|
||||
Assert.Same(OpsEventCategory.All, TableOpsEventRepository.AllCategories);
|
||||
Assert.Contains(OpsEventCategory.Platform, TableOpsEventRepository.AllCategories);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the ops-events field filters (eventType / severity / minSeverity). Two properties matter:
|
||||
/// (1) they are SERVER-SIDE — an operator asking for one event type must not pull a whole category
|
||||
/// over the wire, and the paged fan-out must honour the exact same clauses as the single-category
|
||||
/// and unpaged paths; (2) they are bound into the continuation fingerprint, so a token minted for
|
||||
/// one filter cannot page a different one.
|
||||
/// </summary>
|
||||
public class OpsEventFieldFilterTests
|
||||
{
|
||||
private static NameValueCollection Query(params (string Key, string Value)[] pairs)
|
||||
{
|
||||
var q = new NameValueCollection();
|
||||
foreach (var (k, v) in pairs) q[k] = v;
|
||||
return q;
|
||||
}
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_NoFilterParams_YieldsEmptyFilters_AndNoExtras()
|
||||
{
|
||||
var result = OpsEventFilterRequest.Parse(Query(("category", "Agent")));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.True(result.Filters.IsEmpty);
|
||||
Assert.Empty(OpsEventFilterRequest.ToExtras(result.Filters));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EventType_IsVerbatim_NotCaseFolded()
|
||||
{
|
||||
// Table Storage cannot fold case: whatever the caller names is what the store is asked for.
|
||||
var result = OpsEventFilterRequest.Parse(Query(("eventType", "AgentEmergencyBreak")));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal("AgentEmergencyBreak", result.Filters.EventType);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("warning", "Warning")]
|
||||
[InlineData("ERROR", "Error")]
|
||||
[InlineData("Critical", "Critical")]
|
||||
public void Parse_Severity_IsNormalisedToCanonicalCasing(string input, string expected)
|
||||
{
|
||||
// Without normalisation a "?severity=warning" would produce a Severity eq 'warning'
|
||||
// clause, which matches zero rows and reads like "nothing happened".
|
||||
var result = OpsEventFilterRequest.Parse(Query(("severity", input)));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(expected, result.Filters.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UnknownSeverity_IsRejected_NotSilentlyDropped()
|
||||
{
|
||||
var severity = OpsEventFilterRequest.Parse(Query(("severity", "Warn")));
|
||||
var minSeverity = OpsEventFilterRequest.Parse(Query(("minSeverity", "sev2")));
|
||||
|
||||
Assert.NotNull(severity.Error);
|
||||
Assert.Contains("severity must be one of", severity.Error);
|
||||
Assert.Contains("Warn", severity.Error);
|
||||
Assert.NotNull(minSeverity.Error);
|
||||
Assert.Contains("minSeverity must be one of", minSeverity.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToExtras_EmitsNormalisedValues_InFixedOrder()
|
||||
{
|
||||
// Fixed order + normalised values: the fingerprint minted here must match the one
|
||||
// recomputed when the echoed nextLink params come back on the follow-up request.
|
||||
var result = OpsEventFilterRequest.Parse(Query(
|
||||
("severity", "error"), ("eventType", "AgentEmergencyBreak"), ("minSeverity", "warning")));
|
||||
|
||||
var extras = OpsEventFilterRequest.ToExtras(result.Filters);
|
||||
|
||||
Assert.Equal(new[] { "eventType", "severity", "minSeverity" }, extras.Select(e => e.Key).ToArray());
|
||||
Assert.Equal(new[] { "AgentEmergencyBreak", "Error", "Warning" }, extras.Select(e => e.Value).ToArray());
|
||||
}
|
||||
|
||||
// ── Severity ladder ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SeverityLadder_RanksInfoLowest_CriticalHighest()
|
||||
{
|
||||
Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Info) < OpsEventSeverity.Rank(OpsEventSeverity.Warning));
|
||||
Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Warning) < OpsEventSeverity.Rank(OpsEventSeverity.Error));
|
||||
Assert.True(OpsEventSeverity.Rank(OpsEventSeverity.Error) < OpsEventSeverity.Rank(OpsEventSeverity.Critical));
|
||||
Assert.Equal(-1, OpsEventSeverity.Rank("Warn"));
|
||||
Assert.Equal(-1, OpsEventSeverity.Rank(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AtOrAbove_ReturnsTheThresholdAndEverythingAboveIt()
|
||||
{
|
||||
Assert.Equal(new[] { "Warning", "Error", "Critical" }, OpsEventSeverity.AtOrAbove("Warning").ToArray());
|
||||
Assert.Equal(new[] { "Critical" }, OpsEventSeverity.AtOrAbove("Critical").ToArray());
|
||||
Assert.Equal(OpsEventSeverity.All, OpsEventSeverity.AtOrAbove("Info").ToArray());
|
||||
Assert.Empty(OpsEventSeverity.AtOrAbove("Warn"));
|
||||
}
|
||||
|
||||
// ── Clause building (both builders) ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_EmitsServerSideClauses_ForEveryField()
|
||||
{
|
||||
var f = TableOpsEventRepository.BuildFilter(
|
||||
"Agent", null, null,
|
||||
new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", Severity = "Error" });
|
||||
|
||||
Assert.NotNull(f);
|
||||
Assert.Contains("EventType eq 'AgentEmergencyBreak'", f);
|
||||
Assert.Contains("Severity eq 'Error'", f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_MinSeverity_ExpandsToOrSet_NotALexicographicRange()
|
||||
{
|
||||
// "Critical" < "Error" < "Info" < "Warning" lexicographically — a ge comparison would
|
||||
// silently return the wrong rows, so the threshold has to become an OR-set.
|
||||
var f = TableOpsEventRepository.BuildFilter(
|
||||
"Agent", null, null, new OpsEventQueryFilters { MinSeverity = "Warning" });
|
||||
|
||||
Assert.NotNull(f);
|
||||
Assert.Contains("(Severity eq 'Warning' or Severity eq 'Error' or Severity eq 'Critical')", f);
|
||||
Assert.DoesNotContain("Severity ge", f);
|
||||
Assert.DoesNotContain("Severity eq 'Info'", f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_MinSeverityInfo_EmitsNoClause_ItIsTheFloor()
|
||||
{
|
||||
var f = TableOpsEventRepository.BuildFilter(
|
||||
"Agent", null, null, new OpsEventQueryFilters { MinSeverity = "Info" });
|
||||
|
||||
Assert.Equal("PartitionKey eq 'Agent'", f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_NoFilters_IsUnchanged()
|
||||
{
|
||||
// Regression guard: the pre-existing filter surface must not shift when no field filter
|
||||
// is named — every stored continuation token depends on the same rows coming back.
|
||||
Assert.Equal(
|
||||
TableOpsEventRepository.BuildFilter("Agent", null, null),
|
||||
TableOpsEventRepository.BuildFilter("Agent", null, null, new OpsEventQueryFilters()));
|
||||
Assert.Null(TableOpsEventRepository.BuildFilter(null, null, null, new OpsEventQueryFilters()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFilter_EscapesQuotes_SoAFilterCannotBreakOutOfItsLiteral()
|
||||
{
|
||||
var f = TableOpsEventRepository.BuildFilter(
|
||||
null, null, null, new OpsEventQueryFilters { EventType = "Agent'Break" });
|
||||
|
||||
Assert.Equal("EventType eq 'Agent''Break'", f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FanOutBuilder_HonoursTheSameFieldClauses_AsTheSingleCategoryBuilder()
|
||||
{
|
||||
// The all-categories paged path builds its own filter (it adds the per-partition RowKey
|
||||
// bound). If it skipped the field filters, "no category named" would silently return
|
||||
// unfiltered rows — the exact bug this test exists to prevent.
|
||||
var filters = new OpsEventQueryFilters { EventType = "AgentEmergencyBreak", MinSeverity = "Error" };
|
||||
|
||||
var fanOut = TableOpsEventRepository.BuildFilterWithRowKeyBound("Agent", null, null, "0009", filters);
|
||||
|
||||
Assert.Contains("EventType eq 'AgentEmergencyBreak'", fanOut);
|
||||
Assert.Contains("(Severity eq 'Error' or Severity eq 'Critical')", fanOut);
|
||||
Assert.Contains("RowKey gt '0009'", fanOut);
|
||||
}
|
||||
|
||||
// ── Continuation fingerprint ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_BindsTheFieldFilters_SoATokenCannotCrossFilters()
|
||||
{
|
||||
var from = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var to = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
const string caller = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
string Fp(params (string Key, string Value)[] pairs) => DateWindowPagination.Fingerprint(
|
||||
scope: "ops-events", callerTenantId: caller, dateFrom: from, dateTo: to,
|
||||
extras: OpsEventFilterRequest.ToExtras(OpsEventFilterRequest.Parse(Query(pairs)).Filters));
|
||||
|
||||
var unfiltered = Fp();
|
||||
var byType = Fp(("eventType", "AgentEmergencyBreak"));
|
||||
var byOtherType = Fp(("eventType", "AgentTimeout"));
|
||||
var byMinSeverity = Fp(("minSeverity", "Error"));
|
||||
|
||||
Assert.NotEqual(unfiltered, byType);
|
||||
Assert.NotEqual(byType, byOtherType);
|
||||
Assert.NotEqual(unfiltered, byMinSeverity);
|
||||
// Casing is normalised before fingerprinting, so the same query in two spellings
|
||||
// shares one token instead of minting two that each reject the other.
|
||||
Assert.Equal(byMinSeverity, Fp(("minSeverity", "error")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_WithNoFieldFilters_MatchesThePreFilterToken()
|
||||
{
|
||||
// Backward compatibility: tokens minted before the field filters existed carry only the
|
||||
// category/tenantId extras. Appending the (empty) field extras must not change the hash,
|
||||
// or every in-flight pagination would break on deploy.
|
||||
var from = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
const string caller = "11111111-1111-1111-1111-111111111111";
|
||||
var legacyExtras = new List<KeyValuePair<string, string?>>
|
||||
{
|
||||
new KeyValuePair<string, string?>("category", "Agent"),
|
||||
};
|
||||
var withEmptyFilters = new List<KeyValuePair<string, string?>>(legacyExtras);
|
||||
withEmptyFilters.AddRange(OpsEventFilterRequest.ToExtras(new OpsEventQueryFilters()));
|
||||
|
||||
Assert.Equal(
|
||||
DateWindowPagination.Fingerprint("ops-events", caller, from, null, legacyExtras),
|
||||
DateWindowPagination.Fingerprint("ops-events", caller, from, null, withEmptyFilters));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Every ops event type the backend can write must be selectable in the portal's alert-rule
|
||||
/// editor (<c>OpsAlertRulesSection.tsx</c> <c>OPS_EVENT_TYPES</c>) — the "dual register" that
|
||||
/// used to be a memory-only convention. A type written but not listed cannot be routed to a
|
||||
/// Telegram/webhook rule, which is exactly how a new Warning stays silent.
|
||||
/// Every ops event type the backend can write must be (a) declared in the canonical
|
||||
/// <see cref="OpsEventTypes"/> vocabulary and (b) selectable in the portal's alert-rule editor
|
||||
/// (<c>OpsAlertRulesSection.tsx</c> <c>OPS_EVENT_TYPES</c>). A type written but not declared is
|
||||
/// invisible to every consumer that enumerates the vocabulary (the shared manifest, and through
|
||||
/// it the MCP); a type declared but not listed in the editor cannot be routed to a channel, which
|
||||
/// is exactly how a new Warning stays silent.
|
||||
/// </summary>
|
||||
public class OpsEventTypeDualRegisterTests
|
||||
{
|
||||
@@ -20,27 +24,78 @@ public class OpsEventTypeDualRegisterTests
|
||||
return dir!.FullName;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_backend_ops_event_type_is_listed_in_the_web_alert_rule_editor()
|
||||
{
|
||||
var root = RepoRoot();
|
||||
var service = File.ReadAllText(Path.Combine(root, "src", "Backend", "AutopilotMonitor.Functions", "Services", "OpsEventService.cs"));
|
||||
var web = File.ReadAllText(Path.Combine(root, "src", "Web", "autopilot-monitor-web", "app", "admin", "components", "OpsAlertRulesSection.tsx"));
|
||||
private static string OpsEventServiceSource() => File.ReadAllText(Path.Combine(
|
||||
RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions", "Services", "OpsEventService.cs"));
|
||||
|
||||
// WriteAsync(OpsEventCategory.X, "TypeName", ...) — the second argument is the wire type.
|
||||
var backendTypes = Regex.Matches(service, @"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*""([A-Za-z0-9]+)""")
|
||||
/// <summary>The vocabulary as the assembly declares it — reflection, not a source scan.</summary>
|
||||
private static string[] DeclaredTypes() =>
|
||||
typeof(OpsEventTypes)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToArray();
|
||||
|
||||
[Fact]
|
||||
public void All_listsEveryDeclaredConstant()
|
||||
{
|
||||
// OpsEventTypes.All is what the manifest and every enumerating consumer read.
|
||||
Assert.Equal(
|
||||
DeclaredTypes().OrderBy(t => t, StringComparer.Ordinal),
|
||||
OpsEventTypes.All.OrderBy(t => t, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_write_site_uses_the_vocabulary_constant_not_a_raw_literal()
|
||||
{
|
||||
// The guard that keeps the reflection above honest: a bare string at a call site would
|
||||
// write a real event type that the vocabulary — and therefore every consumer — never sees.
|
||||
var literalWrites = Regex.Matches(
|
||||
OpsEventServiceSource(),
|
||||
@"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*""([A-Za-z0-9]+)""")
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
Assert.NotEmpty(backendTypes);
|
||||
|
||||
Assert.True(literalWrites.Count == 0,
|
||||
"OpsEventService writes these event types as raw string literals — declare them in " +
|
||||
"OpsEventTypes and use the constant: " + string.Join(", ", literalWrites));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_write_site_names_a_declared_constant()
|
||||
{
|
||||
// Catches the other direction of the same seam: `OpsEventTypes.Foo` cannot compile unless
|
||||
// it exists, so this asserts the extraction below still SEES the call sites — a refactor
|
||||
// that renames WriteAsync would otherwise silently reduce this suite to vacuous truth.
|
||||
var used = Regex.Matches(
|
||||
OpsEventServiceSource(),
|
||||
@"WriteAsync\(\s*OpsEventCategory\.\w+\s*,\s*OpsEventTypes\.(\w+)")
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.True(used.Count > 50,
|
||||
$"Only {used.Count} ops-event write sites found — the extraction pattern is likely " +
|
||||
"broken (WriteAsync renamed?), not the call sites.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_declared_ops_event_type_is_listed_in_the_web_alert_rule_editor()
|
||||
{
|
||||
var web = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Web", "autopilot-monitor-web",
|
||||
"app", "admin", "components", "OpsAlertRulesSection.tsx"));
|
||||
|
||||
var webTypes = Regex.Matches(web, @"""([A-Za-z0-9]+)""")
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = backendTypes.Where(t => !webTypes.Contains(t)).ToList();
|
||||
var missing = DeclaredTypes()
|
||||
.Where(t => !webTypes.Contains(t))
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
"Ops event types written by OpsEventService but missing from OPS_EVENT_TYPES in OpsAlertRulesSection.tsx: " + string.Join(", ", missing));
|
||||
"Ops event types declared in OpsEventTypes but missing from OPS_EVENT_TYPES in " +
|
||||
"OpsAlertRulesSection.tsx: " + string.Join(", ", missing));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Platform (ops) notification channels: legacy-slot synthesis so the pre-channels dispatch
|
||||
/// behavior survives the migration untouched, per-rule channel routing, and redaction of the
|
||||
/// new secret-bearing field.
|
||||
/// </summary>
|
||||
public class OpsNotificationChannelsTests
|
||||
{
|
||||
private static AdminConfiguration LegacyConfig(
|
||||
bool telegramEnabled = true, string telegramChat = "-1003785642894",
|
||||
bool teamsEnabled = true, string teamsUrl = "https://teams.example/hook",
|
||||
bool slackEnabled = false, string slackUrl = "")
|
||||
=> new()
|
||||
{
|
||||
UpdatedBy = "test",
|
||||
OpsAlertTelegramEnabled = telegramEnabled,
|
||||
OpsAlertTelegramChatId = telegramChat,
|
||||
OpsAlertTeamsEnabled = teamsEnabled,
|
||||
OpsAlertTeamsWebhookUrl = teamsUrl,
|
||||
OpsAlertSlackEnabled = slackEnabled,
|
||||
OpsAlertSlackWebhookUrl = slackUrl,
|
||||
};
|
||||
|
||||
private static NotificationChannel Channel(string id, bool enabled = true, int providerType = 20)
|
||||
=> new() { Id = id, Name = id, ProviderType = providerType, Url = "https://x.example/" + id, Enabled = enabled };
|
||||
|
||||
private static OpsAlertRule Rule(string eventType, params string[] channelIds)
|
||||
=> new()
|
||||
{
|
||||
EventType = eventType,
|
||||
MinSeverity = OpsEventSeverity.Info,
|
||||
Enabled = true,
|
||||
NotifyChannelIds = channelIds.Length == 0 ? null : channelIds.ToList(),
|
||||
};
|
||||
|
||||
private static OpsAlertRule PayloadRule(string eventType, params string[] channelIds)
|
||||
{
|
||||
var rule = Rule(eventType, channelIds);
|
||||
rule.IncludePayload = true;
|
||||
return rule;
|
||||
}
|
||||
|
||||
/// <summary>Channels that receive the baseline alert (no payload) - the default for every rule.</summary>
|
||||
private static IEnumerable<string> Plain(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
|
||||
=> OpsAlertDispatchService.ResolveTargets(rules, channels).Plain.Select(c => c.Id);
|
||||
|
||||
/// <summary>Channels that receive the event structured payload (opted in per rule).</summary>
|
||||
private static IEnumerable<string> WithPayload(IReadOnlyList<OpsAlertRule> rules, IReadOnlyList<NotificationChannel> channels)
|
||||
=> OpsAlertDispatchService.ResolveTargets(rules, channels).WithPayload.Select(c => c.Id);
|
||||
|
||||
// ── Legacy synthesis ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_SynthesizesConfiguredLegacySlots()
|
||||
{
|
||||
var channels = LegacyConfig().GetOpsNotificationChannels();
|
||||
|
||||
Assert.Equal(2, channels.Count); // Slack slot is empty → not synthesized
|
||||
var telegram = channels[0];
|
||||
Assert.Equal(AdminConfiguration.LegacyTelegramChannelId, telegram.Id);
|
||||
Assert.Equal(AdminConfiguration.LegacyTelegramChannelName, telegram.Name);
|
||||
Assert.Equal((int)WebhookProviderType.Telegram, telegram.ProviderType);
|
||||
Assert.Equal("-1003785642894", telegram.Url);
|
||||
Assert.True(telegram.Enabled);
|
||||
|
||||
Assert.Equal(AdminConfiguration.LegacyTeamsChannelId, channels[1].Id);
|
||||
Assert.Equal((int)WebhookProviderType.TeamsWorkflowWebhook, channels[1].ProviderType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_CarriesLegacyEnabledFlags()
|
||||
{
|
||||
// A configured-but-disabled provider must stay disabled, or the migration would start
|
||||
// sending to a destination the operator had deliberately switched off.
|
||||
var channels = LegacyConfig(telegramEnabled: false).GetOpsNotificationChannels();
|
||||
|
||||
Assert.False(channels.Single(c => c.Id == AdminConfiguration.LegacyTelegramChannelId).Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_EmptyWhenNothingConfigured()
|
||||
=> Assert.Empty(new AdminConfiguration { UpdatedBy = "t" }.GetOpsNotificationChannels());
|
||||
|
||||
[Fact]
|
||||
public void GetOpsNotificationChannels_StoredListWinsOverLegacySlots()
|
||||
{
|
||||
var config = LegacyConfig();
|
||||
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[] { Channel("sales") });
|
||||
|
||||
var channels = config.GetOpsNotificationChannels();
|
||||
|
||||
Assert.Single(channels);
|
||||
Assert.Equal("sales", channels[0].Id);
|
||||
}
|
||||
|
||||
// ── Rule → channel routing ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_RuleWithoutChannelIds_HitsEveryEnabledChannel()
|
||||
{
|
||||
var channels = new[] { Channel("a"), Channel("b") };
|
||||
|
||||
Assert.Equal(new[] { "a", "b" }, Plain(new[] { Rule("X") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_RuleWithChannelIds_HitsOnlyThose()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales") };
|
||||
|
||||
Assert.Equal(new[] { "sales" }, Plain(new[] { Rule("X", "sales") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_NeverSendsToADisabledChannel()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales", enabled: false) };
|
||||
|
||||
Assert.Empty(Plain(new[] { Rule("X", "sales") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_UnknownChannelIdDoesNotFallBackToBroadcast()
|
||||
{
|
||||
// A rule whose only channel was deleted must go nowhere. Falling back to "all" would
|
||||
// silently leak e.g. a sales-only event into the operator push channel.
|
||||
var channels = new[] { Channel("push") };
|
||||
|
||||
Assert.Empty(Plain(new[] { Rule("X", "deleted-channel") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_MultipleRules_UnionOfChannelsWithoutDuplicates()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales"), Channel("audit") };
|
||||
|
||||
Assert.Equal(new[] { "sales", "audit" },
|
||||
Plain(new[] { Rule("X", "sales"), Rule("X", "sales", "audit") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_OneBroadcastRuleWins()
|
||||
{
|
||||
// If any matching rule is unrestricted, the union is everything - a narrower sibling rule
|
||||
// cannot shrink an existing broadcast rule reach.
|
||||
var channels = new[] { Channel("push"), Channel("sales") };
|
||||
|
||||
Assert.Equal(2, Plain(new[] { Rule("X", "sales"), Rule("X") }, channels).Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_ChannelIdMatchIsCaseInsensitive()
|
||||
{
|
||||
var channels = new[] { Channel("Sales") };
|
||||
|
||||
Assert.Single(Plain(new[] { Rule("X", "sales") }, channels));
|
||||
}
|
||||
|
||||
// ── Payload opt-in ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_PayloadIsOffUnlessTheRuleOptsIn()
|
||||
{
|
||||
// The default information level of every pre-existing rule: category, event, severity,
|
||||
// tenant id. Nothing from the event payload.
|
||||
var channels = new[] { Channel("push") };
|
||||
|
||||
Assert.Empty(WithPayload(new[] { Rule("X") }, channels));
|
||||
Assert.Equal(new[] { "push" }, Plain(new[] { Rule("X") }, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_OptedInRuleSendsPayloadToItsChannelsOnly()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales") };
|
||||
var rules = new[] { Rule("X", "push"), PayloadRule("X", "sales") };
|
||||
|
||||
Assert.Equal(new[] { "sales" }, WithPayload(rules, channels));
|
||||
Assert.Equal(new[] { "push" }, Plain(rules, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_ChannelInBothGroupsGetsThePayloadOnceOnly()
|
||||
{
|
||||
// One message per channel, and the explicit opt-in must not be cancelled by an
|
||||
// unrelated sibling rule that happens to target the same channel.
|
||||
var channels = new[] { Channel("sales") };
|
||||
var rules = new[] { Rule("X", "sales"), PayloadRule("X", "sales") };
|
||||
|
||||
Assert.Equal(new[] { "sales" }, WithPayload(rules, channels));
|
||||
Assert.Empty(Plain(rules, channels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTargets_OptedInBroadcastRuleReachesEveryEnabledChannel()
|
||||
{
|
||||
var channels = new[] { Channel("push"), Channel("sales") };
|
||||
|
||||
Assert.Equal(new[] { "push", "sales" }, WithPayload(new[] { PayloadRule("X") }, channels));
|
||||
Assert.Empty(Plain(new[] { PayloadRule("X") }, channels));
|
||||
}
|
||||
|
||||
// ── Redaction ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RedactedCopyForReader_RedactsOpsChannelDestinationsButKeepsStructure()
|
||||
{
|
||||
var config = new AdminConfiguration { UpdatedBy = "t" };
|
||||
config.OpsNotificationChannelsJson = NotificationChannel.SerializeList(new[]
|
||||
{
|
||||
new NotificationChannel
|
||||
{
|
||||
Id = "sales", Name = "Sales", ProviderType = 20,
|
||||
Url = "https://sales.example/hook", SigningSecret = "s3cr3t-signing-key-value",
|
||||
Enabled = true,
|
||||
},
|
||||
});
|
||||
|
||||
var redacted = NotificationChannel.ParseList(
|
||||
config.RedactedCopyForReader().OpsNotificationChannelsJson).Single();
|
||||
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.Url);
|
||||
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.SigningSecret);
|
||||
Assert.Equal("Sales", redacted.Name); // structure stays readable
|
||||
Assert.Equal(20, redacted.ProviderType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRedactedList_RestoresSentinelDestinationById()
|
||||
{
|
||||
var stored = NotificationChannel.SerializeList(new[]
|
||||
{
|
||||
new NotificationChannel { Id = "sales", Name = "Sales", ProviderType = 20, Url = "https://sales.example/hook", Enabled = true },
|
||||
});
|
||||
var roundTripped = NotificationChannel.SerializeList(new[]
|
||||
{
|
||||
new NotificationChannel { Id = "sales", Name = "Sales renamed", ProviderType = 20, Url = Constants.RedactedSecretPlaceholder, Enabled = true },
|
||||
});
|
||||
|
||||
var restored = NotificationChannel.ParseList(
|
||||
NotificationChannel.RestoreRedactedList(roundTripped, stored)).Single();
|
||||
|
||||
Assert.Equal("https://sales.example/hook", restored.Url);
|
||||
Assert.Equal("Sales renamed", restored.Name); // non-secret edits survive
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRedactedList_WholeStringSentinelKeepsStoredList()
|
||||
{
|
||||
var stored = NotificationChannel.SerializeList(new[] { Channel("sales") });
|
||||
|
||||
Assert.Equal(stored, NotificationChannel.RestoreRedactedList(Constants.RedactedSecretPlaceholder, stored));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestoreRedactedList_PassesThroughWhenNoSentinelPresent()
|
||||
{
|
||||
var candidate = NotificationChannel.SerializeList(new[] { Channel("sales") });
|
||||
|
||||
Assert.Equal(candidate, NotificationChannel.RestoreRedactedList(candidate, ""));
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -6,6 +6,7 @@ using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
@@ -79,7 +80,7 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminEntity>
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>
|
||||
{
|
||||
new() { TenantId = TenantId, Upn = "former@contoso.com", IsEnabled = false, Role = Constants.TenantRoles.Admin },
|
||||
});
|
||||
@@ -93,9 +94,9 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), Mock.Of<IMemoryCache>(), Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
var members = new List<TenantAdminEntity>();
|
||||
var members = new List<TenantAdminRow>();
|
||||
if (enabledMember)
|
||||
members.Add(new TenantAdminEntity { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
|
||||
members.Add(new TenantAdminRow { TenantId = TenantId, Upn = "admin@contoso.com", IsEnabled = true, Role = Constants.TenantRoles.Admin });
|
||||
admins.Setup(a => a.GetTenantAdminsAsync(TenantId)).ReturnsAsync(members);
|
||||
return admins;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Progress Portal success responses
|
||||
/// (Functions/Progress/ProgressPortalFunction.cs). Each case serializes the OLD anonymous
|
||||
/// literal (copied verbatim from the pre-migration code) against the NEW typed DTO with the
|
||||
/// same values via <see cref="ApiResponseWireParityTests.AssertWireIdentical"/>.
|
||||
/// </summary>
|
||||
public class ProgressWireParityTests
|
||||
{
|
||||
private static SessionSummary MakeSessionSummary() => new SessionSummary
|
||||
{
|
||||
SessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b",
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
SerialNumber = "SN-CONTOSO-0042",
|
||||
DeviceName = "CONTOSO-LT-0042",
|
||||
Manufacturer = "Fabrikam Inc.",
|
||||
Model = "Latitude 5440",
|
||||
StartedAt = new DateTime(2026, 8, 30, 9, 15, 0, DateTimeKind.Utc),
|
||||
CompletedAt = null,
|
||||
CurrentPhase = 3,
|
||||
CurrentPhaseDetail = "Device setup",
|
||||
Status = SessionStatus.InProgress,
|
||||
FailureReason = string.Empty
|
||||
};
|
||||
|
||||
private static List<EnrollmentEvent> MakeEvents() => new List<EnrollmentEvent>
|
||||
{
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventId = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
SessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b",
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
Timestamp = new DateTime(2026, 8, 30, 9, 16, 30, DateTimeKind.Utc),
|
||||
EventType = "phase_transition"
|
||||
},
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventId = "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
|
||||
SessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b",
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
Timestamp = new DateTime(2026, 8, 30, 9, 18, 5, DateTimeKind.Utc),
|
||||
EventType = "app_install_start"
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/progress/sessions/lookup — { success, found, session } with a matched session.
|
||||
[Fact]
|
||||
public void ProgressLookupSession_match_found()
|
||||
{
|
||||
var match = MakeSessionSummary();
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
found = match != null,
|
||||
session = match
|
||||
},
|
||||
new ProgressLookupSessionResponse
|
||||
{
|
||||
Success = true,
|
||||
Found = match != null,
|
||||
Session = match
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/progress/sessions/lookup — no match: session is null and the key must vanish
|
||||
// identically on both sides (WhenWritingNull).
|
||||
[Fact]
|
||||
public void ProgressLookupSession_no_match_omits_session_key()
|
||||
{
|
||||
SessionSummary? match = null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
found = match != null,
|
||||
session = match
|
||||
},
|
||||
new ProgressLookupSessionResponse
|
||||
{
|
||||
Success = true,
|
||||
Found = match != null,
|
||||
Session = match
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/progress/sessions/{sessionId}/events — { success, sessionId, count, events }.
|
||||
[Fact]
|
||||
public void ProgressGetSessionEvents_success_with_events()
|
||||
{
|
||||
var sessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b";
|
||||
var events = MakeEvents();
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId = sessionId,
|
||||
count = events.Count,
|
||||
events = events
|
||||
},
|
||||
new ProgressGetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events
|
||||
});
|
||||
}
|
||||
|
||||
// Same shape with an empty event list — count 0 and an empty array on both sides.
|
||||
[Fact]
|
||||
public void ProgressGetSessionEvents_success_with_empty_list()
|
||||
{
|
||||
var sessionId = "7f3a1c9e-2b4d-4e8f-9a10-5c6d7e8f9a0b";
|
||||
var events = new List<EnrollmentEvent>();
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId = sessionId,
|
||||
count = events.Count,
|
||||
events = events
|
||||
},
|
||||
new ProgressGetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Functions/Raw folder (anonymous-object → typed-DTO migration).
|
||||
/// Each case serializes the OLD anonymous literal exactly as it stood in the pre-migration
|
||||
/// code (keys, order, compile-time value types) against the NEW DTO with the same values.
|
||||
/// Raw rows are PascalCase-verbatim table columns carried as dictionaries — the production
|
||||
/// options set PropertyNamingPolicy (camelCase) but NO DictionaryKeyPolicy, so dictionary
|
||||
/// keys must survive untouched on both sides; one case asserts that explicitly.
|
||||
/// </summary>
|
||||
public class RawWireParityTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions WireOptions = ApiJsonOptions.Create();
|
||||
|
||||
private static List<IReadOnlyDictionary<string, object?>> SampleEventRows() =>
|
||||
new()
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["PartitionKey"] = "d3b7f8a2-1c4e-4f5a-9b6d-0e2f3a4b5c6d_8f1e2d3c-4b5a-6789-abcd-ef0123456789",
|
||||
["RowKey"] = "0000000042",
|
||||
["Timestamp"] = new DateTimeOffset(2026, 8, 30, 12, 34, 56, TimeSpan.Zero),
|
||||
["EventType"] = "esp_phase_change",
|
||||
["Severity"] = 1,
|
||||
["Source"] = "EspWatcher",
|
||||
["Sequence"] = 42L,
|
||||
["DataJson"] = "{\"phase\":\"DeviceSetup\"}",
|
||||
},
|
||||
};
|
||||
|
||||
// ── QueryRawEventsFunction — GET /api/raw/events + /api/global/raw/events ────────────
|
||||
// Old literal (single-session site):
|
||||
// new { tenantId, count = filtered.Count,
|
||||
// events = RawEntityProjection.Project(filtered, fields), nextLink = singleNextLink }
|
||||
// Old literal (cross-session site) has the identical shape:
|
||||
// new { tenantId, count = events.Count,
|
||||
// events = RawEntityProjection.Project(events, fields), nextLink }
|
||||
|
||||
[Fact]
|
||||
public void QueryRawEvents_populated_matches_old_anonymous_shape()
|
||||
{
|
||||
var tenantId = "11111111-2222-3333-4444-555555555555";
|
||||
var events = SampleEventRows();
|
||||
var nextLink = "/api/raw/events?pageSize=50&continuation=abc123";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { tenantId, count = events.Count, events, nextLink },
|
||||
new QueryRawEventsResponse { TenantId = tenantId, Count = events.Count, Events = events, NextLink = nextLink });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryRawEvents_null_tenantId_and_nextLink_keys_vanish_identically()
|
||||
{
|
||||
// Global scope without tenantId filter (cross-tenant) and last page: both keys
|
||||
// disappear under WhenWritingNull — before and after the migration.
|
||||
var tenantId = (string?)null;
|
||||
var events = new List<IReadOnlyDictionary<string, object?>>();
|
||||
var nextLink = (string?)null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { tenantId, count = events.Count, events, nextLink },
|
||||
new QueryRawEventsResponse { TenantId = tenantId, Count = events.Count, Events = events, NextLink = nextLink });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryRawEvents_dictionary_row_keys_stay_pascalcase_verbatim()
|
||||
{
|
||||
var events = SampleEventRows();
|
||||
var json = JsonSerializer.Serialize(
|
||||
new QueryRawEventsResponse { TenantId = "t", Count = events.Count, Events = events, NextLink = null },
|
||||
WireOptions);
|
||||
|
||||
// Envelope properties are camelCased by PropertyNamingPolicy...
|
||||
Assert.Contains("\"tenantId\"", json);
|
||||
Assert.Contains("\"events\"", json);
|
||||
// ...but the raw row keys are dictionary keys: no DictionaryKeyPolicy is set, so
|
||||
// they must remain PascalCase-verbatim stored column names.
|
||||
Assert.Contains("\"PartitionKey\"", json);
|
||||
Assert.Contains("\"RowKey\"", json);
|
||||
Assert.Contains("\"EventType\"", json);
|
||||
Assert.Contains("\"DataJson\"", json);
|
||||
Assert.DoesNotContain("\"partitionKey\"", json);
|
||||
Assert.DoesNotContain("\"eventType\"", json);
|
||||
}
|
||||
|
||||
// ── QueryRawSessionsFunction — GET /api/raw/sessions + /api/global/raw/sessions ──────
|
||||
// Old literal:
|
||||
// new { tenantId, count = page.Items.Count, sessions = sessionsPayload, nextLink }
|
||||
|
||||
[Fact]
|
||||
public void QueryRawSessions_populated_matches_old_anonymous_shape()
|
||||
{
|
||||
var tenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
var sessions = new List<IReadOnlyDictionary<string, object?>>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["PartitionKey"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
["RowKey"] = "9c8b7a65-4321-0fed-cba9-876543210fed",
|
||||
["Status"] = "InProgress",
|
||||
["SerialNumber"] = "VMware-42 00 11 22 33 44 55 66",
|
||||
["AgentVersion"] = "2.14.0.0",
|
||||
["DeviceName"] = "DESKTOP-CONTOSO1",
|
||||
},
|
||||
};
|
||||
var nextLink = "/api/raw/sessions?pageSize=25&continuation=xyz789";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { tenantId, count = sessions.Count, sessions, nextLink },
|
||||
new QueryRawSessionsResponse { TenantId = tenantId, Count = sessions.Count, Sessions = sessions, NextLink = nextLink });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryRawSessions_null_tenantId_and_nextLink_keys_vanish_identically()
|
||||
{
|
||||
var tenantId = (string?)null;
|
||||
var sessions = new List<IReadOnlyDictionary<string, object?>>();
|
||||
var nextLink = (string?)null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { tenantId, count = sessions.Count, sessions, nextLink },
|
||||
new QueryRawSessionsResponse { TenantId = tenantId, Count = sessions.Count, Sessions = sessions, NextLink = nextLink });
|
||||
}
|
||||
|
||||
// ── TableQueryFunction.ListTables — GET /api/global/raw/tables ───────────────────────
|
||||
// Old literal: new { count = tables.Count, tables }
|
||||
|
||||
[Fact]
|
||||
public void ListRawTables_matches_old_anonymous_shape()
|
||||
{
|
||||
var tables = new List<string> { "Sessions", "SessionsIndex", "TenantConfiguration" };
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { count = tables.Count, tables },
|
||||
new ListRawTablesResponse { Count = tables.Count, Tables = tables });
|
||||
}
|
||||
|
||||
// ── TableQueryFunction.QueryTable — GET /api/global/raw/tables/{tableName} ───────────
|
||||
// Old literal: new { table = actualTableName, count = entities.Count, entities, nextLink }
|
||||
// (entities is List<Dictionary<string, object?>> at the call site.)
|
||||
|
||||
[Fact]
|
||||
public void QueryRawTable_populated_matches_old_anonymous_shape()
|
||||
{
|
||||
var table = "SessionsIndex";
|
||||
var entities = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new()
|
||||
{
|
||||
["PartitionKey"] = "11111111-2222-3333-4444-555555555555",
|
||||
["RowKey"] = "8f1e2d3c-4b5a-6789-abcd-ef0123456789",
|
||||
["Timestamp"] = new DateTimeOffset(2026, 8, 30, 8, 0, 0, TimeSpan.Zero),
|
||||
["Status"] = "Succeeded",
|
||||
["RebootCount"] = 2,
|
||||
},
|
||||
};
|
||||
var nextLink = "/api/global/raw/tables/SessionsIndex?pageSize=100&continuation=tok456";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { table, count = entities.Count, entities, nextLink },
|
||||
new QueryRawTableResponse { Table = table, Count = entities.Count, Entities = entities, NextLink = nextLink });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryRawTable_null_nextLink_key_vanishes_identically()
|
||||
{
|
||||
var table = "OpsEvents";
|
||||
var entities = new List<Dictionary<string, object?>>();
|
||||
var nextLink = (string?)null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { table, count = entities.Count, entities, nextLink },
|
||||
new QueryRawTableResponse { Table = table, Count = entities.Count, Entities = entities, NextLink = nextLink });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Diagnostics;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// DoS hardening of the reducer-verification replay: device-uploaded signal payloads are
|
||||
/// attacker-shaped (chunk-stored up to ~1 MB each, 5000 per request). The verifier must
|
||||
/// (a) decode-fold-discard so peak memory is one signal, not the whole stream, and
|
||||
/// (b) finish in seconds on a forged RealmJoin package stream because
|
||||
/// <see cref="FactStringBounds"/> bounds the dedupe keys inside the engine. The
|
||||
/// signal/transition count-imbalance semantics of the pre-streaming implementation are pinned
|
||||
/// here as well because the streaming rewrite changed how that count is produced.
|
||||
/// </summary>
|
||||
public class ReducerVerifierBoundedReplayTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
private static readonly DateTime T0 = new(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc);
|
||||
private static string CurrentReducerVersion { get; } = new DecisionEngine().ReducerVersion;
|
||||
|
||||
private static DecisionSignal MakeSignal(long ordinal, DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload = null) =>
|
||||
new(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: T0.AddSeconds(ordinal),
|
||||
sourceOrigin: "replay-test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"replay:ord-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
|
||||
/// <summary>Fold <paramref name="sigs"/> through the live reducer and materialise the stored shapes.</summary>
|
||||
private static (SignalRecord[] signals, DecisionTransitionRecord[] transitions) Materialise(IReadOnlyList<DecisionSignal> sigs)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial(SessionId, TenantId);
|
||||
var transitions = new DecisionTransitionRecord[sigs.Count];
|
||||
var signals = new SignalRecord[sigs.Count];
|
||||
|
||||
for (var i = 0; i < sigs.Count; i++)
|
||||
{
|
||||
var step = engine.Reduce(state, sigs[i]);
|
||||
state = step.NewState;
|
||||
var t = step.Transition;
|
||||
transitions[i] = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = t.StepIndex,
|
||||
SessionTraceOrdinal = t.SessionTraceOrdinal,
|
||||
SignalOrdinalRef = t.SignalOrdinalRef,
|
||||
OccurredAtUtc = t.OccurredAtUtc,
|
||||
Trigger = t.Trigger,
|
||||
FromStage = t.FromStage.ToString(),
|
||||
ToStage = t.ToStage.ToString(),
|
||||
Taken = t.Taken,
|
||||
DeadEndReason = t.DeadEndReason,
|
||||
ReducerVersion = t.ReducerVersion,
|
||||
PayloadJson = TransitionSerializer.Serialize(t),
|
||||
};
|
||||
signals[i] = new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = sigs[i].SessionSignalOrdinal,
|
||||
SessionTraceOrdinal = sigs[i].SessionTraceOrdinal,
|
||||
Kind = sigs[i].Kind.ToString(),
|
||||
KindSchemaVersion = sigs[i].KindSchemaVersion,
|
||||
OccurredAtUtc = sigs[i].OccurredAtUtc,
|
||||
SourceOrigin = sigs[i].SourceOrigin,
|
||||
PayloadJson = SignalSerializer.Serialize(sigs[i]),
|
||||
};
|
||||
}
|
||||
|
||||
return (signals, transitions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forged_realmjoin_package_stream_replays_in_bounded_time_with_zero_divergence()
|
||||
{
|
||||
// Exploit shape: many equal-length package ids sharing all but the trailing digits.
|
||||
// Bounded to 256 chars inside the engine they collapse to a single package row, so the
|
||||
// per-signal dedupe scan compares one short key instead of 200 × 200 KB.
|
||||
const int packageSignals = 250;
|
||||
const int idLength = 200_000;
|
||||
var sigs = new List<DecisionSignal>(packageSignals + 2)
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted),
|
||||
MakeSignal(1, DecisionSignalKind.RealmJoinDetected,
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" }),
|
||||
};
|
||||
for (var i = 0; i < packageSignals; i++)
|
||||
{
|
||||
sigs.Add(MakeSignal(2 + i, DecisionSignalKind.RealmJoinPackageStarted, new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = new string('p', idLength - 8) + i.ToString("D8"),
|
||||
[DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine,
|
||||
}));
|
||||
}
|
||||
var (signals, transitions) = Materialise(sigs);
|
||||
Assert.All(signals.Skip(2), s => Assert.True(s.PayloadJson.Length > idLength));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Equal(0, report.TransitionDivergenceCount);
|
||||
Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_divergence");
|
||||
// Deserialising 250 × 200 KB JSON blobs is the dominant (linear) cost here; the
|
||||
// pre-fix quadratic scan alone was tens of seconds for this shape.
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10), $"verification took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signal_surplus_beyond_the_journal_is_still_reported_as_count_imbalance()
|
||||
{
|
||||
var sigs = new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted),
|
||||
MakeSignal(1, DecisionSignalKind.AppInstallCompleted),
|
||||
MakeSignal(2, DecisionSignalKind.AppInstallCompleted),
|
||||
};
|
||||
var (signals, transitions) = Materialise(sigs);
|
||||
var shortJournal = new[] { transitions[0], transitions[1] };
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, shortJournal, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Contains(report.Issues, i => i.Kind == "replay_divergence" && i.Message.StartsWith("Signal count (3) != transition count (2)"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mid_stream_deserialisation_failure_truncates_replay_and_counts_only_decoded_signals()
|
||||
{
|
||||
var sigs = new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted),
|
||||
MakeSignal(1, DecisionSignalKind.AppInstallCompleted),
|
||||
MakeSignal(2, DecisionSignalKind.AppInstallCompleted),
|
||||
};
|
||||
var (signals, transitions) = Materialise(sigs);
|
||||
signals[1].PayloadJson = "{not json";
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Contains(report.Issues, i => i.Kind == "replay_deserialization_error" && i.Message.Contains("ordinal 1"));
|
||||
Assert.Contains(report.Issues, i => i.Kind == "replay_divergence" && i.Message.StartsWith("Signal count (1) != transition count (3)"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Head_deserialisation_failure_skips_replay_silently()
|
||||
{
|
||||
var (signals, transitions) = Materialise(new[] { MakeSignal(0, DecisionSignalKind.SessionStarted) });
|
||||
signals[0].PayloadJson = "stub";
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.False(report.SemanticReplayPerformed);
|
||||
Assert.Equal("deserialization_failure", report.SemanticReplaySkipReason);
|
||||
Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_deserialization_error");
|
||||
}
|
||||
|
||||
// ---- Repository payload budget ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Payload_budget_exhaustion_mirrors_the_repository_stop_condition()
|
||||
{
|
||||
SignalRecord Row(int chars) => new() { PayloadJson = new string('x', chars) };
|
||||
|
||||
Assert.False(SignalQueryLimits.IsPayloadBudgetExhausted(new[] { Row(10), Row(10) }, maxTotalPayloadChars: 100));
|
||||
Assert.True(SignalQueryLimits.IsPayloadBudgetExhausted(new[] { Row(60), Row(40) }, maxTotalPayloadChars: 100));
|
||||
Assert.True(SignalQueryLimits.IsPayloadBudgetExhausted(new[] { Row(60), Row(60) }, maxTotalPayloadChars: 100));
|
||||
Assert.False(SignalQueryLimits.IsPayloadBudgetExhausted(Array.Empty<SignalRecord>(), maxTotalPayloadChars: 100));
|
||||
}
|
||||
}
|
||||
@@ -121,13 +121,7 @@ public class ReportAgentErrorFunctionTests
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
OpsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Functions/Reports folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it appeared at the
|
||||
/// call site (independent fixture, copied from the pre-migration code) and the NEW typed DTO
|
||||
/// with the same values, asserting ordinally identical JSON via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/>.
|
||||
/// </summary>
|
||||
public class ReportsWireParityTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 8, 15, 10, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
// =========================================================================
|
||||
// GetDeviceNotRegisteredFunction — { success, aggregated, totalRawReports, dataQualityNotice }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DeviceNotRegistered_envelope_and_item_match_old_anonymous_shape()
|
||||
{
|
||||
const string notice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, model, and the Cloud PC marker are self-reported by devices.";
|
||||
|
||||
// Old anonymous item shape from BuildAggregatedResult (pre-migration), in a List<object>
|
||||
// exactly as the old code produced it.
|
||||
var oldAggregated = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
serialNumber = "GM18NHV3",
|
||||
manufacturer = "Lenovo",
|
||||
model = "ThinkPad T14",
|
||||
isCloudPc = true,
|
||||
attemptCount = 3,
|
||||
firstSeen = T0,
|
||||
lastSeen = T0.AddHours(2)
|
||||
}
|
||||
};
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
aggregated = oldAggregated,
|
||||
totalRawReports = 3,
|
||||
dataQualityNotice = notice
|
||||
},
|
||||
new DeviceNotRegisteredResponse
|
||||
{
|
||||
Success = true,
|
||||
Aggregated = new List<DeviceNotRegisteredItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SerialNumber = "GM18NHV3",
|
||||
Manufacturer = "Lenovo",
|
||||
Model = "ThinkPad T14",
|
||||
IsCloudPc = true,
|
||||
AttemptCount = 3,
|
||||
FirstSeen = T0,
|
||||
LastSeen = T0.AddHours(2)
|
||||
}
|
||||
},
|
||||
TotalRawReports = 3,
|
||||
DataQualityNotice = notice
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetHardwareRejectedFunction — { success, aggregated, totalRawReports, dataQualityNotice }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void HardwareRejected_envelope_and_item_match_old_anonymous_shape()
|
||||
{
|
||||
const string notice = "This data is from pre-authentication distress reports and is UNVERIFIED. Manufacturer, model, and serial number values are self-reported by devices.";
|
||||
|
||||
var oldAggregated = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
manufacturer = "Dell Inc.",
|
||||
model = "Latitude 5440",
|
||||
attemptCount = 4,
|
||||
uniqueSerials = 2,
|
||||
firstSeen = T0,
|
||||
lastSeen = T0.AddDays(1),
|
||||
sampleSerialNumbers = new List<string> { "SN1", "SN2" }
|
||||
}
|
||||
};
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
aggregated = oldAggregated,
|
||||
totalRawReports = 4,
|
||||
dataQualityNotice = notice
|
||||
},
|
||||
new HardwareRejectedResponse
|
||||
{
|
||||
Success = true,
|
||||
Aggregated = new List<HardwareRejectedItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Manufacturer = "Dell Inc.",
|
||||
Model = "Latitude 5440",
|
||||
AttemptCount = 4,
|
||||
UniqueSerials = 2,
|
||||
FirstSeen = T0,
|
||||
LastSeen = T0.AddDays(1),
|
||||
SampleSerialNumbers = new List<string> { "SN1", "SN2" }
|
||||
}
|
||||
},
|
||||
TotalRawReports = 4,
|
||||
DataQualityNotice = notice
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetTpmPssUnsupportedFunction — { success, aggregated, totalRawReports, dataQualityNotice }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void TpmPssUnsupported_envelope_and_item_match_old_anonymous_shape()
|
||||
{
|
||||
const string notice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here have a TPM that cannot perform RSA-PSS signing, so their agent cannot authenticate to the backend. Serial number, manufacturer, and model values are self-reported by devices.";
|
||||
|
||||
var oldAggregated = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
serialNumber = "SN1",
|
||||
manufacturer = "Lenovo",
|
||||
model = "ThinkCentre M720q",
|
||||
attemptCount = 3,
|
||||
firstSeen = T0,
|
||||
lastSeen = T0.AddHours(2)
|
||||
}
|
||||
};
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
aggregated = oldAggregated,
|
||||
totalRawReports = 3,
|
||||
dataQualityNotice = notice
|
||||
},
|
||||
new TpmPssUnsupportedResponse
|
||||
{
|
||||
Success = true,
|
||||
Aggregated = new List<TpmPssUnsupportedItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
SerialNumber = "SN1",
|
||||
Manufacturer = "Lenovo",
|
||||
Model = "ThinkCentre M720q",
|
||||
AttemptCount = 3,
|
||||
FirstSeen = T0,
|
||||
LastSeen = T0.AddHours(2)
|
||||
}
|
||||
},
|
||||
TotalRawReports = 3,
|
||||
DataQualityNotice = notice
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetSessionReportsFunction — non-paged { success, count, reports } and
|
||||
// paged { success, count, reports, nextLink } — one DTO (SessionReportListResponse)
|
||||
// =========================================================================
|
||||
|
||||
private static List<SessionReportMetadata> MakeReports() =>
|
||||
new()
|
||||
{
|
||||
new SessionReportMetadata
|
||||
{
|
||||
ReportId = "5f0d2c9a-1b3e-4f6a-8c7d-9e0f1a2b3c4d",
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
SessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
Comment = "ESP stuck at device setup",
|
||||
Email = "admin@contoso.com",
|
||||
BlobName = "report_5f0d2c9a.zip",
|
||||
SubmittedBy = "admin@contoso.com",
|
||||
SubmittedAt = T0,
|
||||
AdminNote = "",
|
||||
ReportType = ReportTypes.Session,
|
||||
DiagnosticsBlobName = null,
|
||||
DiagnosticsCopyStatus = null
|
||||
}
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SessionReports_nonPaged_matches_old_anonymous_shape_without_nextLink_key()
|
||||
{
|
||||
var reports = MakeReports();
|
||||
|
||||
// Old non-paged literal had NO nextLink property; the typed DTO leaves NextLink null
|
||||
// and WhenWritingNull must drop the key so both sides stay identical.
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, count = reports.Count, reports },
|
||||
new SessionReportListResponse { Success = true, Count = reports.Count, Reports = reports });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionReports_paged_with_nextLink_matches_old_anonymous_shape()
|
||||
{
|
||||
var reports = MakeReports();
|
||||
var nextLink = "/api/global/session-reports?pageSize=50&continuation=abc123";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = reports.Count,
|
||||
reports,
|
||||
nextLink,
|
||||
},
|
||||
new SessionReportListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = reports.Count,
|
||||
Reports = reports,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionReports_paged_lastPage_nextLink_null_drops_key_on_both_sides()
|
||||
{
|
||||
var reports = MakeReports();
|
||||
string? nextLink = null;
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = reports.Count,
|
||||
reports,
|
||||
nextLink,
|
||||
},
|
||||
new SessionReportListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = reports.Count,
|
||||
Reports = reports,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetDistressReportsFunction — { success, count, reports }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void DistressReports_matches_old_anonymous_shape()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = "11111111-2222-3333-4444-555555555555",
|
||||
ErrorType = "DeviceNotRegistered",
|
||||
Manufacturer = "Lenovo",
|
||||
Model = "ThinkPad T14",
|
||||
SerialNumber = "GM18NHV3",
|
||||
AgentVersion = "2.4.1.0",
|
||||
HttpStatusCode = 403,
|
||||
Message = "Device not found in registry",
|
||||
AgentTimestamp = T0,
|
||||
IngestedAt = T0.AddSeconds(5),
|
||||
SourceIp = "203.0.113.10",
|
||||
IsCloudPc = false
|
||||
// Cert-context fields left null — WhenWritingNull drops them identically
|
||||
// on both sides (same DistressReportEntry class serializes the items).
|
||||
}
|
||||
};
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = reports.Count,
|
||||
reports
|
||||
},
|
||||
new DistressReportListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = reports.Count,
|
||||
Reports = reports
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetSessionReportDownloadUrlFunction — { success, downloadUrl }
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SessionReportDownloadUrl_matches_old_anonymous_shape()
|
||||
{
|
||||
var downloadUrl = "https://storage.example.invalid/session-reports/report_5f0d2c9a.zip?sv=2024-11-04&sig=abc";
|
||||
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true, downloadUrl },
|
||||
new SessionReportDownloadUrlResponse { Success = true, DownloadUrl = downloadUrl });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UpdateSessionReportNoteFunction — { success } (reuses SuccessOnlyResponse)
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void UpdateSessionReportNote_success_matches_old_anonymous_shape()
|
||||
{
|
||||
ApiResponseWireParityTests.AssertWireIdentical(
|
||||
new { success = true },
|
||||
new SuccessOnlyResponse { Success = true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Rules function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class RulesWireParityTests
|
||||
{
|
||||
// ---- GetAnalyzeRules / GetGlobalAnalyzeRules -----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeRuleListResponse_matches_the_rules_listing_shape()
|
||||
{
|
||||
var rules = new List<AnalyzeRule>
|
||||
{
|
||||
new AnalyzeRule { RuleId = "ANALYZE-NET-001" },
|
||||
new AnalyzeRule { RuleId = "ANALYZE-APP-017" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, rules },
|
||||
new AnalyzeRuleListResponse { Success = true, Rules = rules });
|
||||
}
|
||||
|
||||
// ---- GetGatherRules / GetGlobalGatherRules -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GatherRuleListResponse_matches_the_rules_listing_shape()
|
||||
{
|
||||
var rules = new List<GatherRule>
|
||||
{
|
||||
new GatherRule { RuleId = "GATHER-LOG-001", Author = "Contoso Admin" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, rules },
|
||||
new GatherRuleListResponse { Success = true, Rules = rules });
|
||||
}
|
||||
|
||||
// ---- GetImeLogPatterns ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ImeLogPatternListResponse_matches_the_patterns_listing_shape()
|
||||
{
|
||||
var patterns = new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern { PatternId = "IME-PS-AGENT-OUTPUT" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, patterns },
|
||||
new ImeLogPatternListResponse { Success = true, Patterns = patterns });
|
||||
}
|
||||
|
||||
// ---- Create/Update/Delete rule + UpdateImeLogPattern (dual success/message sites) ----
|
||||
|
||||
[Fact]
|
||||
public void Rule_mutation_acknowledgements_match_SuccessMessageResponse_on_both_branches()
|
||||
{
|
||||
foreach (var success in new[] { true, false })
|
||||
{
|
||||
AssertParity(
|
||||
new { success, message = success ? "Rule created" : "Failed to create rule" },
|
||||
new SuccessMessageResponse { Success = success, Message = success ? "Rule created" : "Failed to create rule" });
|
||||
|
||||
AssertParity(
|
||||
new { success, message = success ? "Rule updated" : "Failed to update rule" },
|
||||
new SuccessMessageResponse { Success = success, Message = success ? "Rule updated" : "Failed to update rule" });
|
||||
|
||||
AssertParity(
|
||||
new { success, message = success ? "Rule deleted" : "Failed to delete rule" },
|
||||
new SuccessMessageResponse { Success = success, Message = success ? "Rule deleted" : "Failed to delete rule" });
|
||||
|
||||
AssertParity(
|
||||
new { success, message = success ? "Global pattern updated" : "Failed to update global pattern" },
|
||||
new SuccessMessageResponse { Success = success, Message = success ? "Global pattern updated" : "Failed to update global pattern" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CreateAnalyzeRuleFromTemplate ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CreateAnalyzeRuleFromTemplateResponse_matches_the_created_rule_shape()
|
||||
{
|
||||
var newRule = new AnalyzeRule { RuleId = "ANALYZE-CUSTOM-0001" };
|
||||
|
||||
AssertParity(
|
||||
new { success = true, rule = newRule, message = "Custom rule created from template" },
|
||||
new CreateAnalyzeRuleFromTemplateResponse { Success = true, Rule = newRule, Message = "Custom rule created from template" });
|
||||
}
|
||||
|
||||
// ---- DryRunAnalyzeRule ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void DryRunAnalyzeRuleResponse_matches_the_dry_run_trace_shape()
|
||||
{
|
||||
var sessionId = "7c1e2a90-1111-4b7c-a1d2-52f3bbbb0001";
|
||||
var result = new RuleDryRun
|
||||
{
|
||||
Verdict = RuleDryRunVerdict.Fired,
|
||||
EventCount = 42,
|
||||
BaseConfidence = 50,
|
||||
FinalConfidence = 80,
|
||||
ConfidenceThreshold = 60,
|
||||
WouldMarkSessionAsFailed = false,
|
||||
MatchedConditions = new Dictionary<string, object>
|
||||
{
|
||||
["timeout_seen"] = new { eventId = "evt-0001", value = "timeout" },
|
||||
},
|
||||
};
|
||||
result.Conditions.Add(new RuleDryRunCondition
|
||||
{
|
||||
Signal = "timeout_seen",
|
||||
Source = "event_data",
|
||||
EventType = "app_install_failed",
|
||||
Required = true,
|
||||
Matched = true,
|
||||
Evidence = new Dictionary<string, object> { ["field"] = "errorCode" },
|
||||
MatchingEventCount = 3,
|
||||
});
|
||||
result.ConfidenceFactors.Add(new RuleDryRunFactor
|
||||
{
|
||||
Signal = "timeout_seen",
|
||||
Condition = "count >= 2",
|
||||
Weight = 30,
|
||||
Matched = true,
|
||||
});
|
||||
|
||||
AssertParity(
|
||||
new { success = true, sessionId, result },
|
||||
new DryRunAnalyzeRuleResponse { Success = true, SessionId = sessionId, Result = result });
|
||||
}
|
||||
|
||||
// ---- GetRuleResults ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetRuleResultsResponse_matches_the_analysis_shape_with_persist_failures()
|
||||
{
|
||||
var sessionId = "7c1e2a90-2222-4b7c-a1d2-52f3bbbb0002";
|
||||
var results = new List<RuleResult>
|
||||
{
|
||||
new RuleResult
|
||||
{
|
||||
ResultId = "9d0f1a2b-0001-4e0e-8888-52f3bbbb0003",
|
||||
SessionId = sessionId,
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
RuleId = "ANALYZE-NET-001",
|
||||
RuleTitle = "Network timeout during ESP",
|
||||
Severity = "critical",
|
||||
Category = "network",
|
||||
ConfidenceScore = 90,
|
||||
Explanation = "sample",
|
||||
DetectedAt = new DateTime(2026, 8, 30, 11, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
new RuleResult
|
||||
{
|
||||
ResultId = "9d0f1a2b-0002-4e0e-8888-52f3bbbb0004",
|
||||
SessionId = sessionId,
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
RuleId = "ANALYZE-APP-017",
|
||||
RuleTitle = "Resolved finding",
|
||||
Severity = "warning",
|
||||
Category = "apps",
|
||||
ConfidenceScore = 70,
|
||||
Explanation = "sample",
|
||||
DetectedAt = new DateTime(2026, 8, 30, 11, 5, 0, DateTimeKind.Utc),
|
||||
ResolvedAt = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
};
|
||||
var openResults = results.Where(r => r.ResolvedAt == null).ToList();
|
||||
var persistFailureRuleIds = new List<string> { "ANALYZE-NET-001" };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = persistFailureRuleIds.Count == 0,
|
||||
sessionId,
|
||||
results,
|
||||
totalIssues = openResults.Count,
|
||||
criticalCount = openResults.Count(r => r.Severity == "critical"),
|
||||
highCount = openResults.Count(r => r.Severity == "high"),
|
||||
warningCount = openResults.Count(r => r.Severity == "warning"),
|
||||
persistFailureCount = persistFailureRuleIds.Count,
|
||||
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
|
||||
},
|
||||
new GetRuleResultsResponse
|
||||
{
|
||||
Success = persistFailureRuleIds.Count == 0,
|
||||
SessionId = sessionId,
|
||||
Results = results,
|
||||
TotalIssues = openResults.Count,
|
||||
CriticalCount = openResults.Count(r => r.Severity == "critical"),
|
||||
HighCount = openResults.Count(r => r.Severity == "high"),
|
||||
WarningCount = openResults.Count(r => r.Severity == "warning"),
|
||||
PersistFailureCount = persistFailureRuleIds.Count,
|
||||
PersistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRuleResultsResponse_omits_persistFailureRuleIds_on_the_happy_path()
|
||||
{
|
||||
var sessionId = "7c1e2a90-3333-4b7c-a1d2-52f3bbbb0005";
|
||||
var results = new List<RuleResult>();
|
||||
var openResults = results.Where(r => r.ResolvedAt == null).ToList();
|
||||
var persistFailureRuleIds = new List<string>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = persistFailureRuleIds.Count == 0,
|
||||
sessionId,
|
||||
results,
|
||||
totalIssues = openResults.Count,
|
||||
criticalCount = openResults.Count(r => r.Severity == "critical"),
|
||||
highCount = openResults.Count(r => r.Severity == "high"),
|
||||
warningCount = openResults.Count(r => r.Severity == "warning"),
|
||||
persistFailureCount = persistFailureRuleIds.Count,
|
||||
persistFailureRuleIds = persistFailureRuleIds.Count > 0 ? persistFailureRuleIds : null
|
||||
},
|
||||
new GetRuleResultsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Results = results,
|
||||
TotalIssues = 0,
|
||||
CriticalCount = 0,
|
||||
HighCount = 0,
|
||||
WarningCount = 0,
|
||||
PersistFailureCount = 0,
|
||||
PersistFailureRuleIds = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- ReseedImeLogPatterns ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ReseedImeLogPatternsResponse_matches_the_reseed_summary_shape()
|
||||
{
|
||||
var deleted = 79;
|
||||
var written = 81;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Reseed complete: {deleted} old patterns removed, {written} patterns written from code.",
|
||||
deleted,
|
||||
written
|
||||
},
|
||||
new ReseedImeLogPatternsResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Reseed complete: {deleted} old patterns removed, {written} patterns written from code.",
|
||||
Deleted = deleted,
|
||||
Written = written
|
||||
});
|
||||
}
|
||||
|
||||
// ---- TestLogPattern ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestLogPatternResponse_matches_the_pattern_test_shape()
|
||||
{
|
||||
var isTextMode = false;
|
||||
var result = new LogPatternTestResult
|
||||
{
|
||||
MatchCount = 1,
|
||||
ParseFailureCount = 0,
|
||||
TimeoutCount = 0,
|
||||
};
|
||||
result.Lines.Add(new LogPatternLineResult
|
||||
{
|
||||
LineNumber = 1,
|
||||
Outcome = "matched",
|
||||
Groups = new Dictionary<string, string> { ["level"] = "ERROR", ["message"] = "disk full" },
|
||||
MatchedText = "ERROR: disk full",
|
||||
Component = "Installer",
|
||||
CmTraceType = 3,
|
||||
Message = "ERROR: disk full while unpacking",
|
||||
});
|
||||
// no_match row: every nullable slot stays null → the keys must vanish on both sides.
|
||||
result.Lines.Add(new LogPatternLineResult
|
||||
{
|
||||
LineNumber = 2,
|
||||
Outcome = "no_match",
|
||||
});
|
||||
result.Notes.Add("no line matched — remember logparser patterns are case-SENSITIVE (unlike analyze-rule regex conditions)");
|
||||
|
||||
AssertParity(
|
||||
new { success = true, format = isTextMode ? "text" : "cmtrace", result },
|
||||
new TestLogPatternResponse { Success = true, Format = isTextMode ? "text" : "cmtrace", Result = result });
|
||||
}
|
||||
|
||||
// ---- GetPreviewWhitelist -------------------------------------------------------------
|
||||
// DELIBERATE wire change (2026-08-31 entity-hygiene pass): rows carried the tenant id only
|
||||
// in partitionKey plus garbage entity defaults; the contract is now { tenantId } per row.
|
||||
|
||||
[Fact]
|
||||
public void GetPreviewWhitelistResponse_pins_the_approved_tenant_listing_shape()
|
||||
{
|
||||
var approved = new List<PreviewWhitelistTenantEntry>
|
||||
{
|
||||
new PreviewWhitelistTenantEntry { TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d" },
|
||||
new PreviewWhitelistTenantEntry { TenantId = "7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e" },
|
||||
};
|
||||
|
||||
var json = TestWire.Serialize(new GetPreviewWhitelistResponse { Tenants = approved });
|
||||
Assert.Equal(
|
||||
"{\"tenants\":["
|
||||
+ "{\"tenantId\":\"6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d\"},"
|
||||
+ "{\"tenantId\":\"7b7b46b3-40c3-4f2f-9a1b-6d9f1a2b3c4e\"}"
|
||||
+ "]}",
|
||||
json);
|
||||
Assert.DoesNotContain("partitionKey", json);
|
||||
Assert.DoesNotContain("rowKey", json);
|
||||
Assert.DoesNotContain("eTag", json);
|
||||
Assert.DoesNotContain("approvedAt", json);
|
||||
}
|
||||
|
||||
// ---- GetPreviewNotificationEmail -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetPreviewNotificationEmailResponse_matches_the_coalesced_email_shape()
|
||||
{
|
||||
string? email = "ops@fabrikam.com";
|
||||
AssertParity(
|
||||
new { email = email ?? "" },
|
||||
new GetPreviewNotificationEmailResponse { Email = email ?? "" });
|
||||
|
||||
// No stored address: the site coalesces to "" — the key stays present.
|
||||
email = null;
|
||||
AssertParity(
|
||||
new { email = email ?? "" },
|
||||
new GetPreviewNotificationEmailResponse { Email = "" });
|
||||
}
|
||||
|
||||
// ---- GetAllPreviewNotificationEmails -------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetAllPreviewNotificationEmailsResponse_matches_the_address_map_shape()
|
||||
{
|
||||
var emails = new Dictionary<string, string>
|
||||
{
|
||||
["6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d"] = "ops@contoso.com",
|
||||
["0a0a35a2-30b2-4f2f-9a1b-6d9f1a2b3c01"] = "it@fabrikam.com",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { count = emails.Count, emails },
|
||||
new GetAllPreviewNotificationEmailsResponse { Count = emails.Count, Emails = emails });
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -231,7 +231,10 @@ public class SessionAnnotationFunctionsTests
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
|
||||
// Production wire settings (camelCase, absent-when-null, string enums) — assertions
|
||||
// must run against the shape the deployed worker actually serializes.
|
||||
services.Configure<WorkerOptions>(o => o.Serializer =
|
||||
new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object> { ["RequestContext"] = requestContext };
|
||||
|
||||
+1
-5
@@ -397,11 +397,7 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
AdminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
var maintRepo = new Mock<IMaintenanceRepository>();
|
||||
|
||||
@@ -659,11 +659,7 @@ public class SessionDeletionPr4cCodexFixesTests
|
||||
// backed by a noop repository — F5 tests assert CAS ordering, not the OpsEvent itself.
|
||||
var opsRepoStub = new Mock<IOpsEventRepository>();
|
||||
opsRepoStub.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
|
||||
var alertDispatchStub = new OpsAlertDispatchService(
|
||||
AdminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatchStub = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
|
||||
var opsServiceStub = new OpsEventService(opsRepoStub.Object, NullLogger<OpsEventService>.Instance, alertDispatchStub);
|
||||
|
||||
// PR-B Codex F4 follow-up: worker now reads DeletionProgress on the poison path.
|
||||
|
||||
@@ -679,11 +679,7 @@ public class SessionDeletionWorkerTests
|
||||
lock (OpsEventCalls) OpsEventCalls.Add(CapturedOpsEvent.From(e));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
AdminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService(new HttpClient(), NullLogger<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(AdminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
Sut = new SessionDeletionWorker(
|
||||
|
||||
@@ -46,12 +46,7 @@ public class SessionOwnerBindingObserverTests
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
Sut = new SessionOwnerBindingObserver(
|
||||
|
||||
@@ -8,6 +8,7 @@ using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
@@ -96,10 +97,12 @@ public class SessionScopeResolutionTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequireGlobalAdmin_StillResolves_ForGlobalAdmin()
|
||||
public async Task RequireGlobalAdmin_StillResolves_ForGlobalAdmin_WhenTheSessionRowCorroboratesTheLookup()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
|
||||
repo.Setup(r => r.GetSessionAsync(OtherTenant, SessionId))
|
||||
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = OtherTenant });
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
|
||||
@@ -107,6 +110,38 @@ public class SessionScopeResolutionTests
|
||||
Assert.Equal(OtherTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequireGlobalAdmin_KeepsTargetTenant_WhenTheLookupPointsAtATenantWithoutTheSessionRow()
|
||||
{
|
||||
// A lookup row whose tenant does not hold the Sessions row (poisoned or stale mapping)
|
||||
// must never steer a GA WRITE into that tenant — silently keep the target tenant so the
|
||||
// endpoint's own not-found handling answers; no operator confirmation dialog.
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
|
||||
repo.Setup(r => r.GetSessionAsync(OtherTenant, SessionId)).ReturnsAsync((SessionSummary?)null);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
|
||||
|
||||
Assert.Equal(HomeTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPath_DoesNotCorroborate_TheLookupWithASessionRead()
|
||||
{
|
||||
// Reads keep the single point-read contract: the endpoint's own session read is the
|
||||
// corroboration (404 when the row is missing in the resolved tenant).
|
||||
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
repo.VerifyAll();
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatedCaller_GetsNoFallback_ByDesign()
|
||||
{
|
||||
@@ -151,14 +186,31 @@ public class SessionScopeResolutionTests
|
||||
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
// Self-heal: the lookup row is written so the next resolve is a point-read.
|
||||
harness.Lookup.Verify(t => t.UpsertEntityAsync(
|
||||
// Self-heal: the lookup row is written ADD-ONLY so the next resolve is a point-read and
|
||||
// a claim that landed meanwhile is never overwritten.
|
||||
harness.Lookup.Verify(t => t.AddEntityAsync(
|
||||
It.Is<TableEntity>(e => e.PartitionKey == SessionId
|
||||
&& e.RowKey == "tenant"
|
||||
&& e.GetString("TenantId") == OtherTenant),
|
||||
It.IsAny<TableUpdateMode>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
harness.Lookup.Verify(t => t.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelfHeal_SwallowsAConcurrentClaim_AndStillReturnsTheScannedTenant()
|
||||
{
|
||||
var indexRow = new TableEntity(OtherTenant, "0000000000000000001_" + SessionId) { ["SessionId"] = SessionId };
|
||||
var harness = new ResolveHarness(lookupRow: null, indexRows: new[] { indexRow });
|
||||
harness.Lookup
|
||||
.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(409, "EntityAlreadyExists"));
|
||||
|
||||
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -169,8 +221,8 @@ public class SessionScopeResolutionTests
|
||||
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
|
||||
|
||||
Assert.Null(result);
|
||||
harness.Lookup.Verify(t => t.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
harness.Lookup.Verify(t => t.AddEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
@@ -246,7 +298,7 @@ public class SessionScopeResolutionTests
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found"));
|
||||
}
|
||||
Lookup
|
||||
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
|
||||
var page = Page<TableEntity>.FromValues(indexRows ?? Array.Empty<TableEntity>(), null, Mock.Of<Response>());
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-identity proof for the Sessions function folder (anonymous-object → typed-DTO
|
||||
/// migration). Each fact serializes the OLD anonymous literal exactly as it stood at the
|
||||
/// call site (copied from the pre-migration code, filled with realistic sample values)
|
||||
/// against the NEW DTO carrying the same values, via
|
||||
/// <see cref="ApiResponseWireParityTests.AssertWireIdentical"/> — key names, key order and
|
||||
/// key presence/absence (WhenWritingNull) must match ordinally. Nullable slots additionally
|
||||
/// get a null case proving the key vanishes identically on both sides.
|
||||
/// </summary>
|
||||
public class SessionsWireParityTests
|
||||
{
|
||||
private static SessionSummary SampleSummary(string sessionId) => new SessionSummary
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SerialNumber = "SN-0042",
|
||||
DeviceName = "DESKTOP-CONTOSO1",
|
||||
Manufacturer = "Fabrikam Inc.",
|
||||
Model = "Latitude 9999",
|
||||
StartedAt = new DateTime(2026, 8, 30, 11, 15, 0, DateTimeKind.Utc),
|
||||
CurrentPhase = 3,
|
||||
CurrentPhaseDetail = "Device Setup",
|
||||
Status = SessionStatus.InProgress,
|
||||
FailureReason = string.Empty,
|
||||
};
|
||||
|
||||
// ---- GetSessions / GetAllSessions / SearchSessionsByCve / SearchSessionsByEvent ------
|
||||
|
||||
[Fact]
|
||||
public void SessionListResponse_matches_the_paged_listing_shape()
|
||||
{
|
||||
IReadOnlyList<SessionSummary> items = new List<SessionSummary>
|
||||
{
|
||||
SampleSummary("0b6f7a37-1111-4d61-9c93-0aa111111111"),
|
||||
SampleSummary("0b6f7a37-2222-4d61-9c93-0aa222222222"),
|
||||
};
|
||||
string? nextLink = "/api/sessions?pageSize=2&continuation=abc";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
sessions = items,
|
||||
nextLink,
|
||||
},
|
||||
new SessionListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = items.Count,
|
||||
Sessions = items,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionListResponse_omits_nextLink_on_the_last_page()
|
||||
{
|
||||
IReadOnlyList<SessionSummary> items = new List<SessionSummary>();
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
sessions = items,
|
||||
nextLink,
|
||||
},
|
||||
new SessionListResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = items.Count,
|
||||
Sessions = items,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- SearchSessions ------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SearchSessionsResponse_matches_the_unprojected_shape()
|
||||
{
|
||||
IReadOnlyList<SessionSummary> pageItems = new List<SessionSummary>
|
||||
{
|
||||
SampleSummary("3f1c9d55-aaaa-4e0e-8888-52f3aaaa0001"),
|
||||
};
|
||||
// Old site: `object sessionsPayload` — either the full items or the fields= projection.
|
||||
object sessionsPayload = pageItems;
|
||||
string? nextLink = "/api/search/sessions?pageSize=1&continuation=xyz";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = pageItems.Count,
|
||||
sessions = sessionsPayload,
|
||||
nextLink,
|
||||
},
|
||||
new SearchSessionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = pageItems.Count,
|
||||
Sessions = pageItems,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchSessionsResponse_matches_the_fields_projected_shape_and_omits_null_nextLink()
|
||||
{
|
||||
var projected = new List<Dictionary<string, object?>>
|
||||
{
|
||||
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sessionId"] = "3f1c9d55-bbbb-4e0e-8888-52f3aaaa0002",
|
||||
["status"] = "Failed",
|
||||
["startedAt"] = new DateTime(2026, 8, 29, 7, 0, 0, DateTimeKind.Utc),
|
||||
},
|
||||
};
|
||||
object sessionsPayload = projected;
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = 1,
|
||||
sessions = sessionsPayload,
|
||||
nextLink,
|
||||
},
|
||||
new SearchSessionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = 1,
|
||||
Sessions = projected,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionStats / GetAllSessionStats --------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SessionStatsResponse_matches_the_stats_shape()
|
||||
{
|
||||
var stats = new SessionStats
|
||||
{
|
||||
Days = 7,
|
||||
ActiveCount = 3,
|
||||
TotalLastNDays = 42,
|
||||
SucceededLastNDays = 30,
|
||||
FailedLastNDays = 5,
|
||||
IncompleteLastNDays = 7,
|
||||
SuccessRatePct = 86,
|
||||
AvgDurationMinutes = 55,
|
||||
MedianDurationMinutes = 48,
|
||||
P90DurationMinutes = 92,
|
||||
TotalToday = 4,
|
||||
FailedToday = 1,
|
||||
ComputedAt = new DateTime(2026, 8, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new { success = true, stats },
|
||||
new SessionStatsResponse { Success = true, Stats = stats });
|
||||
}
|
||||
|
||||
// ---- GetSession ----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionResponse_matches_the_single_session_shape()
|
||||
{
|
||||
var session = SampleSummary("9d2f5f70-cccc-4f31-9f1e-52f3aaaa0003");
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
session
|
||||
},
|
||||
new GetSessionResponse
|
||||
{
|
||||
Success = true,
|
||||
Session = session
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionEvents (unpaginated + paginated) --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionEventsResponse_matches_the_unpaginated_shape_without_a_nextLink_key()
|
||||
{
|
||||
var sessionId = "5c8e1b20-dddd-49a2-8bd1-52f3aaaa0004";
|
||||
// EventFieldProjection.Project returns List<object> (full events or dictionary projections).
|
||||
var events = new List<object>
|
||||
{
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventId = "evt-0001",
|
||||
SessionId = sessionId,
|
||||
EventType = "phase_transition",
|
||||
Timestamp = new DateTime(2026, 8, 30, 11, 20, 0, DateTimeKind.Utc),
|
||||
},
|
||||
new Dictionary<string, object?> { ["eventType"] = "app_install_start" },
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
count = events.Count,
|
||||
events,
|
||||
},
|
||||
new GetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events,
|
||||
// NextLink stays null → the key is absent, exactly like the old literal
|
||||
// that never declared it.
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionEventsResponse_matches_the_paginated_shape_with_a_nextLink()
|
||||
{
|
||||
var sessionId = "5c8e1b20-eeee-49a2-8bd1-52f3aaaa0005";
|
||||
var events = new List<object>();
|
||||
string? nextLink = "/api/sessions/5c8e1b20-eeee-49a2-8bd1-52f3aaaa0005/events?pageSize=100&continuation=tok";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
count = events.Count,
|
||||
events,
|
||||
nextLink,
|
||||
},
|
||||
new GetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events,
|
||||
NextLink = nextLink,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionEventsResponse_omits_a_null_nextLink_on_the_paginated_path()
|
||||
{
|
||||
var sessionId = "5c8e1b20-ffff-49a2-8bd1-52f3aaaa0006";
|
||||
var events = new List<object>();
|
||||
string? nextLink = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
count = events.Count,
|
||||
events,
|
||||
nextLink,
|
||||
},
|
||||
new GetSessionEventsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = events.Count,
|
||||
Events = events,
|
||||
NextLink = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionSignals ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionSignalsResponse_matches_the_signal_log_shape()
|
||||
{
|
||||
var sessionId = "7aa20c11-0001-4b7c-a1d2-52f3aaaa0007";
|
||||
var signals = new List<SignalRecord>
|
||||
{
|
||||
new SignalRecord
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = sessionId,
|
||||
SessionSignalOrdinal = 1,
|
||||
SessionTraceOrdinal = 5,
|
||||
Kind = "EspPhaseObserved",
|
||||
KindSchemaVersion = 1,
|
||||
OccurredAtUtc = new DateTime(2026, 8, 30, 11, 25, 0, DateTimeKind.Utc),
|
||||
SourceOrigin = "agent",
|
||||
PayloadJson = "{\"phase\":\"DeviceSetup\"}",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
count = signals.Count,
|
||||
truncated = false,
|
||||
signals,
|
||||
},
|
||||
new GetSessionSignalsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = signals.Count,
|
||||
Truncated = false,
|
||||
Signals = signals,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionReducerVerification ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionReducerVerificationResponse_matches_the_report_shape()
|
||||
{
|
||||
var report = new ReducerVerificationReport
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = "8bb31d22-0002-4b7c-a1d2-52f3aaaa0008",
|
||||
SignalCount = 12,
|
||||
TransitionCount = 11,
|
||||
StoredReducerVersion = "r-2026.08",
|
||||
CurrentReducerVersion = "r-2026.08",
|
||||
SignalOrdinalsContiguous = true,
|
||||
SignalOrdinalFirst = 1,
|
||||
SignalOrdinalLast = 12,
|
||||
StepIndicesContiguous = true,
|
||||
StepIndexFirst = 0,
|
||||
StepIndexLast = 10,
|
||||
SemanticReplayPerformed = true,
|
||||
SemanticReplayFinalStageMatches = true,
|
||||
ReplayedFinalStage = "Completed",
|
||||
Issues = new List<VerificationIssue>
|
||||
{
|
||||
new VerificationIssue { Severity = "Info", Kind = "empty_session", Message = "sample" },
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
truncated = false,
|
||||
report,
|
||||
},
|
||||
new GetSessionReducerVerificationResponse
|
||||
{
|
||||
Success = true,
|
||||
Truncated = false,
|
||||
Report = report,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDecisionGraph ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDecisionGraphResponse_matches_the_graph_shape()
|
||||
{
|
||||
var projection = new DecisionGraphProjection
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = "9cc42e33-0003-4b7c-a1d2-52f3aaaa0009",
|
||||
Nodes = new List<DecisionGraphNode>
|
||||
{
|
||||
new DecisionGraphNode { Id = "EspInProgress", IsTerminal = false, VisitCount = 1 },
|
||||
},
|
||||
Edges = new List<DecisionGraphEdge>
|
||||
{
|
||||
new DecisionGraphEdge
|
||||
{
|
||||
StepIndex = 0,
|
||||
FromStage = "Registered",
|
||||
ToStage = "EspInProgress",
|
||||
Trigger = "EspPhaseObserved",
|
||||
Taken = true,
|
||||
SignalOrdinalRef = 1,
|
||||
OccurredAtUtc = new DateTime(2026, 8, 30, 11, 26, 0, DateTimeKind.Utc),
|
||||
},
|
||||
},
|
||||
ReducerVersion = "r-2026.08",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
truncated = false,
|
||||
graph = projection,
|
||||
},
|
||||
new GetSessionDecisionGraphResponse
|
||||
{
|
||||
Success = true,
|
||||
Truncated = false,
|
||||
Graph = projection,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDeletionsList ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionsListResponse_matches_the_deletion_listing_shape()
|
||||
{
|
||||
var state = "Queued";
|
||||
int? strandedSinceMinutes = 30;
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
var sessionId = "add53f44-0004-4b7c-a1d2-52f3aaaa0010";
|
||||
var deletionState = "Queued";
|
||||
var manifestId = "m-20260830-01";
|
||||
var timestamp = new DateTime(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc).ToString("o");
|
||||
var ageMinutes = 65;
|
||||
|
||||
var anonymousSessions = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantId,
|
||||
sessionId,
|
||||
deletionState,
|
||||
manifestId,
|
||||
timestamp,
|
||||
ageMinutes,
|
||||
},
|
||||
};
|
||||
var typedSessions = new List<SessionDeletionListItem>
|
||||
{
|
||||
new SessionDeletionListItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = sessionId,
|
||||
DeletionState = deletionState,
|
||||
ManifestId = manifestId,
|
||||
Timestamp = timestamp,
|
||||
AgeMinutes = ageMinutes,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
state,
|
||||
strandedSinceMinutes,
|
||||
count = anonymousSessions.Count,
|
||||
sessions = anonymousSessions,
|
||||
},
|
||||
new GetSessionDeletionsListResponse
|
||||
{
|
||||
Success = true,
|
||||
State = state,
|
||||
StrandedSinceMinutes = strandedSinceMinutes,
|
||||
Count = typedSessions.Count,
|
||||
Sessions = typedSessions,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionsListResponse_omits_a_null_strandedSinceMinutes()
|
||||
{
|
||||
var state = "Poisoned";
|
||||
int? strandedSinceMinutes = null;
|
||||
var sessions = new List<object>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
state,
|
||||
strandedSinceMinutes,
|
||||
count = sessions.Count,
|
||||
sessions,
|
||||
},
|
||||
new GetSessionDeletionsListResponse
|
||||
{
|
||||
Success = true,
|
||||
State = state,
|
||||
StrandedSinceMinutes = null,
|
||||
Count = 0,
|
||||
Sessions = new List<SessionDeletionListItem>(),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetTenantDeletionManifests ------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetTenantDeletionManifestsResponse_matches_the_manifest_tree_shape()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
string? sessionFilter = "bee64055-0005-4b7c-a1d2-52f3aaaa0011";
|
||||
var manifestId = "m-20260829-02";
|
||||
long sizeBytes = 20480;
|
||||
var lastModifiedUtc = new DateTime(2026, 8, 29, 18, 30, 0, DateTimeKind.Utc).ToString("o");
|
||||
|
||||
var anonymousManifests = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
manifestId,
|
||||
sizeBytes,
|
||||
lastModifiedUtc,
|
||||
},
|
||||
};
|
||||
var anonymousSessions = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
sessionId = sessionFilter,
|
||||
manifestCount = anonymousManifests.Count,
|
||||
latestManifestUtc = lastModifiedUtc,
|
||||
manifests = anonymousManifests,
|
||||
},
|
||||
};
|
||||
|
||||
var typedManifests = new List<TenantDeletionManifestItem>
|
||||
{
|
||||
new TenantDeletionManifestItem
|
||||
{
|
||||
ManifestId = manifestId,
|
||||
SizeBytes = sizeBytes,
|
||||
LastModifiedUtc = lastModifiedUtc,
|
||||
},
|
||||
};
|
||||
var typedSessions = new List<TenantDeletionManifestSessionNode>
|
||||
{
|
||||
new TenantDeletionManifestSessionNode
|
||||
{
|
||||
SessionId = sessionFilter!,
|
||||
ManifestCount = typedManifests.Count,
|
||||
LatestManifestUtc = lastModifiedUtc,
|
||||
Manifests = typedManifests,
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
tenantId,
|
||||
sessionFilter,
|
||||
sessionCount = anonymousSessions.Count,
|
||||
manifestCount = 1,
|
||||
sessions = anonymousSessions,
|
||||
},
|
||||
new GetTenantDeletionManifestsResponse
|
||||
{
|
||||
Success = true,
|
||||
TenantId = tenantId,
|
||||
SessionFilter = sessionFilter,
|
||||
SessionCount = typedSessions.Count,
|
||||
ManifestCount = 1,
|
||||
Sessions = typedSessions,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTenantDeletionManifestsResponse_omits_a_null_sessionFilter()
|
||||
{
|
||||
var tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
|
||||
string? sessionFilter = null;
|
||||
var sessions = new List<object>();
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
tenantId,
|
||||
sessionFilter,
|
||||
sessionCount = sessions.Count,
|
||||
manifestCount = 0,
|
||||
sessions,
|
||||
},
|
||||
new GetTenantDeletionManifestsResponse
|
||||
{
|
||||
Success = true,
|
||||
TenantId = tenantId,
|
||||
SessionFilter = null,
|
||||
SessionCount = 0,
|
||||
ManifestCount = 0,
|
||||
Sessions = new List<TenantDeletionManifestSessionNode>(),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetTenantsWithDeletionManifests -------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetTenantsWithDeletionManifestsResponse_matches_the_tenant_id_listing_shape()
|
||||
{
|
||||
var sorted = new List<string>
|
||||
{
|
||||
"0a0a35a2-30b2-4f2f-9a1b-6d9f1a2b3c01",
|
||||
"6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = sorted.Count,
|
||||
tenantIds = sorted,
|
||||
},
|
||||
new GetTenantsWithDeletionManifestsResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = sorted.Count,
|
||||
TenantIds = sorted,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- QuickSearchSessions -------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void QuickSearchSessionsResponse_matches_the_typeahead_shape()
|
||||
{
|
||||
var results = new List<QuickSearchResult>
|
||||
{
|
||||
new QuickSearchResult
|
||||
{
|
||||
SessionId = "cff75166-0006-4b7c-a1d2-52f3aaaa0012",
|
||||
SerialNumber = "SN-0042",
|
||||
DeviceName = "DESKTOP-CONTOSO1",
|
||||
Status = SessionStatus.Succeeded,
|
||||
StartedAt = new DateTime(2026, 8, 30, 9, 0, 0, DateTimeKind.Utc),
|
||||
MatchedField = "serialNumber",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
count = results.Count,
|
||||
results
|
||||
},
|
||||
new QuickSearchSessionsResponse
|
||||
{
|
||||
Success = true,
|
||||
Count = results.Count,
|
||||
Results = results
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDeletePreview (mode=summary) ------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletePreviewResponse_matches_the_summary_shape()
|
||||
{
|
||||
string? inFlightHint = "Cascade in progress (state=Running, manifestId=m-1); preview shows current data anyway.";
|
||||
var preflightCounts = new Dictionary<string, int> { ["Sessions"] = 1, ["Events"] = 250 };
|
||||
var anonymousSampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal)
|
||||
{
|
||||
["Events"] = new List<object> { new { pk = "tenant_session", rk = "0000000000000000001" } },
|
||||
};
|
||||
var typedSampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal)
|
||||
{
|
||||
["Events"] = new List<DeletionRowKeySample>
|
||||
{
|
||||
new DeletionRowKeySample { Pk = "tenant_session", Rk = "0000000000000000001" },
|
||||
},
|
||||
};
|
||||
long totalRowCount = 251;
|
||||
long estimatedBytes = 40960;
|
||||
long builderDurationMs = 320;
|
||||
var schemaHash = "sha256:abcd";
|
||||
var manifestId = "m-20260830-03";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
inFlightHint,
|
||||
preflightCounts,
|
||||
sampleKeys = anonymousSampleKeys,
|
||||
estimatedRowCount = totalRowCount,
|
||||
estimatedSnapshotBytes = estimatedBytes,
|
||||
builderDurationMs,
|
||||
schemaHash,
|
||||
manifestId,
|
||||
},
|
||||
new GetSessionDeletePreviewResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
InFlightHint = inFlightHint,
|
||||
PreflightCounts = preflightCounts,
|
||||
SampleKeys = typedSampleKeys,
|
||||
EstimatedRowCount = totalRowCount,
|
||||
EstimatedSnapshotBytes = estimatedBytes,
|
||||
BuilderDurationMs = builderDurationMs,
|
||||
SchemaHash = schemaHash,
|
||||
ManifestId = manifestId,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletePreviewResponse_omits_a_null_inFlightHint()
|
||||
{
|
||||
string? inFlightHint = null;
|
||||
var preflightCounts = new Dictionary<string, int>();
|
||||
var sampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
inFlightHint,
|
||||
preflightCounts,
|
||||
sampleKeys,
|
||||
estimatedRowCount = 0L,
|
||||
estimatedSnapshotBytes = -1L,
|
||||
builderDurationMs = 12L,
|
||||
schemaHash = "sha256:abcd",
|
||||
manifestId = "m-20260830-04",
|
||||
},
|
||||
new GetSessionDeletePreviewResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
InFlightHint = null,
|
||||
PreflightCounts = preflightCounts,
|
||||
SampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal),
|
||||
EstimatedRowCount = 0,
|
||||
EstimatedSnapshotBytes = -1,
|
||||
BuilderDurationMs = 12,
|
||||
SchemaHash = "sha256:abcd",
|
||||
ManifestId = "m-20260830-04",
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDeletionManifest (mode=summary) ---------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionManifestResponse_matches_the_stored_summary_shape_with_progress()
|
||||
{
|
||||
var manifestId = "m-20260830-05";
|
||||
var schemaHash = "sha256:ef01";
|
||||
var snapshotSha = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
var preflightCounts = new Dictionary<string, int> { ["Sessions"] = 1 };
|
||||
var anonymousSampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal)
|
||||
{
|
||||
["Sessions"] = new List<object> { new { pk = "6a6a35a2", rk = "sess-1" } },
|
||||
};
|
||||
var typedSampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal)
|
||||
{
|
||||
["Sessions"] = new List<DeletionRowKeySample> { new DeletionRowKeySample { Pk = "6a6a35a2", Rk = "sess-1" } },
|
||||
};
|
||||
var completedSteps = new HashSet<int> { 0, 1, 2 };
|
||||
DateTime? completedAt = new DateTime(2026, 8, 30, 12, 5, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
source = "stored",
|
||||
manifestId,
|
||||
schemaHash,
|
||||
snapshotSha256 = snapshotSha,
|
||||
estimatedRowCount = 1L,
|
||||
estimatedSnapshotBytes = 2048L,
|
||||
preflightCounts,
|
||||
sampleKeys = anonymousSampleKeys,
|
||||
progress = new
|
||||
{
|
||||
SnapshotSha256 = snapshotSha,
|
||||
completedStepOrders = completedSteps,
|
||||
VerificationDone = true,
|
||||
TombstoneStarted = true,
|
||||
CompletedAt = completedAt,
|
||||
aggregateDecrementsApplied = 2,
|
||||
restoreReIncrementsApplied = 0,
|
||||
LastFailureType = (string?)"ResidualRows",
|
||||
LastFailureMessage = (string?)"3 residual rows observed",
|
||||
LastObservedResidualCount = (int?)3,
|
||||
LastResidualSampleJson = (string?)"[{\"pk\":\"a\",\"rk\":\"b\"}]",
|
||||
},
|
||||
},
|
||||
new GetSessionDeletionManifestResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
Source = "stored",
|
||||
ManifestId = manifestId,
|
||||
SchemaHash = schemaHash,
|
||||
SnapshotSha256 = snapshotSha,
|
||||
EstimatedRowCount = 1,
|
||||
EstimatedSnapshotBytes = 2048,
|
||||
PreflightCounts = preflightCounts,
|
||||
SampleKeys = typedSampleKeys,
|
||||
Progress = new SessionDeletionProgressWire
|
||||
{
|
||||
SnapshotSha256 = snapshotSha,
|
||||
CompletedStepOrders = completedSteps,
|
||||
VerificationDone = true,
|
||||
TombstoneStarted = true,
|
||||
CompletedAt = completedAt,
|
||||
AggregateDecrementsApplied = 2,
|
||||
RestoreReIncrementsApplied = 0,
|
||||
LastFailureType = "ResidualRows",
|
||||
LastFailureMessage = "3 residual rows observed",
|
||||
LastObservedResidualCount = 3,
|
||||
LastResidualSampleJson = "[{\"pk\":\"a\",\"rk\":\"b\"}]",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionManifestResponse_omits_null_slots_inside_progress()
|
||||
{
|
||||
var snapshotSha = "aa86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
var completedSteps = new HashSet<int> { 0 };
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
source = "stored",
|
||||
manifestId = "m-20260830-06",
|
||||
schemaHash = "sha256:ef02",
|
||||
snapshotSha256 = snapshotSha,
|
||||
estimatedRowCount = 0L,
|
||||
estimatedSnapshotBytes = 512L,
|
||||
preflightCounts = new Dictionary<string, int>(),
|
||||
sampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal),
|
||||
progress = new
|
||||
{
|
||||
SnapshotSha256 = snapshotSha,
|
||||
completedStepOrders = completedSteps,
|
||||
VerificationDone = false,
|
||||
TombstoneStarted = false,
|
||||
CompletedAt = (DateTime?)null,
|
||||
aggregateDecrementsApplied = 0,
|
||||
restoreReIncrementsApplied = 0,
|
||||
LastFailureType = (string?)null,
|
||||
LastFailureMessage = (string?)null,
|
||||
LastObservedResidualCount = (int?)null,
|
||||
LastResidualSampleJson = (string?)null,
|
||||
},
|
||||
},
|
||||
new GetSessionDeletionManifestResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
Source = "stored",
|
||||
ManifestId = "m-20260830-06",
|
||||
SchemaHash = "sha256:ef02",
|
||||
SnapshotSha256 = snapshotSha,
|
||||
EstimatedRowCount = 0,
|
||||
EstimatedSnapshotBytes = 512,
|
||||
PreflightCounts = new Dictionary<string, int>(),
|
||||
SampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal),
|
||||
Progress = new SessionDeletionProgressWire
|
||||
{
|
||||
SnapshotSha256 = snapshotSha,
|
||||
CompletedStepOrders = completedSteps,
|
||||
VerificationDone = false,
|
||||
TombstoneStarted = false,
|
||||
CompletedAt = null,
|
||||
AggregateDecrementsApplied = 0,
|
||||
RestoreReIncrementsApplied = 0,
|
||||
LastFailureType = null,
|
||||
LastFailureMessage = null,
|
||||
LastObservedResidualCount = null,
|
||||
LastResidualSampleJson = null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDeletionManifestResponse_omits_a_null_progress_object()
|
||||
{
|
||||
var snapshotSha = "bb86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
// Old site: `progress = progress == null ? null : new { ... }` — the whole key vanishes.
|
||||
object? progress = null;
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
mode = "summary",
|
||||
source = "stored",
|
||||
manifestId = "m-20260830-07",
|
||||
schemaHash = "sha256:ef03",
|
||||
snapshotSha256 = snapshotSha,
|
||||
estimatedRowCount = 0L,
|
||||
estimatedSnapshotBytes = 256L,
|
||||
preflightCounts = new Dictionary<string, int>(),
|
||||
sampleKeys = new Dictionary<string, List<object>>(StringComparer.Ordinal),
|
||||
progress,
|
||||
},
|
||||
new GetSessionDeletionManifestResponse
|
||||
{
|
||||
Success = true,
|
||||
Mode = "summary",
|
||||
Source = "stored",
|
||||
ManifestId = "m-20260830-07",
|
||||
SchemaHash = "sha256:ef03",
|
||||
SnapshotSha256 = snapshotSha,
|
||||
EstimatedRowCount = 0,
|
||||
EstimatedSnapshotBytes = 256,
|
||||
PreflightCounts = new Dictionary<string, int>(),
|
||||
SampleKeys = new Dictionary<string, List<DeletionRowKeySample>>(StringComparer.Ordinal),
|
||||
Progress = null,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- MarkSessionFailed / MarkSessionSucceeded ----------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void MarkSessionFailed_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var sessionId = "dee86277-0007-4b7c-a1d2-52f3aaaa0013";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Session {sessionId} marked as failed"
|
||||
},
|
||||
new SuccessMessageResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Session {sessionId} marked as failed"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkSessionSucceeded_success_body_matches_SuccessMessageResponse()
|
||||
{
|
||||
var sessionId = "eff97388-0008-4b7c-a1d2-52f3aaaa0014";
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Session {sessionId} marked as succeeded"
|
||||
},
|
||||
new SuccessMessageResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Session {sessionId} marked as succeeded"
|
||||
});
|
||||
}
|
||||
|
||||
// ---- QueueSessionAction --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void QueueSessionActionResponse_matches_the_queue_acknowledgement_shape()
|
||||
{
|
||||
var actionType = "request_diagnostics";
|
||||
var queuedAt = new DateTime(2026, 8, 30, 13, 37, 0, DateTimeKind.Utc);
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
message = $"Action '{actionType}' queued for delivery",
|
||||
queuedAt
|
||||
},
|
||||
new QueueSessionActionResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Action '{actionType}' queued for delivery",
|
||||
QueuedAt = queuedAt
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertParity(object anonymousLiteral, IApiResponse typed)
|
||||
=> ApiResponseWireParityTests.AssertWireIdentical(anonymousLiteral, typed);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Xunit;
|
||||
@@ -56,6 +57,9 @@ public sealed class SharedManifestParityTests
|
||||
["$comment"] = "GENERATED from AutopilotMonitor.Shared by SharedManifestParityTests — do not edit by hand. " +
|
||||
"Regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, " +
|
||||
"then node scripts/generate-shared-manifest-types.js.",
|
||||
// v2: adds the "types" section (full wire-type graph) and drops v1's
|
||||
// "sessionSummary" (it had no TS consumer; SessionSummary now rides in "types").
|
||||
["schemaVersion"] = 2,
|
||||
["adminConfiguration"] = new Dictionary<string, object?>
|
||||
{
|
||||
["fields"] = WireFieldNames(typeof(AdminConfiguration)),
|
||||
@@ -64,10 +68,6 @@ public sealed class SharedManifestParityTests
|
||||
{
|
||||
["fields"] = WireFieldNames(typeof(TenantConfiguration)),
|
||||
},
|
||||
["sessionSummary"] = new Dictionary<string, object?>
|
||||
{
|
||||
["fields"] = WireFieldsWithOptionality(typeof(SessionSummary)),
|
||||
},
|
||||
// Enum member order is load-bearing (append-only ordinals) — declaration order kept.
|
||||
["sessionStatuses"] = Enum.GetNames(typeof(SessionStatus)),
|
||||
["enrollmentPhases"] = EnumMap<EnrollmentPhase>(),
|
||||
@@ -75,6 +75,12 @@ public sealed class SharedManifestParityTests
|
||||
["webhookProviderTypes"] = EnumMap<WebhookProviderType>(),
|
||||
["annotationLanes"] = AnnotationLanes.All,
|
||||
["annotationVerdicts"] = AnnotationVerdicts.All,
|
||||
// Ops vocabularies. Declaration order kept for severities — it IS the ladder
|
||||
// (Info < Warning < Error < Critical), and the MCP renders it in that order.
|
||||
["opsEventCategories"] = ConstStrings(typeof(OpsEventCategory)),
|
||||
["opsEventSeverities"] = ConstStrings(typeof(OpsEventSeverity)),
|
||||
// Declaration order (grouped by category) — the MCP renders this catalog verbatim.
|
||||
["opsEventTypes"] = OpsEventTypes.All,
|
||||
["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)),
|
||||
["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)),
|
||||
["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)),
|
||||
@@ -84,6 +90,9 @@ public sealed class SharedManifestParityTests
|
||||
// Declaration order kept: the web derives a union type from this list, order is cosmetic
|
||||
// but a stable order keeps the generated file diff-minimal.
|
||||
["signalRMessages"] = ConstStrings(typeof(Constants.SignalRMessages)),
|
||||
// Every IApiResponse implementer + [WireContract] type, transitively closed —
|
||||
// the source of utils/wire-types.generated.ts. See WireTypeManifestBuilder.
|
||||
["types"] = WireTypeManifestBuilder.BuildTypesSection(),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
|
||||
@@ -99,33 +108,10 @@ public sealed class SharedManifestParityTests
|
||||
private static string[] WireFieldNames(Type type)
|
||||
=> WireProperties(type).Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Wire names plus whether the field can be ABSENT from the JSON: nullable fields are
|
||||
/// omitted under WhenWritingNull, so nullable ⇒ the TS mirror must type it optional.
|
||||
/// </summary>
|
||||
private static object[] WireFieldsWithOptionality(Type type)
|
||||
{
|
||||
var nullability = new NullabilityInfoContext();
|
||||
return WireProperties(type)
|
||||
.Select(p => (object)new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = JsonNamingPolicy.CamelCase.ConvertName(p.Name),
|
||||
["optional"] = IsNullable(p, nullability),
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<PropertyInfo> WireProperties(Type type)
|
||||
=> type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.GetMethod != null && p.GetIndexParameters().Length == 0);
|
||||
|
||||
private static bool IsNullable(PropertyInfo p, NullabilityInfoContext ctx)
|
||||
{
|
||||
if (Nullable.GetUnderlyingType(p.PropertyType) != null) return true;
|
||||
if (p.PropertyType.IsValueType) return false;
|
||||
return ctx.Create(p).ReadState != NullabilityState.NotNull;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> EnumMap<TEnum>() where TEnum : struct, Enum
|
||||
=> Enum.GetValues<TEnum>()
|
||||
.ToDictionary(v => v.ToString(), v => Convert.ToInt32(v));
|
||||
|
||||
@@ -65,7 +65,15 @@ public class SlaMetricsServiceTests
|
||||
int? durationSeconds = null,
|
||||
DateTime? startedAt = null)
|
||||
{
|
||||
var started = startedAt ?? DateTime.UtcNow.AddHours(-1);
|
||||
// The CurrentWeek asserts require the session to fall into the CURRENT ISO week
|
||||
// (SlaMetricsService groups by GetIsoWeekKey, weeks start Monday 00:00 UTC).
|
||||
// A plain "now - 1h" crosses into LAST week during the first hour of every
|
||||
// Monday (UTC) and empties CurrentWeek — exactly the CI failure on
|
||||
// Mon 2026-08-31 00:20Z. Clamp the anchor to the week's Monday instead.
|
||||
var now = DateTime.UtcNow;
|
||||
var isoWeekMonday = now.Date.AddDays(-(((int)now.DayOfWeek + 6) % 7));
|
||||
var anHourAgo = now.AddHours(-1);
|
||||
var started = startedAt ?? (anHourAgo >= isoWeekMonday ? anHourAgo : isoWeekMonday);
|
||||
return new SessionSummary
|
||||
{
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
|
||||
@@ -168,11 +168,8 @@ public class SlaTenantStatusLifecycleTests
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
|
||||
|
||||
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
webhook,
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(_adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
var telemetry = new TelemetryClient(new TelemetryConfiguration());
|
||||
@@ -183,7 +180,7 @@ public class SlaTenantStatusLifecycleTests
|
||||
_maintenanceRepo.Object,
|
||||
_sessionRepo.Object,
|
||||
_metricsRepo.Object,
|
||||
webhook,
|
||||
channelDispatcher,
|
||||
NotificationService.Object,
|
||||
StatusRepo,
|
||||
_adminConfig.Object,
|
||||
@@ -419,9 +416,8 @@ public class SlaTenantStatusLifecycleTests
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(recentPage, null));
|
||||
|
||||
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
|
||||
var alertDispatch = new OpsAlertDispatchService(adminCfg.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
webhook, NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminCfg.Object);
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
|
||||
var opsSvc = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
@@ -432,7 +428,7 @@ public class SlaTenantStatusLifecycleTests
|
||||
var svc = new SlaBreachEvaluationService(
|
||||
tenantConfigSvc, configRepoMock.Object, maintenanceRepo.Object, sessionRepo.Object,
|
||||
metricsRepo.Object,
|
||||
webhook, notif.Object, statusRepo, adminCfg.Object, opsSvc,
|
||||
channelDispatcher, notif.Object, statusRepo, adminCfg.Object, opsSvc,
|
||||
new TelemetryClient(new TelemetryConfiguration()),
|
||||
NullLogger<SlaBreachEvaluationService>.Instance);
|
||||
|
||||
@@ -850,14 +846,13 @@ public class SlaTenantStatusLifecycleTests
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var webhook = new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance);
|
||||
var alertDispatch = new OpsAlertDispatchService(adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
webhook, NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var channelDispatcher = new NotificationChannelDispatcher(webhook, new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance));
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
Service = new SlaBreachEvaluationService(
|
||||
tenantConfigService.Object, _configRepo.Object, _maintenanceRepo.Object, _sessionRepo.Object,
|
||||
_metricsRepo.Object, webhook, notifService.Object, StatusRepo, adminConfig.Object, opsService,
|
||||
_metricsRepo.Object, channelDispatcher, notifService.Object, StatusRepo, adminConfig.Object, opsService,
|
||||
new TelemetryClient(new TelemetryConfiguration()), NullLogger<SlaBreachEvaluationService>.Instance);
|
||||
|
||||
_sessionRepo.Setup(r => r.GetSessionsPageAsync(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()))
|
||||
|
||||
+6
@@ -386,6 +386,7 @@ public class StoreSessionReregistrationPreserveTests
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Sessions { get; }
|
||||
public Mock<TableClient> Lookup { get; } = new();
|
||||
public TableStorageService Sut { get; }
|
||||
public TableEntity? Written { get; private set; }
|
||||
|
||||
@@ -423,8 +424,13 @@ public class StoreSessionReregistrationPreserveTests
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
// Ownership claim (first-writer-wins lookup) — Add succeeds: this tenant owns the id.
|
||||
Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Mock<Response>().Object);
|
||||
|
||||
var mockServiceClient = new Mock<TableServiceClient>();
|
||||
mockServiceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(Sessions.Object);
|
||||
mockServiceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionTenantLookup)).Returns(Lookup.Object);
|
||||
// Events / SessionsIndex intentionally unconfigured → null client → SUT swallows.
|
||||
|
||||
Sut = new TableStorageService(mockServiceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SessionTenantLookup ownership claim in <see cref="TableStorageService.StoreSessionAsync"/>.
|
||||
/// The lookup (PK = client-supplied sessionId → owning tenantId) is the authority behind every
|
||||
/// global-scope cross-tenant session resolve. It used to be an unconditional last-writer-wins
|
||||
/// upsert AFTER the Sessions row, so any tenant's device could re-point a victim sessionId at
|
||||
/// its own tenant. Now it is a create-only claim BEFORE any session write: a foreign owner
|
||||
/// refuses the registration (409, nothing written), the same owner re-registers as before.
|
||||
/// </summary>
|
||||
public class StoreSessionTenantLookupClaimTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "44444444-4444-4444-4444-444444444444";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static SessionRegistration Registration(string tenantId) => new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = SessionId,
|
||||
StartedAt = new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc),
|
||||
SerialNumber = "SN-1",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task FreshSessionId_ClaimsTheLookup_BeforeWritingTheSessionRow()
|
||||
{
|
||||
var harness = new Harness(existingOwner: null);
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new[] { "lookup-add", "session-write" }, harness.Order);
|
||||
Assert.Equal(TenantA, harness.ClaimedTenant);
|
||||
harness.Lookup.Verify(t => t.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SameTenantReregistration_KeepsTheExistingClaim_AndStoresNormally()
|
||||
{
|
||||
var harness = new Harness(existingOwner: TenantA.ToUpperInvariant());
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Contains("session-write", harness.Order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForeignTenant_IsRefused_AndNothingIsWritten()
|
||||
{
|
||||
// Attacker in tenant B registers the victim's (tenant A) sessionId.
|
||||
var harness = new Harness(existingOwner: TenantA);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SessionTenantConflictException>(
|
||||
() => harness.Sut.StoreSessionAsync(Registration(TenantB)));
|
||||
|
||||
Assert.Equal(SessionId, ex.SessionId);
|
||||
Assert.Equal(TenantB, ex.RequestedTenantId);
|
||||
Assert.Equal(TenantA, ex.OwningTenantId);
|
||||
// The mapping still points at A, and B never got a Sessions row for the id.
|
||||
Assert.DoesNotContain("session-write", harness.Order);
|
||||
harness.Lookup.Verify(t => t.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
harness.Lookup.Verify(t => t.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimStorageFailure_IsNotFailSoft_TheSessionIsNotStored()
|
||||
{
|
||||
var harness = new Harness(existingOwner: null);
|
||||
harness.Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "ServerBusy"));
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(Registration(TenantA));
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.DoesNotContain("session-write", harness.Order);
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Sessions { get; } = new();
|
||||
public Mock<TableClient> Lookup { get; } = new();
|
||||
public List<string> Order { get; } = new();
|
||||
public string? ClaimedTenant { get; private set; }
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness(string? existingOwner)
|
||||
{
|
||||
// Sessions: no existing row; Add captures the write order.
|
||||
Sessions.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "ResourceNotFound"));
|
||||
Sessions.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, CancellationToken>((_, _) =>
|
||||
{
|
||||
Order.Add("session-write");
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
if (existingOwner is null)
|
||||
{
|
||||
Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, CancellationToken>((e, _) =>
|
||||
{
|
||||
Order.Add("lookup-add");
|
||||
ClaimedTenant = e.GetString("TenantId");
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Lookup.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(409, "EntityAlreadyExists"));
|
||||
Lookup.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(
|
||||
new TableEntity(SessionId, "tenant") { ["TenantId"] = existingOwner }, Mock.Of<Response>()));
|
||||
}
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(Sessions.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionTenantLookup)).Returns(Lookup.Object);
|
||||
// Events / SessionsIndex intentionally unconfigured → null client → SUT swallows.
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -283,13 +283,7 @@ public sealed class SubmitOffboardingFeedbackFunctionTests
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
memCache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
|
||||
return (
|
||||
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch),
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Telegram as a notification-channel provider: dispatch routing (it must never reach the
|
||||
/// SSRF-guarded webhook path), chat-ID validation, and the Global-Admin gate that keeps a
|
||||
/// tenant admin from sending through the platform-owned bot.
|
||||
/// </summary>
|
||||
public class TelegramChannelProviderTests
|
||||
{
|
||||
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(int providerType, string? url, string id = "c1", bool enabled = true)
|
||||
=> new() { Id = id, Name = id, ProviderType = providerType, Url = url, Enabled = enabled };
|
||||
|
||||
private static string ChannelJson(int providerType, string url, string id = "c1", bool enabled = true)
|
||||
=> $"[{{\"id\":\"{id}\",\"name\":\"{id}\",\"providerType\":{providerType},\"url\":\"{url}\",\"enabled\":{(enabled ? "true" : "false")}}}]";
|
||||
|
||||
// ── Dispatch routing ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_RoutesTelegramToBotAndNeverToWebhook()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Telegram, "-1003785642894") }, alert);
|
||||
|
||||
telegram.Verify(t => t.SendOpsAlertAsync("-1003785642894", 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_RoutesWebhookProvidersToWebhookService()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(
|
||||
new[] { Channel((int)WebhookProviderType.Slack, "https://hooks.slack.example/x") }, alert);
|
||||
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
"https://hooks.slack.example/x", WebhookProviderType.Slack, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_MixedList_EachChannelReachesItsOwnTransport()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
var alert = new NotificationAlert { Title = "t", Summary = "s" };
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel((int)WebhookProviderType.Telegram, "-100123", "tg"),
|
||||
Channel((int)WebhookProviderType.GenericJson, "https://sales.example/hook", "sales"),
|
||||
}, alert);
|
||||
|
||||
telegram.Verify(t => t.SendOpsAlertAsync("-100123", alert), Times.Once);
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
"https://sales.example/hook", WebhookProviderType.GenericJson, alert,
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendToChannels_SkipsChannelsWithoutDestination()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
|
||||
await dispatcher.SendToChannelsAsync(new[]
|
||||
{
|
||||
Channel((int)WebhookProviderType.Telegram, null, "tg"),
|
||||
Channel((int)WebhookProviderType.Slack, "", "slack"),
|
||||
}, new NotificationAlert { Title = "t", Summary = "s" });
|
||||
|
||||
telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()), Times.Never);
|
||||
webhook.Verify(w => w.SendNotificationAsync(
|
||||
It.IsAny<string>(), It.IsAny<WebhookProviderType>(), It.IsAny<NotificationAlert>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendWithResult_RoutesTelegramToBot()
|
||||
{
|
||||
var webhook = WebhookMock();
|
||||
var telegram = TelegramMock();
|
||||
telegram.Setup(t => t.SendAlertWithResultAsync(It.IsAny<string>(), It.IsAny<NotificationAlert>()))
|
||||
.ReturnsAsync(new WebhookTestResult { Success = true, Message = "ok" });
|
||||
var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object);
|
||||
|
||||
var result = await dispatcher.SendWithResultAsync(
|
||||
Channel((int)WebhookProviderType.Telegram, "@salesdesk"),
|
||||
new NotificationAlert { Title = "t", Summary = "s" });
|
||||
|
||||
Assert.True(result.Success);
|
||||
telegram.Verify(t => t.SendAlertWithResultAsync("@salesdesk", It.IsAny<NotificationAlert>()), Times.Once);
|
||||
}
|
||||
|
||||
// ── Chat-ID validation ────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("-1003785642894")]
|
||||
[InlineData("123456789")]
|
||||
[InlineData("@salesdesk")]
|
||||
[InlineData("@Sales_Desk_01")]
|
||||
public void ValidateTelegramChatId_AcceptsNumericAndUsernameForms(string chatId)
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("https://api.telegram.org/bot123/sendMessage")]
|
||||
[InlineData("-100abc")]
|
||||
[InlineData("@abc")] // too short
|
||||
[InlineData("@1sales")] // must start with a letter
|
||||
[InlineData("@sales-desk")] // hyphen not allowed
|
||||
[InlineData("123456789012345678901")] // 21 digits
|
||||
public void ValidateTelegramChatId_RejectsEverythingElse(string? chatId)
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChatId(chatId));
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_TelegramChatIdBypassesSsrfUrlGate()
|
||||
{
|
||||
// A chat ID is not a URL — the webhook SSRF gate would reject it. Proves the
|
||||
// provider-specific destination branch is wired, not just the format helper.
|
||||
Assert.Null(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-1003785642894")));
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Slack, "-1003785642894")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateNotificationChannels_RejectsTelegramChannelWithUrlDestination()
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateNotificationChannels(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "https://hooks.example/x")));
|
||||
|
||||
// ── Global-Admin gate ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_GlobalAdminMayAddChannel()
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: true));
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotAddChannel()
|
||||
=> Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.Telegram, "-100123"), null, isGlobalAdmin: false));
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotRetargetExistingChannel()
|
||||
{
|
||||
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
||||
var retargeted = ChannelJson((int)WebhookProviderType.Telegram, "-100999");
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMayNotFlipEnabledState()
|
||||
{
|
||||
var existing = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: true);
|
||||
var disabled = ChannelJson((int)WebhookProviderType.Telegram, "-100123", enabled: false);
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(disabled, existing, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_TenantAdminMaySaveWithUnchangedGaCreatedChannel()
|
||||
{
|
||||
// The gate must not turn a GA-created Telegram channel into a permanent save block
|
||||
// for the tenant admin's unrelated configuration changes.
|
||||
var json = ChannelJson((int)WebhookProviderType.Telegram, "-100123");
|
||||
|
||||
Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(json, json, isGlobalAdmin: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelegramGate_IgnoresNonTelegramChannels()
|
||||
=> Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(
|
||||
ChannelJson((int)WebhookProviderType.GenericJson, "https://sales.example/hook"), null, isGlobalAdmin: false));
|
||||
|
||||
// ── Both write paths (PUT + field patch) go through ValidateModel ─────
|
||||
|
||||
[Fact]
|
||||
public void ValidateModel_EnforcesTelegramGateForNonGlobalAdmin()
|
||||
{
|
||||
var candidate = new TenantConfiguration
|
||||
{
|
||||
TenantId = "t1",
|
||||
NotificationChannelsJson = ChannelJson((int)WebhookProviderType.Telegram, "-100123"),
|
||||
};
|
||||
var existing = new TenantConfiguration { TenantId = "t1" };
|
||||
|
||||
Assert.NotNull(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
|
||||
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: true));
|
||||
}
|
||||
}
|
||||
@@ -224,10 +224,11 @@ public class TenantAdminManagementFunctionTests
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
// Mirror the production camelCase policy so camelCase test bodies bind to the
|
||||
// PascalCase request models exactly like real web requests do.
|
||||
// Production wire settings (camelCase policy binds camelCase test bodies to the
|
||||
// PascalCase request models exactly like real web requests do; absent-when-null and
|
||||
// string enums match what the deployed worker serializes).
|
||||
services.Configure<WorkerOptions>(o => o.Serializer =
|
||||
new JsonObjectSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
||||
new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var requestContext = new RequestContext
|
||||
|
||||
@@ -51,13 +51,7 @@ public class TenantApprovalServiceTests
|
||||
.Returns(Task.CompletedTask);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
|
||||
_sut = new TenantApprovalService(
|
||||
NullLogger<TenantApprovalService>.Instance,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user