mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0ab3eee40 | |||
| 2de8f5c4e6 | |||
| fe07f5cef0 | |||
| 57775d6ba2 | |||
| 91b702e239 | |||
| e12da0c99b | |||
| 3ac647a680 | |||
| ecf6e8c5a8 | |||
| f26721c0f7 | |||
| 789926fcad | |||
| 01e76a6ff7 | |||
| daa6df6bab | |||
| 98fd173fb8 | |||
| 06626a4095 | |||
| 22a8c7a51d | |||
| 72136f0273 | |||
| 9c96881242 | |||
| 6c6014940e | |||
| c60c0bbede | |||
| c4280e823c | |||
| 6dc2506fc5 | |||
| 88cd29d3b9 | |||
| f4d99b43f4 | |||
| 6c2b431aec | |||
| 383d0694fe | |||
| 792b7d3b0e | |||
| c48f95b88c | |||
| 4c413337ca | |||
| 8277d70915 | |||
| e1b3ec25fd | |||
| 321ae63c63 | |||
| 842db1603a |
+1
-1
Submodule .claude/commands updated: 0766ebfef6...4a98eb1da6
@@ -8,6 +8,7 @@ on:
|
||||
- 'rules/ime-log-patterns/**'
|
||||
- 'rules/schema/**'
|
||||
- 'rules/scripts/**'
|
||||
- 'rules/guardrails.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'rules/gather/**'
|
||||
@@ -15,6 +16,7 @@ on:
|
||||
- 'rules/ime-log-patterns/**'
|
||||
- 'rules/schema/**'
|
||||
- 'rules/scripts/**'
|
||||
- 'rules/guardrails.json'
|
||||
|
||||
jobs:
|
||||
validate-and-combine:
|
||||
@@ -55,9 +57,11 @@ jobs:
|
||||
- name: Combine rules into dist/
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
- name: Commit dist/ changes
|
||||
- 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 from individual files"
|
||||
file_pattern: "rules/dist/*"
|
||||
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"
|
||||
|
||||
@@ -16,6 +16,10 @@ on:
|
||||
- 'src/Web/autopilot-monitor-web/**'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
|
||||
env:
|
||||
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
|
||||
jobs:
|
||||
build_and_deploy_job:
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')
|
||||
@@ -48,6 +52,9 @@ jobs:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
|
||||
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
|
||||
# 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)
|
||||
@@ -64,6 +71,55 @@ jobs:
|
||||
output_location: "out" # Built app content directory - optional
|
||||
###### End of Repository/Build Configurations ######
|
||||
|
||||
# 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.
|
||||
- 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)
|
||||
$deadline = [DateTime]::UtcNow.AddMinutes(5)
|
||||
$attempt = 0
|
||||
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
$attempt++
|
||||
try {
|
||||
$v = Invoke-RestMethod -Uri '${{ env.PORTAL_VERSION_URL }}' -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' }
|
||||
if ($v.commit -eq $expectedCommit) {
|
||||
Write-Host "Live after $attempt attempt(s): web @ $($v.commit), built $($v.buildUtc)"
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Attempt ${attempt}: serving $($v.commit) -- waiting for $expectedCommit"
|
||||
} catch {
|
||||
Write-Host "Attempt ${attempt}: /version.json not answering yet -- $($_.Exception.Message)"
|
||||
}
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
|
||||
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 —
|
||||
# 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]@{
|
||||
component = 'web'
|
||||
commit = '${{ github.sha }}'.Substring(0, 7)
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
} | ConvertTo-Json
|
||||
|
||||
$sas = '${{ secrets.AZURE_BLOB_VERSIONS_SAS_TOKEN }}'.TrimStart('?')
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/web.json?$sas"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
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
|
||||
|
||||
@@ -154,3 +154,7 @@ docs/~$Introduction-AutopilotMonitor.pptx
|
||||
|
||||
# Playwright MCP browser artifacts (snapshots, screenshots, console logs)
|
||||
.playwright-mcp/
|
||||
|
||||
# Web build stamp: generated by scripts/generate-version.js on every build (buildUtc
|
||||
# changes each run) and served at /version.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/version.json
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
type: Concept
|
||||
title: App Version Duration Regression Radar
|
||||
description: How the radar detects app versions whose median install duration regressed vs. the previous version (median lift ≥2x plus an absolute floor over first-seen-ordered versions), how alert episodes live in the appversionregression tracker keyspace, and which surfaces show them.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs
|
||||
tags:
|
||||
- backend
|
||||
- apps
|
||||
- regression-radar
|
||||
- insights
|
||||
timestamp: 2026-08-13T12:00:00+02:00
|
||||
---
|
||||
|
||||
# App Version Duration Regression Radar
|
||||
|
||||
Detects "version X made this app's installs much slower" — the concrete, quotable insight
|
||||
("median install duration rose from 11 to 29 min after version X") — from the per-(session,
|
||||
app) `AppInstallSummaries` rows. Duration only: failure-rate per version is already visible
|
||||
in the apps dashboard's `versionBreakdown`, and rate regressions belong to Wilson-interval
|
||||
statistics, not medians.
|
||||
|
||||
# Schema
|
||||
|
||||
## Signal & detection
|
||||
|
||||
Per (tenant, app), the MEASURED install population is: `Status == "Succeeded"`, terminal
|
||||
state not Skipped/Postponed, `MetricsMath.HasMeasuredDuration` (1s..6h — 0s means the start
|
||||
was unobserved, above 6h means back-stamped batches), no `AppIdCollision`, non-empty
|
||||
`AppVersion`. Versions are ordered by FIRST-SEEN install time (min `StartedAt`), never by
|
||||
string comparison ("9.1" vs "2024.10" would lie). The comparison pair is always the newest
|
||||
version (the one with the most recent measured install) vs. the version with the latest
|
||||
first-seen strictly before it.
|
||||
|
||||
MEDIANS, not means (nearest-rank, shared with the apps dashboard via
|
||||
`AppsAnalyticsHelper.Percentile`) — one back-stamped straggler must not fire a fleet alert.
|
||||
A regression fires only when ALL hold over the trailing 35-day horizon:
|
||||
|
||||
1. both versions have ≥ 10 measured installs;
|
||||
2. current median ≥ 2× the previous version's median;
|
||||
3. absolute increase ≥ 300 s (a 20s→45s app is noise, not an incident).
|
||||
|
||||
## Suppression & limits
|
||||
|
||||
* Parallel ring rollouts can make "previous" a concurrently-deployed version — accepted in
|
||||
v1; the episode key (app, current version) still caps it at one bell per version.
|
||||
* Platform kill switch: app setting `AppVersionRegressionRadarDisabled=true` skips the
|
||||
radar (fail-open — it only notifies, never mutates data).
|
||||
* One cross-tenant install-summary read per pass (the same query the global apps dashboard
|
||||
runs), grouped by tenant; per-tenant failures are non-fatal.
|
||||
|
||||
## Alert episodes (tracker keyspace)
|
||||
|
||||
An episode lives as `appversionregression|{app-lower}|{version-lower}` (table-key-sanitized;
|
||||
raw AppName/CurrentVersion live in columns, the mapper never parses the RowKey) in the
|
||||
notification-tracker table, next to the `ruleregression|` keyspace: the row IS the dedup
|
||||
(bell + ops event fire exactly once per episode) and the `versionRegressions[]` payload —
|
||||
both medians, both sample counts, lift. Numbers refresh on every radar pass while the
|
||||
episode stays active; `FirstNotifiedAt` never moves. The episode closes (dedup re-arms) when
|
||||
the alerted version drains under 10 measured installs in the horizon or its recomputed
|
||||
median falls back under 1.5× the previous version's median; the tracker's 30-day retention
|
||||
sweep also re-arms, so a month-old still-burning regression rings once more by design.
|
||||
|
||||
## Notification chain (v1: bell + ops event)
|
||||
|
||||
* Tenant bell `app_version_duration_regression` (audience: Admin), href
|
||||
`/apps/detail?name={app}&days=35` (canonical `appDetailUrl` shape). Message carries the
|
||||
full numbers so the admin can verify without a portal round-trip.
|
||||
* Ops event `AppVersionDurationRegression` (Tenant category, Warning) — dual-registered in
|
||||
the web `OPS_EVENT_TYPES` catalog so operators can wire Telegram/Teams/Slack alert rules.
|
||||
|
||||
## Surfaces
|
||||
|
||||
* `apps/{appName}/analytics` (+ global twin with `?tenantId=`) responds with
|
||||
`versionRegressions[]` (empty for the cross-tenant aggregate — episodes are per-tenant
|
||||
rows), and `versionBreakdown[]` rows now carry `measuredInstalls` /
|
||||
`medianDurationSeconds` / `p95DurationSeconds`.
|
||||
* Apps detail page: amber "↑ Duration regression" banner above the version cards and a
|
||||
"Median Install Duration by Version" bar chart.
|
||||
|
||||
# Examples
|
||||
|
||||
A tenant ships Contoso VPN 2.4.0 after 2.3.9. 2.3.9 has 40 measured installs, median 660s;
|
||||
2.4.0 accumulates 12 measured installs with median 1740s. Lift 2.6×, absolute +1080s →
|
||||
episode `appversionregression|contoso vpn|2.4.0` opens, one bell + one ops event fire.
|
||||
Subsequent passes refresh the numbers only. When 2.4.1 replaces 2.4.0 and 2.4.0 drains
|
||||
below 10 measured installs in the horizon, the episode deletes and the dedup re-arms.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/AppVersionRegressionRadar.cs` — pure core
|
||||
(gates, first-seen ordering, re-arm).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.AppVersionRegression.cs`
|
||||
— orchestration, kill switch, bell/ops-event emission, wording contract.
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs`
|
||||
— episode keyspace, entity round-trip.
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Metrics/AppVersionRegressionAlert.cs` — persisted
|
||||
alert payload.
|
||||
* [Rule Regression Radar](rule-regression-radar.md) — the sibling pattern this radar mirrors.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type: concept
|
||||
title: Lifecycle Manifests & Session Scope Resolution
|
||||
description: The enforced completeness nets added by the fragility-audit P5/P6 remediation — table lifecycle buckets, the SessionsIndex field manifest, the single session-scope resolver with its SessionTenantLookup point-read, and the server-side dashboard free-text search.
|
||||
resource: backend
|
||||
tags: [lifecycle, offboarding, sessionsindex, manifest, scope-resolution, search, performance]
|
||||
timestamp: 2026-08-13
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Four mechanisms replace hand-synchronized copies with a declared source plus tests that fail on drift.
|
||||
|
||||
## 1. Table lifecycle buckets
|
||||
|
||||
Every table in `Constants.TableNames` must belong to at least one deletion lifecycle:
|
||||
|
||||
| Bucket | Mechanism | Source of truth |
|
||||
|---|---|---|
|
||||
| Tenant-offboarding wipe | `TenantOffboardingHandler` bucket arrays (`TenantPartitionTables`, `CompositePartitionTables`, `DiscriminatorTables`, `PropertyOnlyTables`, `RowKeyTables`, `ArchivedRuleTables`) + the one-off `TenantConfiguration` final delete | handler arrays (read via reflection by the test) |
|
||||
| Per-session cascade | `DeletionManifestBuilder.BuildAsync` step list (incl. the Final tombstone → Sessions + SessionsIndex) | a real manifest built against an empty-inventory fake |
|
||||
| Kept by design | reviewed exception list with per-table reasons | `TableLifecycleBucketTests.KeptByDesign` |
|
||||
|
||||
`TableLifecycleBucketTests` (Functions.Tests) asserts: constants set-equal `TableNames.All`, no table unclassified, kept-by-design disjoint from every wipe path, bucket entries are real constants, offboarding buckets disjoint except documented multi-membership (`BootstrapSessions`).
|
||||
|
||||
**Not covered mechanically:** whether a bucket's key shape matches the table's writer. That was the UsageMetrics bug (PK=date, RK=tenantId sat in the exact-PK bucket; the wipe matched 0 rows) — SafeWipe **Variant D** (`WipeByRowKeyAsync`, `RowKey eq '{tenantId}'`) now covers RowKey-anchored tables. Read the writer's `new TableEntity(pk, rk)` before classifying a new table.
|
||||
|
||||
## 2. SessionsIndex field manifest
|
||||
|
||||
`SessionIndexFieldManifest` (Functions/Services) declares the full-mirror column set: `AlwaysProjected` (32, written with defaults on every rebuild), `ConditionallyProjected` (18, written only when present), `PrimaryOnly` (4 — ServerActions + deletion-CAS fields that never touch the index). `SessionIndexFieldManifestTests` pins `BuildSessionIndexEntity` against it bidirectionally (full-row fixture built from a test-local sample table, NOT from the manifest — deriving the fixture from the manifest would make the check circular), pins that every manifest field influences `MapToSessionSummary`, and pins the rebuild's `TableUpdateMode.Replace` (a merge-mode upsert could never clear a conditional column the primary blanked). `MergeSessionIndexAsync` logs a warning for merged keys outside the manifest (fail-soft drift guard).
|
||||
|
||||
## 3. Session scope resolution (one implementation)
|
||||
|
||||
`RequestContextExtensions.ResolveSessionScopeAsync(ctx, sessionRepo, sessionId, requireGlobalAdmin)` is the single implementation of the former 15-site copy-paste fallback ("which tenant owns this session?"):
|
||||
|
||||
- Non-global callers always keep the middleware-validated `TargetTenantId`.
|
||||
- Global-scope callers resolve via `ISessionRepository.ResolveSessionTenantIdAsync` — a point-read on **SessionTenantLookup** (PK=sessionId, RK="tenant") with a legacy cross-partition SessionsIndex scan fallback that self-heals the lookup row, so the scan is paid at most once per pre-table session.
|
||||
- `requireGlobalAdmin: true` on WRITE paths gates the cross-tenant reach on `IsGlobalAdmin` so a read-only Global Reader can never steer a write into a foreign tenant.
|
||||
- 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.
|
||||
|
||||
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).
|
||||
|
||||
## 4. Server-side dashboard search
|
||||
|
||||
`q=` on `/api/search/sessions` + `/api/global/search/sessions`: case-insensitive substring over exactly the fields the dashboard's client-side filter searches (device name, serial, manufacturer, model, status, sessionId, geo, agent version, OS fields — NOT the client-derived date/duration/blocked tokens; a server-only field would produce ghost results the client filters back out). Azure Tables has no substring operator, so the scan path backfills whole Azure pages until it has `pageSize` matches (max 10 pages per request, continuation at a page boundary — gap-free). `q` is bound into the pagination fingerprint. The web dashboard's `searchAll()` (useDashboardSessions) follows nextLink up to 10 requests / 50 matches and dedupe-merges results into the loaded list (`mergeSessionsById`), replacing the former `loadAll()` walk that downloaded the entire session history to search it client-side. Delegated callers in cross-tenant mode keep the legacy full sweep (the global search route has no delegated tier).
|
||||
|
||||
# Examples
|
||||
|
||||
Adding a new table: classify it in one lifecycle path before `TableLifecycleBucketTests` lets it ship — offboarding bucket (check the writer's PK/RK shape first), deletion-manifest step, or `KeptByDesign` with a reason.
|
||||
|
||||
Adding a mirrored SessionsIndex field: manifest entry + `BuildSessionIndexEntity` write + `MapToSessionSummary` read + a sample value in the test — any missing step is red.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs` (`ResolveSessionScopeAsync`)
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs` (Variant D)
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` (`MatchesFreeText`, backfill loop)
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
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
|
||||
tags:
|
||||
- contracts
|
||||
- guard-tests
|
||||
- signalr
|
||||
- web
|
||||
timestamp: 2026-08-13T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Drift Guards
|
||||
|
||||
Follow-up to the 2026-08 fragility audit (see [Lifecycle Manifests & Session Scope
|
||||
Resolution](backend/lifecycle-manifests-and-session-scope.md) for the earlier rounds).
|
||||
Each section below replaces a hand-synchronized mirror with an enforced contract: a
|
||||
change on one side now fails a build, a type check, or a test on the other side.
|
||||
|
||||
# Schema
|
||||
|
||||
## SignalR message names — one catalog, typed on both ends
|
||||
|
||||
`Constants.SignalRMessages` (Shared) is the complete catalog of SignalR target names.
|
||||
Backend senders (output bindings and the imperative `SignalRNotificationService`)
|
||||
reference the constants; the hub name is `SignalRGroupHelper.HubName`. The catalog is
|
||||
exported into `shared-manifests.json` (`signalRMessages` section) by
|
||||
`SharedManifestParityTests`, and the web's `SignalRContext.on/off` type their event
|
||||
name against the generated union (`lib/signalrMessages.ts`) — subscribing to a name
|
||||
the backend never sends is a tsc error, not a silent no-op. `newevents` is the one
|
||||
legacy lowercase name; it is a persisted wire name, do not "fix" the casing.
|
||||
|
||||
## Inverted-tick RowKeys — one codec
|
||||
|
||||
`RowKeyCodec` (Functions/Helpers) is the single encoder/decoder for
|
||||
`rev(t) = MaxTicks - t.Ticks` RowKeys. Every writer routes through it; the persisted
|
||||
per-table shapes (D19 standard, AuditLogs `!`-prefix, UserActivity's legacy D20 width,
|
||||
ConfigurationBackups' 28-char truncation) are pinned by `RowKeyCodecTests` and must
|
||||
never change — a width/prefix change reorders live tables against their existing rows.
|
||||
`BusinessTimestamp` keeps the OData clause builders and delegates decode to the codec.
|
||||
|
||||
## Tombstone rows carry their table
|
||||
|
||||
`DeletionRowDump.Table` names the source table on FINAL (Tombstone) rows — the one
|
||||
step that mixes two tables while `DeletionStep.Table` is null.
|
||||
`DeletionTombstoneTables.Resolve` is the only consumer-side resolution (delete +
|
||||
restore + dry-run); the historical `Contains('_')` RowKey-shape heuristic survives
|
||||
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
|
||||
|
||||
`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.
|
||||
|
||||
## Tenant settings — per-section PATCH
|
||||
|
||||
The web's Settings sections no longer PUT the full ~92-field configuration. Each
|
||||
section's exact write surface lives in `app/settings/sectionFieldMap.ts` (owned fields
|
||||
plus documented `alsoWrites` write-throughs); `saveConfiguration` diffs those fields
|
||||
against the loaded config and PATCHes only the changes through
|
||||
`PATCH config/{tenantId}/fields` — the transactional endpoint (CAS + fail-closed
|
||||
backup + exactly-these-fields verify + auto-rollback) that previously served MCP only.
|
||||
The policy line is TenantAdminOrGA; a tenant admin runs on the stricter
|
||||
`TenantConfigCallerTier.TenantAdmin` whose deny-list turns GA-only fields into
|
||||
explicit 400s. Deploy order: backend before web. `sectionFieldMap.test.ts` pins
|
||||
field↔section ownership (each field exactly one owner, all fields on the model, none
|
||||
server-denied); backups/revert stay GlobalAdminOnly.
|
||||
|
||||
## Scoped endpoint routing — one decision object
|
||||
|
||||
`lib/scopedApi.ts` owns the tenant/global endpoint-pair choice: pages pass their scope
|
||||
hook object (`routeGlobal`, `selectedTenantId`, `effectiveTenantId`) instead of
|
||||
hand-rolling `routeGlobal ? global(..., sel || undefined) : tenant(tid, ...)` with the
|
||||
tenant parameter at a different position per pair. `useGlobalAdminScope` is now a thin
|
||||
projection over `useAggregatedAdminScope` (one hook, aggregated as a mode) via the
|
||||
pure `resolveConcreteScopeView`. `lib/scopedFetch.ts` adds the shared JSON+ok-check
|
||||
fetch layer. `lib/navVisibility.ts` extracts the sidebar's roles × nav-config × guard
|
||||
logic; its matrix test also ratchets the known "sidebar shows it, guard bounces it"
|
||||
mismatches (plain Operator and GlobalReader vs. `/settings` — pending a user decision).
|
||||
|
||||
# Examples
|
||||
|
||||
Renaming a SignalR message end to end: change the constant in
|
||||
`Constants.SignalRMessages` → `AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter
|
||||
SharedManifestParityTests` → `node scripts/generate-shared-manifest-types.js` → tsc
|
||||
now flags every stale web subscription literal.
|
||||
|
||||
Adding a field to a Settings section: add it to the section in `sectionFieldMap.ts`
|
||||
(tsc validates the name against the manifest), wire the form state into the
|
||||
`updatedConfig` construction in `TenantConfigContext`, and extend the expected-fields
|
||||
list in `sectionFieldMap.test.ts`.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Shared/AutopilotMonitor.Shared/Constants.cs` — SignalRMessages catalog
|
||||
- `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/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
|
||||
@@ -14,6 +14,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
|
||||
* [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.
|
||||
* [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.
|
||||
|
||||
# Agent
|
||||
|
||||
@@ -31,6 +32,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
|
||||
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
|
||||
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
|
||||
* [Analyze Rule Evaluation Triggers](rules/analyze-rule-triggers.md) - Opt-in `evaluateOn` interim triggers (`whiteglove_sealed`, `on_event:<type>`) so rules can fire before a session is terminal, with RuleResults update-semantics (interim → finalize/resolve, NotifiedAt notification dedupe) replacing the permanent dedupe freeze, KO/stat suppression on interim runs, the ingest-side cached trigger registry, and the authoring lints that keep interim rules monotonic.
|
||||
|
||||
# Backend
|
||||
|
||||
@@ -39,10 +41,12 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
|
||||
* [Lifecycle Manifests & Session Scope Resolution](backend/lifecycle-manifests-and-session-scope.md) - The fragility-audit P5/P6 completeness nets: every table classified into a deletion lifecycle (offboarding buckets incl. the RowKey-anchored SafeWipe Variant D, per-session cascade, kept-by-design), the SessionsIndex field manifest with bidirectional builder/reader pinning, the single ResolveSessionScopeAsync helper backed by the SessionTenantLookup point-read, and the server-side dashboard free-text search (q=) with its page-backfill loop.
|
||||
|
||||
# MCP Server
|
||||
|
||||
|
||||
+24
@@ -1,5 +1,29 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-17
|
||||
|
||||
* **Update**: `mcp/tool-telemetry.md` — the `tool_call` stderr line now carries `errorMessage` (capped at 300 chars; thrown message or the leading `toolError` text, absent on success), so error drilldowns name the failure class instead of guessing from args. Same change: `get_rule_stats` omits the per-rule daily `trend` rows by default (they drove 50% of calls over the response cap at platform scope); `includeTrends=true` opts back in, the response carries a `trendsNote` marker when stripped.
|
||||
|
||||
## 2026-08-14 (later)
|
||||
|
||||
* **Update**: `rules/analyze-rule-triggers.md` — status SPEC → IMPLEMENTED. Shipped: `evaluateOn` on the analyze-rule schema (grammar-validated server-side and in validate_rule), `AnalyzeRunContext`-driven engine runs (Terminal/Interim/Reanalyze) with the RuleResult lifecycle columns (FirstDetectedAt/LastEvaluatedAt/IsInterim/ResolvedAt/NotifiedAt), interim rule filtering + KO/stat suppression, resolve/finalize update-semantics, the NotifiedAt one-send-per-(session,rule) notification dedupe (preserved across the manual reanalyze rebuild), the ingest-side `InterimTriggerRegistry` (5-min TTL, fail-soft) gating `interim_trigger` / `whiteglove_sealed` enqueues, the three MCP authoring lints, web preliminary/resolved rendering, and ANALYZE-ID-004 as the first interim-enabled rule.
|
||||
|
||||
## 2026-08-14
|
||||
|
||||
* **Creation**: Added `rules/analyze-rule-triggers.md` — SPEC for opt-in `evaluateOn` interim evaluation of analyze rules: `enrollment_end` default preserves today's behavior, `whiteglove_sealed` + `on_event:<type>` close the WhiteGlove notification gap and the never-terminal stuck-session gap; RuleResults update-semantics (FirstDetectedAt/IsInterim/ResolvedAt/NotifiedAt) replace the permanent dedupe freeze; interim runs suppress KO + stats, notifications fire on first detection; three new validate_rule lints (terminal-precondition trap, unknown trigger type, KO-on-interim).
|
||||
|
||||
## 2026-08-13 (late evening)
|
||||
|
||||
* **Creation**: Added `drift-guards.md` — the fragility-audit follow-up contracts (P5.x + P7): SignalR message-name catalog in `Constants.SignalRMessages`, exported through the shared manifest and typed into the web's `SignalRContext.on/off` union; the `RowKeyCodec` single encoder for inverted-tick RowKeys (persisted widths pinned, incl. UserActivity's legacy D20); `DeletionRowDump.Table` on tombstone rows with `DeletionTombstoneTables.Resolve` replacing the duplicated `Contains('_')` heuristic (legacy fallback retained one retention cycle); the shrink-only `OkAsyncBaselineGuardTests` per-file baseline (44 sites frozen); the Settings per-section PATCH (section field map + diff-based `config/{tenantId}/fields` writes on the TenantAdmin caller tier); and the web's `scopedApi` endpoint-pair builders, single admin-scope hook, `navVisibility` matrix and guard-consistency ratchet.
|
||||
|
||||
* **Update**: `versioning.md` — the web portal joins the version contract commit-only: `public/version.json` generated at build time from `GITHUB_SHA`, served with `Cache-Control: no-store`, polled by `deploy-web.yml` until the deployed commit matches before `versions/web.json` is published.
|
||||
|
||||
## 2026-08-13 (evening)
|
||||
|
||||
* **Creation**: Added `backend/lifecycle-manifests-and-session-scope.md` — the fragility-audit P5/P6 completeness nets: (1) `TableLifecycleBucketTests` classifies every `TableNames` constant into offboarding-wipe / per-session-cascade / kept-by-design (fixed en route: UsageMetrics wiped via the new RowKey-anchored SafeWipe Variant D — the exact-PK wipe matched 0 rows; PlatformStats tenant partition + ScriptNameCache added to the tenant-PK bucket); (2) `SessionIndexFieldManifest` as the declared SessionsIndex column set with bidirectional builder/reader test pinning, Replace-mode full rebuilds, and a fail-soft merge-key guard; (3) `ResolveSessionScopeAsync` as the single session-scope fallback (16 former copies), backed by the new `SessionTenantLookup` point-read table (scan fallback + self-heal, cascade step before the tombstone), plus the `RecomputeTriggerGate` 403 for reanalyze/rescan by read-only roles; (4) server-side dashboard free-text search (`q=`) with page-backfill loop and fingerprint binding, replacing the client's full-history loadAll sweep.
|
||||
|
||||
* **Creation**: Added `backend/app-version-regression-radar.md` — app-version duration regression radar: per (tenant, app) the newest version's median measured install duration (nearest-rank; population Succeeded + non-skip + `HasMeasuredDuration` + no AppId collision + non-empty AppVersion) is compared against the previous version (first-seen ordering, never string sort); fires at median lift ≥2× AND ≥300s absolute with ≥10 measured installs on both sides. Episodes live as `appversionregression|{app}|{version}` tracker rows (one bell `app_version_duration_regression` + one `AppVersionDurationRegression` ops event per episode, refresh keeps `FirstNotifiedAt`, re-arm on drain/median-recovery/30d retention). Surfaces: `versionRegressions[]` + per-version `measuredInstalls`/`medianDurationSeconds`/`p95DurationSeconds` in the apps analytics responses, amber banner + duration-by-version chart on the apps detail page. Kill switch `AppVersionRegressionRadarDisabled`.
|
||||
|
||||
## 2026-08-13
|
||||
|
||||
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
|
||||
|
||||
@@ -25,7 +25,10 @@ Three layers answer "are the MCP tools doing their job, and are tools missing?":
|
||||
Logs) — the layer that carries what the other two structurally cannot:
|
||||
- `tool_call`: duration, `isError` **including soft errors** (handlers never
|
||||
throw — `toolError` *returns* `isError: true`, so backend-only telemetry
|
||||
records those calls as successes), `resultChars` + `overCap` (result
|
||||
records those calls as successes), `errorMessage` (capped; the thrown
|
||||
message or the first lines of the `toolError` text, which name the error
|
||||
class — HTTP status / timeout / auth / not found; absent on success),
|
||||
`resultChars` + `overCap` (result
|
||||
exceeds the tool's `anthropic/maxResultSizeChars` hint → the host truncates
|
||||
it; a frequently-overCap tool needs tighter defaults or projections), a
|
||||
size-bounded args summary, and the caller scope (ga/delegated/tenant).
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Analyze Rule Evaluation Triggers (evaluateOn)
|
||||
description: "Opt-in interim evaluation of analyze rules before a session is terminal — enrollment_end stays the default, whiteglove_sealed and on_event:<type> triggers close the WhiteGlove notification gap and the never-terminal stuck-session gap with one mechanism, backed by update-semantics on RuleResults instead of the permanent dedupe freeze."
|
||||
resource: backend/rule-engine
|
||||
tags: [rules, analyze, triggers, notifications, whiteglove]
|
||||
timestamp: 2026-08-14
|
||||
---
|
||||
|
||||
# Status
|
||||
|
||||
**IMPLEMENTED 2026-08-14** (same day the spec was approved). The open product decisions below were confirmed as "no" for v1. Key implementation anchors: `AnalyzeRuleTriggers` (Shared — grammar + matching helpers, shared by engine, ingest registry and CRUD validation), `AnalyzeRunContext` / `AnalysisOutcome.ResolvedResults` (RuleEngine), `InterimTriggerRegistry` (5-min-TTL per-tenant cache for the ingest hot path, fail-soft), `AnalyzeOnEnrollmentEndHandler` interim branch, `EvaluateOnJson` + five RuleResult lifecycle columns in `TableStorageService.Rules`. ANALYZE-ID-004 ships as the first interim-enabled rule (`on_event:hybrid_login_pending`, repetition-gated at threshold 75).
|
||||
|
||||
# Problem
|
||||
|
||||
Analyze rules run at exactly three points today, all of them terminal or near-terminal:
|
||||
|
||||
1. `enrollment_complete` / `enrollment_failed` observed at ingest (`EventIngestProcessor.cs`, classification → analyze envelope).
|
||||
2. The maintenance sweep terminalizing a silent session (Failed / Incomplete + synthetic `session_timeout`, `MaintenanceService.cs`).
|
||||
3. The vulnerability-correlation rerun (`vulnerability_correlated` reason — incremental, no stats).
|
||||
|
||||
Two incident classes fall through this model:
|
||||
|
||||
- **WhiteGlove gap (sits-d, 2026-08-11):** `whiteglove_complete` seals a session to Pending but enqueues no analyze run. A rule that should alert at the end of pre-provisioning (technician still at the bench) never fires, and when Part 2 eventually terminalizes days later, the permanent per-(session, rule) dedupe means a finding that sneaked in earlier (e.g. via the vuln-rerun backdoor) is frozen and never notifies.
|
||||
- **Never-terminal stuck sessions (esa-logistics, 2026-08-13/14):** a session looping in Account Setup (user signs in daily, hybrid affinity never establishes) resets the 5h inactivity clock on every logon. It is never terminalized, therefore never analyzed — the exact sessions where a diagnosis would help most (ANALYZE-ID-004, ANALYZE-APP-015) produce no finding while the customer watches the device loop.
|
||||
|
||||
Both gaps share one root cause: *evaluation time is hard-wired to session end*. Gather rules already solved the analogous problem on-device with declarative triggers (`startup` / `phase_change` / `phase_exit` / `interval` / `on_event`); analyze rules need the server-side equivalent.
|
||||
|
||||
# Design Overview
|
||||
|
||||
Add an optional `evaluateOn` array to the analyze-rule schema. Absent field ⇒ `["enrollment_end"]` ⇒ **today's behavior, bit-for-bit, for every existing rule**.
|
||||
|
||||
```
|
||||
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"]
|
||||
```
|
||||
|
||||
Trigger grammar (v1):
|
||||
|
||||
| Trigger | Fires when | Notes |
|
||||
|---|---|---|
|
||||
| `enrollment_end` | terminal analyze run (complete / failed / sweep-terminalized) | default; also the **finalization pass** for interim results (below) |
|
||||
| `whiteglove_sealed` | ingest classifies the first genuine `whiteglove_complete` (session → Pending, `isPreProvisioned`) | duplicate seals do not re-trigger |
|
||||
| `on_event:<eventType>` | an ingest batch contains ≥1 event of `<eventType>` | `<eventType>` validated against the event-types catalog; phase boundaries are covered by `on_event:phase_transition` / `on_event:esp_phase_changed` — no separate phase grammar needed, since every run evaluates the full stream anyway |
|
||||
|
||||
Explicitly **not** in v1: interval/timer triggers (no cheap host for them; the sweep remains the only clock), and `on_event` filters on data fields (the rule's own conditions do that — the trigger only decides *when* to look).
|
||||
|
||||
# Pipeline Changes
|
||||
|
||||
## Ingest (producer side)
|
||||
|
||||
- `EventIngestProcessor` already classifies each stored batch. After classification, intersect the batch's event types with the tenant's **trigger registry** (the set of `on_event` types across the tenant's active rules — derivable from `AnalyzeRuleService`'s merged rule load, cached alongside it). Non-empty intersection ⇒ enqueue one envelope `Reason=interim_trigger`, carrying the matched trigger event types. Reuse the existing 30s visibility delay (terminal-flush ordering argument in `AzureQueueAnalyzeOnEnrollmentEndProducer` applies to interim batches too).
|
||||
- The `whiteglove_complete` seal path (`EventIngestProcessor.Classification.cs`) enqueues `Reason=whiteglove_sealed` — only on the first seal, only for `isPreProvisioned`.
|
||||
- Terminal enqueue (`CompletionEvent` / `FailureEvent`) is unchanged.
|
||||
|
||||
## Handler / engine (consumer side)
|
||||
|
||||
`AnalyzeOnEnrollmentEndHandler` branches by reason today (primary vs. `vulnerability_correlated`); interim reasons become a third branch. The engine call gets a run context instead of the bare `reanalyze` flag:
|
||||
|
||||
| Aspect | Terminal run (`enrollment_end`) | Interim run (`whiteglove_sealed`, `interim_trigger`) |
|
||||
|---|---|---|
|
||||
| Rule set | ALL active rules (interim-triggered ones get their finalization pass here) | only rules whose `evaluateOn` matches the reason / one of the batch's trigger types |
|
||||
| KO (`MarkSessionAsFailed`) | applies (unchanged) | **suppressed** — a Pending/InProgress session must never be flipped Failed by an interim pass (`TryMarkSessionFailedFromRuleAsync` is skipped; the terminal run re-fires the rule and applies KO then) |
|
||||
| Platform stat `IssuesDetected` | counted | skipped (like vuln rerun) |
|
||||
| Per-rule fire stats | counted | skipped (like vuln rerun) — keeps the terminal-only stats convention and the Rule Regression Radar baselines clean |
|
||||
| Channel notifications | new findings notify (unchanged) | new findings notify — **this is the point** of both gaps |
|
||||
| SignalR results-available | yes | yes (live update of an open session page) |
|
||||
|
||||
## Result lifecycle: update semantics replace the dedupe freeze
|
||||
|
||||
Today the engine skips any rule that already has a stored `RuleResult` row for the session (`RuleEngine.AnalyzeSessionAsync`, existing-results lookup) — findings are frozen at first write, which is precisely the WG-gap failure mode ("dedupe permanent").
|
||||
|
||||
New model — `RuleResult` gains four fields (`FirstDetectedAt`, `LastEvaluatedAt`, `IsInterim`, `ResolvedAt`, plus `NotifiedAt` for the notification marker):
|
||||
|
||||
- **Interim run:** rules in scope are (re-)evaluated even when a row exists. Fired ⇒ upsert (evidence/confidence refreshed, `FirstDetectedAt` preserved, `IsInterim=true`). Not fired + existing interim row ⇒ `ResolvedAt` set (kept for audit; excluded from issue counts and default UI).
|
||||
- **Terminal run:** every rule that has an interim row is re-evaluated regardless of dedupe (finalization). Fired ⇒ row finalized (`IsInterim=false`, evidence refreshed — fixes the stale-template-field freeze, e.g. the ESP-004 `{{appName}}` case). Not fired (typically a `not_exists` precondition on `enrollment_complete` now failing because the session healed) ⇒ `ResolvedAt` set. Rules without interim rows keep today's skip-if-exists dedupe.
|
||||
- **Notification dedupe** moves off "row exists" onto `NotifiedAt` on the row: notify once per (session, rule) at first fire — interim or terminal, whichever comes first; refreshes and finalization do not re-notify. The manual `reanalyze=true` path must **preserve** `NotifiedAt` across its rebuild (today it deletes rows; delete-and-recreate would silently re-arm notifications) and keeps its designed never-notify behavior.
|
||||
- Legacy rows (null new fields) read as final/terminal — no migration.
|
||||
|
||||
# Authoring Guardrails (validate_rule lints)
|
||||
|
||||
Interim evaluation changes what a rule's conditions mean mid-flight. Three new lints in the MCP `validate_rule` pre-flight (and the baked authoring guide):
|
||||
|
||||
1. **Terminal-precondition trap (warning):** `evaluateOn` contains an interim trigger AND a precondition is `not_exists` on `enrollment_complete` / `enrollment_failed` / `session_timeout`. Mid-run these pass trivially — the rule needs conditions that are *monotonic* (once true on the growing event stream, stays true) or repetition-gated. Worked example: ANALYZE-APP-015 must NOT get an interim trigger as-is — even successful sessions transiently show its "completion signal deferred" + `pending>=1` pattern (the terminal precondition is its only gate). ANALYZE-ID-004 is interim-safe by construction (threshold 65 requires repeated `hybrid_login_pending`).
|
||||
2. **Unknown trigger event type (error):** `on_event:<type>` not in the event-types catalog.
|
||||
3. **KO-on-interim (warning):** `markSessionAsFailedDefault: true` on a rule whose only triggers are interim — the KO would never apply until terminal; say so.
|
||||
|
||||
# Cost & Safety
|
||||
|
||||
- Interim runs are bounded by trigger frequency, and the chosen v1 triggers are rare by construction (`hybrid_login_pending` ≤1 per boot, `whiteglove_sealed` once per session). High-frequency telemetry event types (performance_snapshot, agent_metrics_snapshot, download_progress, network churn, log_entry, agent_trace, stall_probe_check) are **hard-blocked** as `on_event` triggers, not merely advised against: the CRUD write is rejected (`AnalyzeRuleTriggers.BlockedOnEventTypes`, code-only enforcement), the runtime matcher drops them from any row that slipped in (defense in depth — the trigger registry and the engine both go through `OnEventTypes`), and the MCP lint reports them as an error. The JSON mirror lives in `rules/guardrails.json` (`blockedInterimTriggerEventTypes`) with a parity test pinning it to the C# set — the same pattern the gather hard blocks use. If a legitimate rare-but-bursty type ever hurts, a per-(session, reason) throttle marker is the v2 lever.
|
||||
- Each run loads the full event stream once (existing engine behavior, no change); rule filtering makes interim passes strictly cheaper than terminal ones.
|
||||
- Queue semantics (retry via visibility timeout, poison after MaxDequeueCount, idempotent partial-success re-runs) are inherited unchanged; upserts keep idempotency.
|
||||
|
||||
# Surfaces
|
||||
|
||||
- **Web session page:** interim findings render live (existing SignalR results-available notification) with a "preliminary" badge on `IsInterim`; `ResolvedAt` rows hidden by default behind a toggle.
|
||||
- **MCP:** `get_session(includeAnalysis)` / `get_session_summary` expose `isInterim` / `resolvedAt`; `list_session_annotations`' fired-rule snapshot semantics unchanged (labels bind to whatever was fired at annotation time).
|
||||
- **Success-rate / platform stats:** terminal-only convention untouched (interim runs record no stats).
|
||||
|
||||
# Application to the Motivating Cases
|
||||
|
||||
- WG battery rule (sits-d): `evaluateOn: ["enrollment_end", "whiteglove_sealed"]` — technician gets the channel alert at the bench; Part-2 terminal pass finalizes or resolves the finding.
|
||||
- Hybrid-affinity stuck (esa): ANALYZE-ID-004 gets `evaluateOn: ["enrollment_end", "on_event:hybrid_login_pending"]` — the second overdue-login event (threshold 65) produces a live finding on the still-InProgress session. ANALYZE-APP-015 stays `enrollment_end`-only until its conditions are hardened (lint 1).
|
||||
|
||||
# Product Decisions (settled 2026-08-14)
|
||||
|
||||
1. Notify-on-resolve (a "finding healed" message)? **No** (v1) — resolution is visible in UI/MCP.
|
||||
2. Re-notify on severity/confidence escalation of an existing finding? **No** (v1) — one notification per finding.
|
||||
3. `whiteglove_sealed` runs allowing KO rules to flip Pending → Failed? **No** — the Pending state is the WhiteGlove contract; suppression is unconditional for interim.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs` — terminal-only enqueue (classification → analyze envelope).
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/Analyze/AnalyzeOnEnrollmentEndHandler.cs` — reason branching, notify path, stats side-effects.
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/Analyze/AzureQueueAnalyzeOnEnrollmentEndProducer.cs` — 30s visibility delay rationale.
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs` — existing-results dedupe, `TryMarkSessionFailedFromRuleAsync` KO path.
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs` — sweep terminalization + analyze enqueue; inactivity-clock reset explains the never-terminal gap.
|
||||
- `rules/gather-rule-phase-scoping.md` (this bundle) — the on-device trigger precedent this design mirrors.
|
||||
- `rules/rule-authoring-surface.md` (this bundle) — where the new lints land (validate_rule + baked guide).
|
||||
+16
-6
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Version Contract — Agent, Backend, MCP
|
||||
description: One version shape for all three shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
|
||||
title: Version Contract — Agent, Backend, MCP, Web
|
||||
description: One version shape for all shipping components, the ETag-CAS counter blob that mints build numbers, and the published manifests that make "what is deployed" checkable from outside.
|
||||
resource: /.github/scripts/Request-BuildNumber.ps1
|
||||
tags:
|
||||
- build
|
||||
@@ -13,9 +13,12 @@ timestamp: 2026-07-22T00:00:00+02:00
|
||||
|
||||
# Version Contract
|
||||
|
||||
Three components ship from this repository — the agent, the Functions backend and the
|
||||
MCP server. They share one version shape, one way of minting build numbers, and one
|
||||
manifest format, so that a version string means the same thing whoever emits it.
|
||||
Four components ship from this repository — the agent, the Functions backend, the
|
||||
MCP server and the web portal. They share one version shape, one way of minting build
|
||||
numbers, and one manifest format, so that a version string means the same thing
|
||||
whoever emits it. The web portal is the commit-only exception: a static export has no
|
||||
runtime that could report a `major.minor.build`, so it carries `commit` + `buildUtc`
|
||||
alone (no counter blob).
|
||||
|
||||
```
|
||||
version = <major>.<minor>.<build> major.minor curated by hand, build minted per build
|
||||
@@ -28,6 +31,7 @@ buildUtc = ISO-8601 build moment
|
||||
| Agent V2 | `VersionPrefix` in `AutopilotMonitor.Agent.V2.csproj` | `agent/buildcounter-v2.txt` | `agent/version.json` |
|
||||
| Backend | `VersionPrefix` in `AutopilotMonitor.Functions.csproj` | `versions/backend.txt` | `GET /api/health` |
|
||||
| MCP | `version` in `package.json` | `versions/mcp.txt` | `GET /health`, MCP handshake |
|
||||
| Web portal | — (commit-only) | — | `GET /version.json` on the portal host |
|
||||
|
||||
# Schema
|
||||
|
||||
@@ -77,7 +81,12 @@ exception: `build.ps1` produces uploadable artifacts and reserves a real number
|
||||
```
|
||||
|
||||
The MCP manifest adds `docsCommit`, because for that component "which build" and "which
|
||||
documentation" are separate questions.
|
||||
documentation" are separate questions. `versions/web.json` carries `component`, `commit`,
|
||||
`deployedUtc` and `runId` (no `version`/`buildUtc` — the served `/version.json` holds the
|
||||
build moment). The portal's own stamp is generated into `public/version.json` by
|
||||
`scripts/generate-version.js` (prebuild) from `GITHUB_SHA`, served with
|
||||
`Cache-Control: no-store`, and `deploy-web.yml` polls it until the deployed commit
|
||||
matches the pushed SHA before publishing the manifest.
|
||||
|
||||
Each is written **last and only after the deploy workflow has polled the component's
|
||||
health endpoint until it reports that exact version and commit**. A green deploy step
|
||||
@@ -104,5 +113,6 @@ remain, being the customer-facing integrity contract.
|
||||
|
||||
- `.github/scripts/Request-BuildNumber.ps1` — the CAS protocol
|
||||
- `.github/workflows/deploy-backend.yml`, `deploy-mcp.yml` — reserve, verify, publish
|
||||
- `.github/workflows/deploy-web.yml`, `src/Web/autopilot-monitor-web/scripts/generate-version.js` — web stamp, verify, publish
|
||||
- `.github/workflows/build-agent.yml`, `scripts/Deployment/build.ps1` — agent line
|
||||
- [Build Counter Blob](agent/build-counter-blob.md) — agent-specific consequences
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
// az deployment group create \
|
||||
// --resource-group <rg-name> \
|
||||
// --template-file infra/mcp-server.bicep \
|
||||
// --parameters apiUrl=https://autopilotmonitor-api.azurewebsites.net \
|
||||
// --parameters apiUrl=https://autopilotmonitor-api-eu.azurewebsites.net \
|
||||
// entraClientSecret=<secret-value>
|
||||
// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp.<env-suffix>.<region>.azurecontainerapps.io).
|
||||
//
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -34,5 +38,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "detection", "win32"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
"win32"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -36,5 +41,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "msi", "error-code"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"msi",
|
||||
"error-code"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -29,5 +33,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "dependency"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"dependency"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -36,8 +41,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "app_failed", "condition": "exists", "weight": 15 },
|
||||
{ "signal": "enrollment_failed", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "app_failed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "Performance monitoring shows disk free space dropped below 5 GB during enrollment. This can cause app installation failures, especially for large Win32 apps.\n\nIf app installations failed in the same session while free space was low, treat storage exhaustion as the likely contributing cause — the confidence score of this finding is raised accordingly. Verify the individual app failures for their concrete error codes before concluding storage was the only cause.",
|
||||
"remediation": [
|
||||
@@ -58,5 +71,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "disk-space", "critical"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"disk-space",
|
||||
"critical"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -29,5 +33,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "download", "timeout"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"download",
|
||||
"timeout"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -31,5 +35,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "reboot", "retry", "loop"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
"retry",
|
||||
"loop"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
"description": "Detects when the app tracking summary reports multiple failed app installations.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -16,10 +22,22 @@
|
||||
"operator": "gte",
|
||||
"value": "2",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"source": "event_type",
|
||||
"eventType": "enrollment_failed",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "enrollment_failed", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"explanation": "The app tracking summary reports 2 or more failed app installations during enrollment. This suggests a systemic issue rather than an isolated app failure.",
|
||||
"remediation": [
|
||||
@@ -32,5 +50,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "summary", "multiple-failures"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"summary",
|
||||
"multiple-failures"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -28,5 +32,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "performance", "slow-install", "warning"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"performance",
|
||||
"slow-install",
|
||||
"warning"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -34,7 +38,17 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{ "title": "Win32 app return codes in Intune", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes" }
|
||||
{
|
||||
"title": "Win32 app return codes in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes"
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "enforcement", "exit-code", "unmapped", "resolved"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"enforcement",
|
||||
"exit-code",
|
||||
"unmapped",
|
||||
"resolved"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 90,
|
||||
"preconditions": [
|
||||
{
|
||||
@@ -69,8 +73,20 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{ "title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes", "url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg" },
|
||||
{ "title": "Win32 app detection rules in Intune", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules" }
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg"
|
||||
},
|
||||
{
|
||||
"title": "Win32 app detection rules in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "detection", "esp", "0x87d1041c", "intune"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
"esp",
|
||||
"0x87d1041c",
|
||||
"intune"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"preconditions": [
|
||||
{
|
||||
@@ -35,6 +39,51 @@
|
||||
"operator": "regex",
|
||||
"value": "^0x80073[cd]",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_esp_category",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "espCategory",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_esp_errorcode",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "espErrorCode",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_top_candidate_package",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "topCandidatePackage",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_candidate_count",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "candidateCount",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_scan_confidence",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "confidence",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "The ESP **{{espCategory}}/Apps** subcategory failed with HRESULT **{{espErrorCode}}** — an error from the AppX/MSIX deployment stack, i.e. a **Microsoft Store / MSIX app**, not a Win32 app. These apps install via the EnterpriseModernAppManagement CSP and never appear in the IME Win32 sidecar logs, which is why all tracked Win32/WinGet apps in this session can look green while ESP still reports an Apps failure.\n\nThe agent scanned the AppXDeploymentServer event log during the failure settle window and identified **{{topCandidatePackageName}}** (package `{{topCandidatePackage}}`) as the most likely culprit — its deployment error matches the ESP failure code ({{candidateCount}} candidate(s) total, confidence: {{confidence}}).\n\n**This is a correlation-based assessment, not a confirmed root cause** — validate against the AppXDeploymentServer/Operational event log on the device or in the diagnostics archive before acting.",
|
||||
@@ -65,8 +114,21 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{ "title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes", "url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg" },
|
||||
{ "title": "Add Microsoft Store apps to Intune", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft" }
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot Windows Autopilot ESP error codes",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/troubleshoot-tsg"
|
||||
},
|
||||
{
|
||||
"title": "Add Microsoft Store apps to Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft"
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "appx", "msix", "store", "esp", "0x80073cf9"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"appx",
|
||||
"msix",
|
||||
"store",
|
||||
"esp",
|
||||
"0x80073cf9"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-015",
|
||||
"title": "Required App Never Settled — ESP Completion Deferred",
|
||||
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 55,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the pending app eventually settled and the enrollment completed — a transient deferral is normal."
|
||||
},
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_failed",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the session failed explicitly — the app/ESP failure rules already cover that path; this rule targets the never-finishing case."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "completion_deferred",
|
||||
"source": "event_type",
|
||||
"eventType": "agent_trace",
|
||||
"dataField": "message",
|
||||
"operator": "contains",
|
||||
"value": "completion signal deferred",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "apps_pending",
|
||||
"source": "event_data",
|
||||
"eventType": "app_tracking_summary",
|
||||
"dataField": "pending",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "pending_app_names",
|
||||
"source": "event_data",
|
||||
"eventType": "app_tracking_summary",
|
||||
"dataField": "pendingNames",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "pending_app_names", "condition": "exists", "weight": 5 }
|
||||
],
|
||||
"explanation": "The Intune Management Extension wrote its user-session completion line, but the agent observed `{{pending}}` required app(s) still pending (`{{pendingNames}}`) and therefore **deferred the enrollment completion signal** — repeatedly, if this happened across several logons or reboots.\n\nA pending app that never transitions to downloading/installing and never fails is almost always an **assignment or requirement problem**, not an install error:\n\n- requirement rules that are never met on this device (OS/architecture/registry/script checks),\n- an unsatisfied dependency or supersedence chain,\n- an app targeted as *required* for the user ESP that needs something unavailable during provisioning (e.g. a VPN client whose portal sign-in only happens after first logon),\n- user-targeted apps on a device where the user identity never fully registered (see the hybrid user-affinity rule if it also fired).",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Identify and unblock the pending app",
|
||||
"steps": [
|
||||
"Check the app names listed in this finding's evidence (pendingNames) — these are the apps the ESP is waiting for.",
|
||||
"In Intune, review the app's requirement rules and dependencies; test them on the device (IntuneManagementExtension.log logs each requirement evaluation).",
|
||||
"Decide whether the app really must block the ESP: removing it from the ESP's 'blocking apps' list (or making it not-required during enrollment) unblocks completion immediately.",
|
||||
"If the app is user-targeted on a hybrid-joined device, verify the user's Entra affinity first — without a signed-in Entra user, user-targeted policy never applies."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page — blocking apps",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/intune-service/enrollment/windows-enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["apps", "esp", "completion", "assignment", "pending"]
|
||||
}
|
||||
@@ -5,8 +5,13 @@
|
||||
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
@@ -30,8 +35,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "app_install_failed", "condition": "count >= 2", "weight": 15 },
|
||||
{ "signal": "enrollment_failed", "condition": "count >= 1", "weight": 10 }
|
||||
{
|
||||
"signal": "app_install_failed",
|
||||
"condition": "count >= 2",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "count >= 1",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 50,
|
||||
"explanation": "A proxy (explicit or PAC-based) is configured on this device and app content downloads failed (Delivery Optimization timeout or download content error). Proxies can block or throttle downloads from *.delivery.mp.microsoft.com and other Intune CDN endpoints.\n\nThis is a common root cause when app downloads fail consistently on proxy-connected corporate networks. Note this rule only fires when the failure is download-related — generic app install errors behind a proxy do not trigger it.",
|
||||
@@ -54,5 +67,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["correlation", "apps", "proxy", "network", "download", "root-cause"]
|
||||
"tags": [
|
||||
"correlation",
|
||||
"apps",
|
||||
"proxy",
|
||||
"network",
|
||||
"download",
|
||||
"root-cause"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 55,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -38,8 +42,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "app_failed", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "enrollment_failed", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "app_failed",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"explanation": "Memory usage exceeded 90% in at least 3 performance snapshots during this enrollment — sustained pressure, not a single transient spike. Persistent high memory usage can cause app installation failures, system slowdowns, and ESP timeouts, especially on devices with 8 GB RAM installing several apps concurrently.",
|
||||
"remediation": [
|
||||
@@ -53,5 +65,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["device", "memory", "performance"]
|
||||
"tags": [
|
||||
"device",
|
||||
"memory",
|
||||
"performance"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -52,5 +56,11 @@
|
||||
"url": "https://learn.microsoft.com/en-us/lifecycle/products/windows-11-home-and-pro"
|
||||
}
|
||||
],
|
||||
"tags": ["device", "os-version", "end-of-support", "windows-11", "windows-10"]
|
||||
"tags": [
|
||||
"device",
|
||||
"os-version",
|
||||
"end-of-support",
|
||||
"windows-11",
|
||||
"windows-10"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:windows_update_failed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"confidenceThreshold": 60,
|
||||
@@ -65,8 +70,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "enrollment_failed", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "reboot_pending", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "reboot_pending",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "A Windows Update failed to install during enrollment: **{{updateTitle}}** (error {{hresult}} {{hresultSymbol}}). Windows can install quality/cumulative updates during OOBE (the \"Getting updates\" screen and the ESP \"Install Windows quality updates during OOBE\" option). When one of these fails mid-enrollment it can leave the device in an inconsistent state, force an unexpected reboot, or block the Enrollment Status Page — a failure mode that is otherwise only discoverable by deep manual digging on the device.",
|
||||
"remediation": [
|
||||
@@ -87,5 +100,10 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["device", "windows-update", "oobe", "servicing"]
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"servicing"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 50,
|
||||
@@ -39,7 +43,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "reboot_pending", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "reboot_pending",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"explanation": "One or more Windows updates installed during enrollment (e.g. **{{updateTitle}}**). This is normal when Windows Update runs during OOBE, but it extends enrollment time and can trigger a reboot mid-ESP. Track it when correlating enrollment duration or reboot count with a specific cumulative update, or when a device behaves differently after a particular KB landed during provisioning.",
|
||||
"remediation": [
|
||||
@@ -53,5 +61,9 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["device", "windows-update", "oobe"]
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"confidenceThreshold": 50,
|
||||
@@ -47,7 +51,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "wu_channel_blind", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "wu_channel_blind",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "The OS build changed from **{{previousBuild}}** to **{{currentBuild}}** across a reboot during enrollment — a Windows update was installed mid-provisioning. This is normal when quality updates run during OOBE, but it adds a reboot and extends enrollment time. If a windows_update_channel_census event is also present, the update installed via a servicing path the Windows Update watcher does not capture yet — its EventID histogram tells which IDs to enable.",
|
||||
"remediation": [
|
||||
@@ -61,5 +69,10 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["device", "windows-update", "oobe", "reboot"]
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"reboot"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts. Knowledge source: docs/agent/autopilot-ztd-diagnostics.md.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 55,
|
||||
"confidenceThreshold": 50,
|
||||
@@ -55,5 +59,12 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["device", "ntp", "clock-skew", "tpm", "esp", "known-issue"]
|
||||
"tags": [
|
||||
"device",
|
||||
"ntp",
|
||||
"clock-skew",
|
||||
"tpm",
|
||||
"esp",
|
||||
"known-issue"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 80,
|
||||
"preconditions": [
|
||||
@@ -30,9 +30,35 @@
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "timeoutHours",
|
||||
"source": "event_data",
|
||||
"eventType": "session_timeout",
|
||||
"dataField": "timeoutHours",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "desktop_was_reached",
|
||||
"source": "event_type",
|
||||
"eventType": "desktop_arrived",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "all_tracked_apps_completed",
|
||||
"source": "event_data",
|
||||
"eventType": "app_tracking_summary",
|
||||
"dataField": "isAllCompleted",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.",
|
||||
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the silent session",
|
||||
@@ -43,6 +69,13 @@
|
||||
"If the device is reachable, collect the agent + IME logs and re-run enrollment, correlating with the timeout timestamp",
|
||||
"If many sessions time out in the same window, check for a tenant-wide network/connectivity issue or an Intune service incident"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Desktop reached and apps done, but no completion",
|
||||
"steps": [
|
||||
"Sign in as the affected user and run dsregcmd /status — 'SSO State: AzureAdPrt' must be YES; NO means the user never registered with Entra and the Account Setup ESP will loop",
|
||||
"Verify Entra Connect syncs both the computer object (userCertificate) and the signing-in user, and that the user's UPN suffix is a verified Entra domain"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["enrollment", "timeout", "stalled"]
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:modern_deployment_error"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 55,
|
||||
@@ -75,5 +80,12 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["enrollment", "tpm", "attestation", "self-deploying", "pre-provisioning", "known-issue"]
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"tpm",
|
||||
"attestation",
|
||||
"self-deploying",
|
||||
"pre-provisioning",
|
||||
"known-issue"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 55,
|
||||
@@ -74,5 +78,11 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["enrollment", "mdm", "known-issue", "entra-id", "hybrid-join"]
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"mdm",
|
||||
"known-issue",
|
||||
"entra-id",
|
||||
"hybrid-join"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276). Knowledge source: docs/agent/autopilot-ztd-diagnostics.md (error-code map).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 25,
|
||||
"confidenceThreshold": 60,
|
||||
@@ -59,10 +63,26 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "e_fail_esp_failure", "condition": "exists", "weight": 40 },
|
||||
{ "signal": "e_fail_esp_advisory", "condition": "exists", "weight": 35 },
|
||||
{ "signal": "e_fail_modern_deployment", "condition": "exists", "weight": 40 },
|
||||
{ "signal": "e_fail_error_detected", "condition": "exists", "weight": 35 }
|
||||
{
|
||||
"signal": "e_fail_esp_failure",
|
||||
"condition": "exists",
|
||||
"weight": 40
|
||||
},
|
||||
{
|
||||
"signal": "e_fail_esp_advisory",
|
||||
"condition": "exists",
|
||||
"weight": 35
|
||||
},
|
||||
{
|
||||
"signal": "e_fail_modern_deployment",
|
||||
"condition": "exists",
|
||||
"weight": 40
|
||||
},
|
||||
{
|
||||
"signal": "e_fail_error_detected",
|
||||
"condition": "exists",
|
||||
"weight": 35
|
||||
}
|
||||
],
|
||||
"explanation": "This is a **Microsoft Entra hybrid join** deployment that hit error **0x80004005** — a documented, build-dependent Autopilot known issue where hybrid deployments time out.\n\nThe issue is fixed by Windows cumulative updates:\n\n- Windows 11 **25H2**: KB5065789 — fixed from build **26200.6725**\n- Windows 11 **24H2**: KB5065426 — fixed from build **26100.6584**\n- Windows 11 **23H2**: KB5070312 — fixed from build **22631.6276**\n\nCompare this session's OS build (see the Session Info card / os_info event) against the fixed-build threshold for its version. If the device booted with an older build, updating the deployment image (or installing quality updates during OOBE) resolves the timeout. Note that 0x80004005 is a generic failure code (E_FAIL) — if the build is already at or above the fixed threshold, investigate the Intune Connector for Active Directory and domain-controller connectivity instead.",
|
||||
"remediation": [
|
||||
@@ -83,5 +103,11 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["enrollment", "hybrid-join", "known-issue", "windows-update", "esp"]
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"hybrid-join",
|
||||
"known-issue",
|
||||
"windows-update",
|
||||
"esp"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 50,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -20,8 +24,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "long_esp", "condition": "phase_duration > 1800", "weight": 40 },
|
||||
{ "signal": "very_long_esp", "condition": "phase_duration > 3600", "weight": 15 }
|
||||
{
|
||||
"signal": "long_esp",
|
||||
"condition": "phase_duration > 1800",
|
||||
"weight": 40
|
||||
},
|
||||
{
|
||||
"signal": "very_long_esp",
|
||||
"condition": "phase_duration > 3600",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "The ESP DeviceSetup phase took more than 30 minutes. This indicates a blocking app is stuck installing, downloading very slowly, or has entered a retry loop. A duration beyond 60 minutes raises the confidence further — at that point the enrollment is almost certainly held by a single stuck blocking item rather than ordinary slowness.",
|
||||
@@ -35,5 +47,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["esp", "blocking", "timeout", "duration"]
|
||||
"tags": [
|
||||
"esp",
|
||||
"blocking",
|
||||
"timeout",
|
||||
"duration"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -54,5 +58,10 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["esp", "certificates", "subcategory", "silent-failure"]
|
||||
"tags": [
|
||||
"esp",
|
||||
"certificates",
|
||||
"subcategory",
|
||||
"silent-failure"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
@@ -47,8 +51,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "multiple_reboot_uris", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "omadm_reboot_flag_set", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "multiple_reboot_uris",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "omadm_reboot_flag_set",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "This session **rebooted during enrollment**, and Windows flagged one or more device-assigned MDM policies as **reboot-required** — the classic cause of the coalesced mid-ESP restart and the unexpected **second sign-in screen** before AccountSetup.\n\nFirst reboot-forcing policy URI: `{{firstRebootUri}}`\n\nThe complete URI list is in the `mdm_policy_reboot_required` timeline event (`rebootUris`). Policies applied DURING ESP DeviceSetup are coalesced into one forced restart at the end of DeviceSetup; the same flags raised later (e.g. user-policy sync in AccountSetup) have no immediate effect — this advisory only fires when a reboot was actually observed.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
|
||||
@@ -81,5 +93,12 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["esp", "reboot", "coalescing", "second-sign-in", "policy-attribution", "user-experience"]
|
||||
"tags": [
|
||||
"esp",
|
||||
"reboot",
|
||||
"coalescing",
|
||||
"second-sign-in",
|
||||
"policy-attribution",
|
||||
"user-experience"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
|
||||
"severity": "info",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 85,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -71,8 +75,16 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "retry", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "likelyCulpritApps", "condition": "exists", "weight": 5 }
|
||||
{
|
||||
"signal": "retry",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "likelyCulpritApps",
|
||||
"condition": "exists",
|
||||
"weight": 5
|
||||
}
|
||||
],
|
||||
"explanation": "The Enrollment Status Page reported a **failure** in the `{{category}}` phase (`{{failedSubcategory}}` subcategory, most likely app(s): `{{likelyCulpritApps}}`) — and then **recovered**: the failed step left its failed state and completed successfully about `{{minutesSinceFailure}}` min later.\n\nThat pattern is consistent with the user pressing **'Try again'** on the ESP failure page (the profile allows failure-page actions; the agent cannot observe the click itself). The failure the user saw was real, but it did not stick — the ESP re-ran the step, it succeeded, and the enrollment continued.\n\nRead the session timeline as one arc: `esp_failure_advisory` (the failure, with the likely culprit app) → `esp_failure_retry_detected` (the retry) → `esp_failure_recovered` (the recovery). If this pattern repeats across devices for the same app, that app's install is flaky on first attempt — fix the app rather than the devices. Any literal `{{…}}` placeholder means that detail was not recorded for this session.",
|
||||
"remediation": [
|
||||
@@ -86,5 +98,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["esp", "recovery", "try-again", "retry", "continue-anyway", "enrollment"]
|
||||
"tags": [
|
||||
"esp",
|
||||
"recovery",
|
||||
"try-again",
|
||||
"retry",
|
||||
"continue-anyway",
|
||||
"enrollment"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
"category": "identity",
|
||||
"isCommunity": true,
|
||||
"enabled": false,
|
||||
"version": "1.2.0",
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"version": "1.3.0",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -50,7 +54,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["identity", "certificates", "machine-store", "subject-check"],
|
||||
"tags": [
|
||||
"identity",
|
||||
"certificates",
|
||||
"machine-store",
|
||||
"subject-check"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "cert_subject",
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
@@ -81,5 +85,10 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [],
|
||||
"tags": ["identity", "security", "local-admin", "bypass-detection"]
|
||||
"tags": [
|
||||
"identity",
|
||||
"security",
|
||||
"local-admin",
|
||||
"bypass-detection"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
@@ -31,7 +35,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "kev_matches", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "kev_matches",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 80,
|
||||
"explanation": "The device has installed software with critical vulnerabilities. Software with CVEs rated CRITICAL (CVSS >= 9.0) or listed in the CISA Known Exploited Vulnerabilities catalog was detected. These vulnerabilities are actively targeted by attackers and should be remediated immediately.",
|
||||
@@ -64,5 +72,11 @@
|
||||
"url": "https://nvd.nist.gov/"
|
||||
}
|
||||
],
|
||||
"tags": ["vulnerability", "cve", "kev", "critical", "security"]
|
||||
"tags": [
|
||||
"vulnerability",
|
||||
"cve",
|
||||
"kev",
|
||||
"critical",
|
||||
"security"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ID-004",
|
||||
"title": "Hybrid Sign-In Never Establishes Entra User Affinity",
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"],
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 75,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the session eventually completed — a single overdue-login warning followed by a successful sign-in is normal hybrid latency, not a broken affinity."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "hybrid_login_overdue",
|
||||
"source": "event_type",
|
||||
"eventType": "hybrid_login_pending",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "user_desktop_reached",
|
||||
"source": "event_type",
|
||||
"eventType": "desktop_arrived",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "placeholder_still_active",
|
||||
"source": "event_type",
|
||||
"eventType": "aad_placeholder_user_detected",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "hybrid_login_pending", "condition": "count >= 2", "weight": 20 },
|
||||
{ "signal": "hybrid_login_pending", "condition": "count >= 3", "weight": 10 },
|
||||
{ "signal": "aad_placeholder_user_detected", "condition": "count >= 3", "weight": 10 }
|
||||
],
|
||||
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Verify user affinity on the device",
|
||||
"steps": [
|
||||
"Sign in as the affected user and run: dsregcmd /status — under 'SSO State' the value AzureAdPrt must be YES. NO means the user has no Entra token and this diagnosis is confirmed.",
|
||||
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join.",
|
||||
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362).",
|
||||
"Enable the gather rules 'Collect dsregcmd join status' so future sessions capture this state automatically."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix the identity pipeline",
|
||||
"steps": [
|
||||
"Entra Connect: confirm BOTH the computer OU and the user OU are in the sync scope, and the computer object's userCertificate attribute is being exported.",
|
||||
"Verify the user's UPN suffix is a verified domain in Entra ID (no .local / mismatched suffix).",
|
||||
"Ensure domain-controller line of sight at logon from the provisioning network/VLAN (hybrid PRT requires a reachable DC).",
|
||||
"Check proxy/firewall rules for login.microsoftonline.com, device.login.microsoftonline.com and enterpriseregistration.windows.net from the user context."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — late device registration",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-late-device-registration"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot hybrid-joined devices",
|
||||
"url": "https://learn.microsoft.com/en-us/entra/identity/devices/troubleshoot-hybrid-join-windows-current"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["identity", "hybrid-join", "entra-id", "user-affinity", "esp"]
|
||||
}
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) background install did not reach completion. The V2 OfficeInstallDetector watches the C2R client directly (registry + process) and emits office_install_failed when the install scenario was abandoned (worker gone and streaming never finished) or a C2R failure code was observed. This catches Office failures that the Intune integrated app type hides, because IME reports the app done within 1-2 minutes while C2R keeps installing in the background.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -28,7 +32,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "error_code_present", "condition": "exists", "weight": 15 }
|
||||
{
|
||||
"signal": "error_code_present",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 40,
|
||||
"explanation": "The Microsoft 365 Apps (Office Click-to-Run) install did not finish.\n\nThe OfficeInstallDetector watches the C2R client directly and reports the real background install lifecycle. The Intune \"integrated\" Microsoft 365 Apps app type only pushes the configuration XML and starts the C2R bootstrapper, so IME marks the app done within 1-2 minutes while C2R keeps streaming and laying down the product for several more minutes. This event means C2R activity ended (the OfficeC2RClient worker exited and the install scenario cleared) without the StreamingFinished flag being set, or a C2R failure code was observed.\n\nReview the office_install_failed event payload: `products`, `channel`, `phase`, `streamingFinished`, `errorCode` (best-effort — may be empty) and `durationSeconds`.",
|
||||
@@ -44,5 +52,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["apps", "office", "microsoft-365-apps", "click-to-run", "install-failure"]
|
||||
"tags": [
|
||||
"apps",
|
||||
"office",
|
||||
"microsoft-365-apps",
|
||||
"click-to-run",
|
||||
"install-failure"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
{
|
||||
@@ -83,10 +87,26 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "ca2023_notfound", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "ca2023_error", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "db_ca2023_firmware_missing", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "kek_2023_missing", "condition": "exists", "weight": 10 }
|
||||
{
|
||||
"signal": "ca2023_notfound",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "ca2023_error",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "db_ca2023_firmware_missing",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "kek_2023_missing",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "This device has Secure Boot enabled but the **Windows UEFI CA 2023** certificate has not been deployed to the Secure Boot DB.\n\nThe original Windows UEFI CA 2011 certificates **expired on June 27, 2026 — this deadline has passed**. The automatic pre-expiry rollout window is over: this device no longer receives Secure Boot security updates (DB/DBX) and cannot trust 2023-signed boot managers until the 2023 certificate is deployed. Deploying it now requires active remediation (Windows servicing opt-in and, on some models, an OEM firmware update) rather than waiting for an automatic update.\n\nOn devices running a current agent this finding is verified against the firmware itself: the agent reads the UEFI `db` signature database directly, and this warning is automatically suppressed when the firmware already contains the 2023 certificate — regardless of the Windows servicing status below.\n\nCurrent UEFI CA 2023 servicing status: `{{uefiCA2023Status}}`",
|
||||
"remediation": [
|
||||
@@ -112,5 +132,11 @@
|
||||
"url": "https://support.microsoft.com/en-us/topic/kb5036210"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "secureboot", "certificate", "ca2023", "compliance"]
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
"certificate",
|
||||
"ca2023",
|
||||
"compliance"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
|
||||
"severity": "high",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
@@ -45,5 +49,11 @@
|
||||
"url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "autologon", "winlogon", "credentials", "plaintext-password"]
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
"winlogon",
|
||||
"credentials",
|
||||
"plaintext-password"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -55,7 +59,13 @@
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "autologon", "winlogon", "kiosk", "allow-list"],
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
"winlogon",
|
||||
"kiosk",
|
||||
"allow-list"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "allowed_autologon_users",
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -34,5 +38,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["security", "provisioning-package", "ppkg", "allow-list", "tamper"]
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
"ppkg",
|
||||
"allow-list",
|
||||
"tamper"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -41,7 +45,13 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["security", "provisioning-package", "ppkg", "allow-list", "template"],
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
"ppkg",
|
||||
"allow-list",
|
||||
"template"
|
||||
],
|
||||
"templateVariables": [
|
||||
{
|
||||
"name": "allowed_packages",
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
"description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -32,5 +36,11 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": ["security", "provisioning-package", "ppkg", "truncated", "coverage"]
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
"ppkg",
|
||||
"truncated",
|
||||
"coverage"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
@@ -41,7 +45,11 @@
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "physical_device", "condition": "exists", "weight": 20 }
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
|
||||
@@ -74,5 +82,11 @@
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "secureboot", "firmware", "uefi", "compliance"]
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
"firmware",
|
||||
"uefi",
|
||||
"compliance"
|
||||
]
|
||||
}
|
||||
|
||||
+474
-37
@@ -7,8 +7,12 @@
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -48,8 +52,13 @@
|
||||
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -92,6 +101,10 @@
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -118,7 +131,8 @@
|
||||
"tags": [
|
||||
"apps",
|
||||
"dependency"
|
||||
]
|
||||
],
|
||||
"version": "1.1.0"
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-004",
|
||||
@@ -126,8 +140,13 @@
|
||||
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -200,6 +219,10 @@
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -227,7 +250,8 @@
|
||||
"apps",
|
||||
"download",
|
||||
"timeout"
|
||||
]
|
||||
],
|
||||
"version": "1.1.0"
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-006",
|
||||
@@ -235,8 +259,12 @@
|
||||
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -274,7 +302,13 @@
|
||||
"description": "Detects when the app tracking summary reports multiple failed app installations.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -285,6 +319,14 @@
|
||||
"operator": "gte",
|
||||
"value": "2",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_failed",
|
||||
"source": "event_type",
|
||||
"eventType": "enrollment_failed",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -318,6 +360,10 @@
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -345,7 +391,8 @@
|
||||
"performance",
|
||||
"slow-install",
|
||||
"warning"
|
||||
]
|
||||
],
|
||||
"version": "1.1.0"
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-010",
|
||||
@@ -425,6 +472,10 @@
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -464,7 +515,8 @@
|
||||
"exit-code",
|
||||
"unmapped",
|
||||
"resolved"
|
||||
]
|
||||
],
|
||||
"version": "1.1.0"
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-013",
|
||||
@@ -472,8 +524,12 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 90,
|
||||
"preconditions": [
|
||||
{
|
||||
@@ -559,8 +615,12 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"preconditions": [
|
||||
{
|
||||
@@ -589,6 +649,51 @@
|
||||
"operator": "regex",
|
||||
"value": "^0x80073[cd]",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_esp_category",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "espCategory",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_esp_errorcode",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "espErrorCode",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_top_candidate_package",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "topCandidatePackage",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_candidate_count",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "candidateCount",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appx_scan_confidence",
|
||||
"source": "event_data",
|
||||
"eventType": "esp_appx_failure_analysis",
|
||||
"dataField": "confidence",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "The ESP **{{espCategory}}/Apps** subcategory failed with HRESULT **{{espErrorCode}}** — an error from the AppX/MSIX deployment stack, i.e. a **Microsoft Store / MSIX app**, not a Win32 app. These apps install via the EnterpriseModernAppManagement CSP and never appear in the IME Win32 sidecar logs, which is why all tracked Win32/WinGet apps in this session can look green while ESP still reports an Apps failure.\n\nThe agent scanned the AppXDeploymentServer event log during the failure settle window and identified **{{topCandidatePackageName}}** (package `{{topCandidatePackage}}`) as the most likely culprit — its deployment error matches the ESP failure code ({{candidateCount}} candidate(s) total, confidence: {{confidence}}).\n\n**This is a correlation-based assessment, not a confirmed root cause** — validate against the AppXDeploymentServer/Operational event log on the device or in the diagnostics archive before acting.",
|
||||
@@ -637,14 +742,108 @@
|
||||
"0x80073cf9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-015",
|
||||
"title": "Required App Never Settled — ESP Completion Deferred",
|
||||
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 55,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the pending app eventually settled and the enrollment completed — a transient deferral is normal."
|
||||
},
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_failed",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the session failed explicitly — the app/ESP failure rules already cover that path; this rule targets the never-finishing case."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "completion_deferred",
|
||||
"source": "event_type",
|
||||
"eventType": "agent_trace",
|
||||
"dataField": "message",
|
||||
"operator": "contains",
|
||||
"value": "completion signal deferred",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "apps_pending",
|
||||
"source": "event_data",
|
||||
"eventType": "app_tracking_summary",
|
||||
"dataField": "pending",
|
||||
"operator": "gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "pending_app_names",
|
||||
"source": "event_data",
|
||||
"eventType": "app_tracking_summary",
|
||||
"dataField": "pendingNames",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "pending_app_names",
|
||||
"condition": "exists",
|
||||
"weight": 5
|
||||
}
|
||||
],
|
||||
"explanation": "The Intune Management Extension wrote its user-session completion line, but the agent observed `{{pending}}` required app(s) still pending (`{{pendingNames}}`) and therefore **deferred the enrollment completion signal** — repeatedly, if this happened across several logons or reboots.\n\nA pending app that never transitions to downloading/installing and never fails is almost always an **assignment or requirement problem**, not an install error:\n\n- requirement rules that are never met on this device (OS/architecture/registry/script checks),\n- an unsatisfied dependency or supersedence chain,\n- an app targeted as *required* for the user ESP that needs something unavailable during provisioning (e.g. a VPN client whose portal sign-in only happens after first logon),\n- user-targeted apps on a device where the user identity never fully registered (see the hybrid user-affinity rule if it also fired).",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Identify and unblock the pending app",
|
||||
"steps": [
|
||||
"Check the app names listed in this finding's evidence (pendingNames) — these are the apps the ESP is waiting for.",
|
||||
"In Intune, review the app's requirement rules and dependencies; test them on the device (IntuneManagementExtension.log logs each requirement evaluation).",
|
||||
"Decide whether the app really must block the ESP: removing it from the ESP's 'blocking apps' list (or making it not-required during enrollment) unblocks completion immediately.",
|
||||
"If the app is user-targeted on a hybrid-joined device, verify the user's Entra affinity first — without a signed-in Entra user, user-targeted policy never applies."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page — blocking apps",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/intune-service/enrollment/windows-enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"apps",
|
||||
"esp",
|
||||
"completion",
|
||||
"assignment",
|
||||
"pending"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-CORR-003",
|
||||
"title": "Proxy Configuration Causing Download Failure",
|
||||
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:app_install_failed"
|
||||
],
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
@@ -752,8 +951,12 @@
|
||||
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 55,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -820,8 +1023,12 @@
|
||||
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -881,8 +1088,13 @@
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:windows_update_failed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"confidenceThreshold": 60,
|
||||
@@ -984,8 +1196,12 @@
|
||||
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 50,
|
||||
@@ -1048,8 +1264,12 @@
|
||||
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"confidenceThreshold": 50,
|
||||
@@ -1121,9 +1341,13 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts. Knowledge source: docs/agent/autopilot-ztd-diagnostics.md.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 55,
|
||||
"confidenceThreshold": 50,
|
||||
@@ -1268,7 +1492,7 @@
|
||||
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 80,
|
||||
"preconditions": [
|
||||
@@ -1293,9 +1517,35 @@
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "timeoutHours",
|
||||
"source": "event_data",
|
||||
"eventType": "session_timeout",
|
||||
"dataField": "timeoutHours",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "desktop_was_reached",
|
||||
"source": "event_type",
|
||||
"eventType": "desktop_arrived",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "all_tracked_apps_completed",
|
||||
"source": "event_data",
|
||||
"eventType": "app_tracking_summary",
|
||||
"dataField": "isAllCompleted",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.",
|
||||
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the silent session",
|
||||
@@ -1306,6 +1556,13 @@
|
||||
"If the device is reachable, collect the agent + IME logs and re-run enrollment, correlating with the timeout timestamp",
|
||||
"If many sessions time out in the same window, check for a tenant-wide network/connectivity issue or an Intune service incident"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Desktop reached and apps done, but no completion",
|
||||
"steps": [
|
||||
"Sign in as the affected user and run dsregcmd /status — 'SSO State: AzureAdPrt' must be YES; NO means the user never registered with Entra and the Account Setup ESP will loop",
|
||||
"Verify Entra Connect syncs both the computer object (userCertificate) and the signing-in user, and that the user's UPN suffix is a verified Entra domain"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
@@ -1320,9 +1577,14 @@
|
||||
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:modern_deployment_error"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 55,
|
||||
@@ -1405,9 +1667,13 @@
|
||||
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios. See docs/agent/autopilot-ztd-diagnostics.md (Observability section).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 55,
|
||||
@@ -1488,9 +1754,13 @@
|
||||
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276). Knowledge source: docs/agent/autopilot-ztd-diagnostics.md (error-code map).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 25,
|
||||
"confidenceThreshold": 60,
|
||||
@@ -1596,8 +1866,12 @@
|
||||
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 50,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -1647,9 +1921,13 @@
|
||||
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -1791,9 +2069,13 @@
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
@@ -1890,8 +2172,12 @@
|
||||
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
|
||||
"severity": "info",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 85,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -1996,7 +2282,11 @@
|
||||
"category": "identity",
|
||||
"isCommunity": true,
|
||||
"enabled": false,
|
||||
"version": "1.2.0",
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"version": "1.3.0",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -2062,9 +2352,13 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
@@ -2151,9 +2445,13 @@
|
||||
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
@@ -2222,14 +2520,125 @@
|
||||
"security"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-004",
|
||||
"title": "Hybrid Sign-In Never Establishes Entra User Affinity",
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Autopilot provisioning placeholder stayed active in JoinInfo and the agent repeatedly reported the hybrid sign-in as overdue. The user session never establishes Entra user affinity (no PRT), so the Account Setup ESP re-runs on every logon and the enrollment never completes.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"on_event:hybrid_login_pending"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 75,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the session eventually completed — a single overdue-login warning followed by a successful sign-in is normal hybrid latency, not a broken affinity."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "hybrid_login_overdue",
|
||||
"source": "event_type",
|
||||
"eventType": "hybrid_login_pending",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "user_desktop_reached",
|
||||
"source": "event_type",
|
||||
"eventType": "desktop_arrived",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "placeholder_still_active",
|
||||
"source": "event_type",
|
||||
"eventType": "aad_placeholder_user_detected",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "hybrid_login_pending",
|
||||
"condition": "count >= 2",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "hybrid_login_pending",
|
||||
"condition": "count >= 3",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "aad_placeholder_user_detected",
|
||||
"condition": "count >= 3",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "A real user signed in and reached the desktop, but the device kept reporting the **Autopilot provisioning placeholder** as the active identity in JoinInfo and the hybrid sign-in as **overdue** — across multiple reboots. This means the signed-in user never established **Microsoft Entra user affinity** (no Primary Refresh Token), so:\n\n- the Account Setup ESP page re-appears on every logon,\n- user-targeted required apps and policies cannot settle,\n- the enrollment never reaches completion.\n\nAfter this many hours this is not normal Entra Connect sync latency — something is structurally preventing the user (or the computer object) from registering:\n\n- the computer object's `userCertificate` attribute is not syncing to Entra ID (sync scope / OU filtering),\n- the signing-in user is not synced, or their UPN does not match a verified Entra domain,\n- the device has no line of sight to a domain controller **at logon time**, so the hybrid PRT flow cannot finish,\n- time skew or a proxy blocking `login.microsoftonline.com` / `enterpriseregistration.windows.net` from the user context.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Verify user affinity on the device",
|
||||
"steps": [
|
||||
"Sign in as the affected user and run: dsregcmd /status — under 'SSO State' the value AzureAdPrt must be YES. NO means the user has no Entra token and this diagnosis is confirmed.",
|
||||
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join.",
|
||||
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362).",
|
||||
"Enable the gather rules 'Collect dsregcmd join status' so future sessions capture this state automatically."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix the identity pipeline",
|
||||
"steps": [
|
||||
"Entra Connect: confirm BOTH the computer OU and the user OU are in the sync scope, and the computer object's userCertificate attribute is being exported.",
|
||||
"Verify the user's UPN suffix is a verified domain in Entra ID (no .local / mismatched suffix).",
|
||||
"Ensure domain-controller line of sight at logon from the provisioning network/VLAN (hybrid PRT requires a reachable DC).",
|
||||
"Check proxy/firewall rules for login.microsoftonline.com, device.login.microsoftonline.com and enterpriseregistration.windows.net from the user context."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Hybrid join — late device registration",
|
||||
"url": "https://docs.autopilotmonitor.com/troubleshooting/hybrid-join-late-device-registration"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Troubleshoot hybrid-joined devices",
|
||||
"url": "https://learn.microsoft.com/en-us/entra/identity/devices/troubleshoot-hybrid-join-windows-current"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"identity",
|
||||
"hybrid-join",
|
||||
"entra-id",
|
||||
"user-affinity",
|
||||
"esp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-OFFICE-001",
|
||||
"title": "Microsoft 365 Apps Install Failed",
|
||||
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) background install did not reach completion. The V2 OfficeInstallDetector watches the C2R client directly (registry + process) and emits office_install_failed when the install scenario was abandoned (worker gone and streaming never finished) or a C2R failure code was observed. This catches Office failures that the Intune integrated app type hides, because IME reports the app done within 1-2 minutes while C2R keeps installing in the background.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -2285,8 +2694,12 @@
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
{
|
||||
@@ -2422,8 +2835,12 @@
|
||||
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
|
||||
"severity": "high",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
@@ -2476,8 +2893,12 @@
|
||||
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -2550,8 +2971,12 @@
|
||||
"description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -2593,8 +3018,12 @@
|
||||
"description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -2653,8 +3082,12 @@
|
||||
"description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -2694,9 +3127,13 @@
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
|
||||
+21
@@ -128,6 +128,27 @@
|
||||
"oobe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-DEVICE-006",
|
||||
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": false,
|
||||
"collectorType": "command_allowlisted",
|
||||
"target": "dsregcmd /status",
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "hybrid_login_pending",
|
||||
"outputEventType": "gather_dsregcmd_status",
|
||||
"outputSeverity": "Info",
|
||||
"tags": [
|
||||
"device",
|
||||
"entra",
|
||||
"join",
|
||||
"hybrid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-ENROLL-001",
|
||||
"title": "MDM enrollment diagnostic events",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "../schema/gather-rule.schema.json",
|
||||
"ruleId": "GATHER-DEVICE-006",
|
||||
"title": "Collect dsregcmd join status on overdue hybrid sign-in",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports a hybrid sign-in as overdue (hybrid_login_pending), capturing the Entra join and registration state at exactly the point where the problem is detected. Complements GATHER-DEVICE-001 (snapshot on entering Account Setup). Note: the agent runs as SYSTEM, so the output shows device join state — the per-user SSO/PRT state still requires running dsregcmd as the signed-in user.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": false,
|
||||
"collectorType": "command_allowlisted",
|
||||
"target": "dsregcmd /status",
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "hybrid_login_pending",
|
||||
"outputEventType": "gather_dsregcmd_status",
|
||||
"outputSeverity": "Info",
|
||||
"tags": ["device", "entra", "join", "hybrid"]
|
||||
}
|
||||
+11
-1
@@ -272,5 +272,15 @@
|
||||
"Format-Volume",
|
||||
"Set-ExecutionPolicy"
|
||||
],
|
||||
"maxCommandLength": 2000
|
||||
"maxCommandLength": 2000,
|
||||
"blockedInterimTriggerEventTypes": [
|
||||
"performance_snapshot",
|
||||
"agent_metrics_snapshot",
|
||||
"download_progress",
|
||||
"network_state_change",
|
||||
"network_connectivity_check",
|
||||
"log_entry",
|
||||
"agent_trace",
|
||||
"stall_probe_check"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -75,6 +75,16 @@
|
||||
"enum": ["single", "correlation"],
|
||||
"default": "single"
|
||||
},
|
||||
"evaluateOn": {
|
||||
"type": "array",
|
||||
"description": "When this rule is evaluated. Absent = [\"enrollment_end\"] (terminal-only, the historical behavior). Interim triggers let the rule fire before the session is terminal: \"whiteglove_sealed\" (first genuine WhiteGlove seal) and \"on_event:<eventType>\" (an ingest batch contained that event type). High-frequency telemetry event types are HARD-BLOCKED as on_event triggers (guardrails blockedInterimTriggerEventTypes): the backend rejects them on save and the runtime ignores them. Interim runs suppress markSessionAsFailed and record no stats; findings notify once per (session, rule). Interim-enabled rules need monotonic conditions — a not_exists precondition on enrollment_complete/enrollment_failed/session_timeout passes trivially mid-run.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^(enrollment_end|whiteglove_sealed|on_event:[a-z0-9_]{1,128})$"
|
||||
},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"description": "Conditions evaluated against the event stream. All required conditions must match for the rule to fire.",
|
||||
|
||||
@@ -159,6 +159,12 @@ export const BLOCKED_COMMAND_PATTERNS: readonly string[] = [
|
||||
${flatArray(guardrails.blockedCommandPatterns)}
|
||||
];
|
||||
|
||||
// High-frequency telemetry event types hard-blocked as analyze-rule evaluateOn
|
||||
// on_event triggers (backend rejects on save, runtime ignores them).
|
||||
export const BLOCKED_INTERIM_TRIGGER_EVENT_TYPES: readonly string[] = [
|
||||
${flatArray(guardrails.blockedInterimTriggerEventTypes ?? [])}
|
||||
];
|
||||
|
||||
export const MAX_COMMAND_LENGTH = ${guardrails.maxCommandLength};
|
||||
`;
|
||||
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Completeness tripwire for <see cref="RemoteConfigMerger"/>: the merger hand-maps
|
||||
/// <see cref="AgentConfigResponse"/> fields onto <c>AgentConfiguration</c>, and
|
||||
/// historically new tenant knobs were forgotten there and silently ignored. Every public
|
||||
/// property of <see cref="AgentConfigResponse"/> must be classified in exactly one of the
|
||||
/// two lists below. A new property fails this test until a human either wires it through
|
||||
/// <see cref="RemoteConfigMerger.Merge"/> or documents where else it is consumed.
|
||||
/// </summary>
|
||||
public sealed class RemoteConfigMergerCompletenessTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fields <see cref="RemoteConfigMerger.Merge"/> maps onto <c>AgentConfiguration</c>.
|
||||
/// Keep in sync with the Merge body — the test only proves classification coverage,
|
||||
/// the value-level mapping itself is covered by <see cref="RemoteConfigMergerTests"/>.
|
||||
/// </summary>
|
||||
private static readonly string[] MergedByMerger =
|
||||
{
|
||||
nameof(AgentConfigResponse.SelfDestructOnComplete),
|
||||
nameof(AgentConfigResponse.KeepLogFile),
|
||||
nameof(AgentConfigResponse.RebootOnComplete),
|
||||
nameof(AgentConfigResponse.EnableGeoLocation),
|
||||
nameof(AgentConfigResponse.LogLevel),
|
||||
nameof(AgentConfigResponse.RebootDelaySeconds),
|
||||
nameof(AgentConfigResponse.ShowEnrollmentSummary),
|
||||
nameof(AgentConfigResponse.EnrollmentSummaryTimeoutSeconds),
|
||||
nameof(AgentConfigResponse.EnrollmentSummaryBrandingImageUrl),
|
||||
nameof(AgentConfigResponse.EnrollmentSummaryLaunchRetrySeconds),
|
||||
nameof(AgentConfigResponse.NtpServer),
|
||||
nameof(AgentConfigResponse.EnableTimezoneAutoSet),
|
||||
nameof(AgentConfigResponse.DiagnosticsUploadEnabled),
|
||||
nameof(AgentConfigResponse.DiagnosticsUploadMode),
|
||||
nameof(AgentConfigResponse.DiagnosticsLogPaths),
|
||||
nameof(AgentConfigResponse.SendTraceEvents),
|
||||
nameof(AgentConfigResponse.EnableEspContinueAnywayObservation),
|
||||
nameof(AgentConfigResponse.UnrestrictedMode),
|
||||
nameof(AgentConfigResponse.EnableImeMatchLog),
|
||||
nameof(AgentConfigResponse.EnableGatherRuleDebugLog),
|
||||
nameof(AgentConfigResponse.UploadIntervalSeconds),
|
||||
nameof(AgentConfigResponse.MaxBatchSize),
|
||||
nameof(AgentConfigResponse.MaxAuthFailures),
|
||||
nameof(AgentConfigResponse.AuthFailureTimeoutMinutes),
|
||||
// Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds);
|
||||
// the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory.
|
||||
nameof(AgentConfigResponse.Collectors),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fields deliberately NOT mapped by the merger because another component consumes them
|
||||
/// directly from the remote config. One-line justification per entry — verify the
|
||||
/// consumer before classifying a new field here.
|
||||
/// </summary>
|
||||
private static readonly string[] IntentionallyNotMerged =
|
||||
{
|
||||
// Schema/version metadata — logged and cached by RemoteConfigService, not a runtime knob.
|
||||
nameof(AgentConfigResponse.ConfigVersion),
|
||||
// Consumed by DefaultComponentFactory → GatherRuleExecutorHost (rules run against the raw remote list).
|
||||
nameof(AgentConfigResponse.GatherRules),
|
||||
// Consumed by DefaultComponentFactory → ImeLogHost (pattern list handed to ImeLogTracker).
|
||||
nameof(AgentConfigResponse.ImeLogPatterns),
|
||||
// Passed by AgentRuntimeHost into IComponentFactory.CreateCollectorHosts → ImeLogHost sealing re-emit.
|
||||
nameof(AgentConfigResponse.WhiteGloveSealingPatternIds),
|
||||
// Consumed by DefaultComponentFactory (RealmJoin/KeepAwake/ConsoleBypass host gates) + AgentAnalyzerManager.
|
||||
nameof(AgentConfigResponse.Analyzers),
|
||||
// Self-update ZIP integrity second trust channel — BinaryIntegrityVerifier / Program update path.
|
||||
nameof(AgentConfigResponse.LatestAgentSha256),
|
||||
// Running-EXE integrity verification — BinaryIntegrityVerifier post-config check.
|
||||
nameof(AgentConfigResponse.LatestAgentExeSha256),
|
||||
// Self-updater force-update downgrade gate — BinaryIntegrityVerifier / Program update path.
|
||||
nameof(AgentConfigResponse.AllowAgentDowngrade),
|
||||
// Control-channel block signal — RemoteConfigService/ServerControlPlane; stripped from the disk cache.
|
||||
nameof(AgentConfigResponse.DeviceBlocked),
|
||||
// Control-channel kill signal — ServerControlPlane / Program.Guards; live fetch only.
|
||||
nameof(AgentConfigResponse.DeviceKillSignal),
|
||||
// Temporary-block expiry accompanying DeviceBlocked — ServerControlPlane.
|
||||
nameof(AgentConfigResponse.UnblockAt),
|
||||
// Endpoint migration — EndpointMigration/RemoteConfigService re-run the fetch, live fetch only.
|
||||
nameof(AgentConfigResponse.MigrateToApiBaseUrl),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Every_AgentConfigResponse_property_is_classified_exactly_once()
|
||||
{
|
||||
var properties = typeof(AgentConfigResponse)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Select(p => p.Name)
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var merged = new HashSet<string>(MergedByMerger, StringComparer.Ordinal);
|
||||
var notMerged = new HashSet<string>(IntentionallyNotMerged, StringComparer.Ordinal);
|
||||
|
||||
var inBoth = merged.Intersect(notMerged, StringComparer.Ordinal).ToArray();
|
||||
Assert.True(
|
||||
inBoth.Length == 0,
|
||||
$"Fields listed in BOTH classification lists: [{string.Join(", ", inBoth)}].");
|
||||
|
||||
var unclassified = properties
|
||||
.Where(p => !merged.Contains(p) && !notMerged.Contains(p))
|
||||
.ToArray();
|
||||
Assert.True(
|
||||
unclassified.Length == 0,
|
||||
"New AgentConfigResponse field(s) without a classification: " +
|
||||
$"[{string.Join(", ", unclassified)}]. Either map them in RemoteConfigMerger.Merge " +
|
||||
"(+ MergedByMerger here) or verify the consuming component and add them to " +
|
||||
"IntentionallyNotMerged with a justification comment.");
|
||||
|
||||
// Stale entries (renamed/removed properties) must be cleaned up too.
|
||||
var known = new HashSet<string>(properties, StringComparer.Ordinal);
|
||||
var stale = merged.Concat(notMerged).Where(n => !known.Contains(n)).ToArray();
|
||||
Assert.True(
|
||||
stale.Length == 0,
|
||||
$"Classification lists reference non-existent AgentConfigResponse properties: [{string.Join(", ", stale)}].");
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Enforces the comment-only ordering invariants of <see cref="DefaultComponentFactory"/> by
|
||||
/// building the REAL host list (construction only — no host is started) and asserting the
|
||||
/// relative positions of the order-sensitive host types. The orchestrator starts hosts in
|
||||
/// list order, so list order IS start order:
|
||||
/// <list type="bullet">
|
||||
/// <item>DeviceInfoHost before EspAndHelloHost — L9 (delta review 2026-07-02): DeviceInfoHost
|
||||
/// must subscribe to SignalPosted before EspAndHelloHost.Start can post the registry-backfill
|
||||
/// EspPhaseChanged(DeviceSetup), or the enrollment-start re-collect never runs.</item>
|
||||
/// <item>OsBuildChangeHost before WindowsUpdateWatcherHost — the WU channel census reads
|
||||
/// OsBuildChangeHost.BuildChanged at its own start.</item>
|
||||
/// <item>DeliveryOptimizationHost before OfficeInstallDetectorHost — the DO host must have
|
||||
/// subscribed to the Office process-start signal before the watcher starts, or an install
|
||||
/// already in flight misses its wake.</item>
|
||||
/// <item>DesktopArrivalHost before AadJoinHost — the AadJoinHost ctor takes the desktop
|
||||
/// host's RequestResetForRealUserSwitch callback, so the target must exist first.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class DefaultComponentFactoryOrderingTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
/// <summary>
|
||||
/// Build the production host list with default remote config (all order-sensitive hosts
|
||||
/// enabled by default) and system-boundary fakes only: temp state dir, fake ingress,
|
||||
/// virtual clock, no network metrics, no telemetry spool.
|
||||
/// </summary>
|
||||
private CollectorSurfaces CreateSurfaces()
|
||||
{
|
||||
var factory = new DefaultComponentFactory(
|
||||
agentConfig: new AgentConfiguration
|
||||
{
|
||||
ApiBaseUrl = "https://example",
|
||||
TenantId = "t",
|
||||
SessionId = "s",
|
||||
},
|
||||
remoteConfig: new AgentConfigResponse
|
||||
{
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
},
|
||||
networkMetrics: null,
|
||||
agentVersion: "test",
|
||||
stateDirectory: _tmp.Path,
|
||||
startupEventGate: null);
|
||||
|
||||
return factory.CreateCollectorHosts(
|
||||
sessionId: "s",
|
||||
tenantId: "t",
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
whiteGloveSealingPatternIds: Array.Empty<string>(),
|
||||
ingress: new FakeSignalIngressSink(),
|
||||
clock: new VirtualClock(new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc)),
|
||||
telemetrySpool: null,
|
||||
timelineEvents: null);
|
||||
}
|
||||
|
||||
private static int IndexOf<THost>(IReadOnlyList<ICollectorHost> hosts) where THost : ICollectorHost
|
||||
{
|
||||
var indices = hosts
|
||||
.Select((host, index) => (host, index))
|
||||
.Where(t => t.host is THost)
|
||||
.Select(t => t.index)
|
||||
.ToArray();
|
||||
Assert.True(
|
||||
indices.Length == 1,
|
||||
$"Expected exactly one {typeof(THost).Name} in the host list, found {indices.Length}. " +
|
||||
$"Hosts: [{string.Join(", ", hosts.Select(h => h.GetType().Name))}]");
|
||||
return indices[0];
|
||||
}
|
||||
|
||||
private static void AssertBefore<TFirst, TSecond>(IReadOnlyList<ICollectorHost> hosts, string reason)
|
||||
where TFirst : ICollectorHost
|
||||
where TSecond : ICollectorHost
|
||||
{
|
||||
var first = IndexOf<TFirst>(hosts);
|
||||
var second = IndexOf<TSecond>(hosts);
|
||||
Assert.True(
|
||||
first < second,
|
||||
$"{typeof(TFirst).Name} (index {first}) must come before {typeof(TSecond).Name} (index {second}): {reason}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Order_sensitive_hosts_keep_their_documented_relative_order()
|
||||
{
|
||||
var surfaces = CreateSurfaces();
|
||||
var hosts = surfaces.Hosts;
|
||||
try
|
||||
{
|
||||
AssertBefore<DeviceInfoHost, EspAndHelloHost>(
|
||||
hosts,
|
||||
"L9 — DeviceInfoHost must subscribe to SignalPosted before EspAndHelloHost's " +
|
||||
"registry backfill can post EspPhaseChanged(DeviceSetup).");
|
||||
|
||||
AssertBefore<OsBuildChangeHost, WindowsUpdateWatcherHost>(
|
||||
hosts,
|
||||
"the Windows Update channel census reads OsBuildChangeHost.BuildChanged at its own start.");
|
||||
|
||||
AssertBefore<DeliveryOptimizationHost, OfficeInstallDetectorHost>(
|
||||
hosts,
|
||||
"the DO host must subscribe to the Office process-start signal before the watcher starts.");
|
||||
|
||||
AssertBefore<DesktopArrivalHost, AadJoinHost>(
|
||||
hosts,
|
||||
"AadJoinHost wires its onRealUserJoined callback to the already-constructed DesktopArrivalHost.");
|
||||
|
||||
// Closure-latch sanity (factory lines ~139/297): the EspAndHelloHost probes close
|
||||
// over the ImeLogHost reference, which is assigned during the same factory call —
|
||||
// the host must therefore exist in the returned list.
|
||||
Assert.Equal(1, hosts.Count(h => h is ImeLogHost));
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,6 +420,93 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
// ================================================== Branch (a2): snapshot schema mismatch
|
||||
|
||||
[Fact]
|
||||
public void Stale_schema_snapshot_is_quarantined_and_state_rebuilt_from_full_log()
|
||||
{
|
||||
// Self-update scenario: the pre-update binary persisted a snapshot under an older
|
||||
// DecisionState schema generation. The new binary must NOT trust it (Newtonsoft
|
||||
// would silently default reshaped fields) — the snapshot is a cache, so it gets
|
||||
// quarantined and the state is rebuilt from the authoritative SignalLog.
|
||||
using var rig = new Rig();
|
||||
Directory.CreateDirectory(rig.StateDir);
|
||||
|
||||
var sigs = new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted),
|
||||
MakeSignal(1, DecisionSignalKind.AppInstallCompleted),
|
||||
MakeSignal(2, DecisionSignalKind.AppInstallCompleted),
|
||||
};
|
||||
var seedLog = new SignalLogWriter(Path.Combine(rig.StateDir, "signal-log.jsonl"));
|
||||
foreach (var s in sigs) seedLog.Append(s);
|
||||
|
||||
// Snapshot = state after the first two signals, stamped with a FOREIGN schema
|
||||
// version. It parses cleanly (valid checksum via Save), so only the version
|
||||
// check can reject it.
|
||||
var engine = new DecisionEngine();
|
||||
var midState = ReducerReplay.Replay(
|
||||
engine, DecisionState.CreateInitial("S1", "T1"), new[] { sigs[0], sigs[1] });
|
||||
var staleBuilder = midState.ToBuilder();
|
||||
staleBuilder.SchemaVersion = "v0-test-stale";
|
||||
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json"))
|
||||
.Save(staleBuilder.Build());
|
||||
|
||||
var expected = ReducerReplay.Replay(
|
||||
engine, DecisionState.CreateInitial("S1", "T1"), sigs);
|
||||
|
||||
var sut = rig.Build();
|
||||
sut.Start();
|
||||
|
||||
// Full-log rebuild (not a tail replay): startup quarantine flagged, snapshot moved
|
||||
// to a quarantine bucket whose reason names the schema mismatch, log untouched.
|
||||
Assert.True(sut.WasStartupQuarantine);
|
||||
Assert.Equal(expected.StepIndex, sut.CurrentState.StepIndex);
|
||||
Assert.Equal(expected.LastAppliedSignalOrdinal, sut.CurrentState.LastAppliedSignalOrdinal);
|
||||
Assert.Equal(DecisionState.CurrentSchemaVersion, sut.CurrentState.SchemaVersion);
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(rig.StateDir, "snapshot.json")));
|
||||
var reasons = Directory.GetFiles(rig.StateDir, "reason.txt", SearchOption.AllDirectories)
|
||||
.Select(File.ReadAllText).ToList();
|
||||
Assert.Contains(reasons, r => r.Contains("schema-mismatch:v0-test-stale"));
|
||||
|
||||
var signalLogPath = Path.Combine(rig.StateDir, "signal-log.jsonl");
|
||||
Assert.True(File.Exists(signalLogPath));
|
||||
Assert.True(new FileInfo(signalLogPath).Length > 0); // log NOT quarantined
|
||||
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stale_schema_snapshot_with_unreadable_log_escalates_to_total_loss()
|
||||
{
|
||||
// Worst case: untrustworthy cache AND a log that is unreadable from the first
|
||||
// line. Nothing remains to rebuild from — same total-loss escalation as the
|
||||
// corrupt-snapshot branch, seeding a fresh Initial state.
|
||||
using var rig = new Rig();
|
||||
Directory.CreateDirectory(rig.StateDir);
|
||||
|
||||
var staleBuilder = DecisionState.CreateInitial("S1", "T1").ToBuilder();
|
||||
staleBuilder.SchemaVersion = "v0-test-stale";
|
||||
staleBuilder.StepIndex = 4;
|
||||
new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json"))
|
||||
.Save(staleBuilder.Build());
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(rig.StateDir, "signal-log.jsonl"),
|
||||
"not-json-at-all\n",
|
||||
Encoding.UTF8);
|
||||
|
||||
var sut = rig.Build();
|
||||
sut.Start();
|
||||
|
||||
Assert.True(sut.WasStartupQuarantine);
|
||||
Assert.Equal(0, sut.CurrentState.StepIndex); // fresh Initial seed
|
||||
Assert.Equal(DecisionState.CurrentSchemaVersion, sut.CurrentState.SchemaVersion);
|
||||
|
||||
sut.Stop();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Journal_ahead_of_replayed_state_triggers_phantom_truncate()
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// <summary>
|
||||
/// Upload interval in seconds
|
||||
/// </summary>
|
||||
public int UploadIntervalSeconds { get; set; } = 30;
|
||||
public int UploadIntervalSeconds { get; set; } = Shared.Constants.DefaultUploadIntervalSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum batch size for events
|
||||
|
||||
@@ -322,7 +322,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
return new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 0,
|
||||
UploadIntervalSeconds = 30,
|
||||
UploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds,
|
||||
SelfDestructOnComplete = true,
|
||||
KeepLogFile = false,
|
||||
EnableGeoLocation = true,
|
||||
|
||||
@@ -283,6 +283,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// and rebuild from the full SignalLog. If the log itself is head-corrupt
|
||||
// (file non-empty but ReadAll yields zero signals) we escalate to a total-
|
||||
// loss quarantine of the log segments and start fresh.
|
||||
// a2) Snapshot parsed but carries a foreign DecisionState.SchemaVersion (written
|
||||
// by a different binary generation, e.g. across a self-update) → same
|
||||
// treatment as (a): the cache is untrustworthy, rebuild from the full log.
|
||||
// b) Snapshot loaded cleanly → replay any SignalLog tail past the snapshot's
|
||||
// LastAppliedSignalOrdinal so a pre-crash Journal/Snapshot lag is closed.
|
||||
// c) No snapshot file — two sub-cases (Codex follow-up post-#50 #A):
|
||||
@@ -365,6 +368,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
wasStartupQuarantine = true;
|
||||
}
|
||||
else if (loadedState != null
|
||||
&& !string.Equals(loadedState.SchemaVersion, DecisionState.CurrentSchemaVersion, StringComparison.Ordinal))
|
||||
{
|
||||
// Branch (a2) — Snapshot parsed, but it was written by a DIFFERENT DecisionState
|
||||
// schema generation (e.g. the pre-self-update binary). Trusting it would let
|
||||
// Newtonsoft silently default/drop any reshaped fields — the mid-session-amnesia
|
||||
// class this check exists to prevent. The snapshot is only a cache: quarantine it
|
||||
// and rebuild from the authoritative SignalLog. Signals carry their own
|
||||
// KindSchemaVersion and replaying them through the CURRENT reducer is the defined
|
||||
// upgrade path. (Snapshots that PREDATE the SchemaVersion field deserialize with
|
||||
// CurrentSchemaVersion via the ctor fallback and are not detectable here — the
|
||||
// guard protects every generation from v4 on.)
|
||||
logger.Warning(
|
||||
$"EnrollmentOrchestrator: snapshot schema '{loadedState.SchemaVersion}' does not match " +
|
||||
$"current '{DecisionState.CurrentSchemaVersion}' — quarantining snapshot and rebuilding " +
|
||||
"state from the full SignalLog.");
|
||||
snapshot.Quarantine($"schema-mismatch:{loadedState.SchemaVersion}->{DecisionState.CurrentSchemaVersion}");
|
||||
|
||||
var loggedSignals = signalLog.ReadAll();
|
||||
var logFileInfo = new FileInfo(signalLogPath);
|
||||
var logIsHeadCorrupt =
|
||||
logFileInfo.Exists && logFileInfo.Length > 0 && loggedSignals.Count == 0;
|
||||
|
||||
if (logIsHeadCorrupt)
|
||||
{
|
||||
// Same escalation as branch (a): a cache we must not trust plus a log we
|
||||
// cannot read leaves nothing to rebuild from — total-loss quarantine.
|
||||
logger.Warning(
|
||||
"EnrollmentOrchestrator: SignalLog unreadable from the first line — escalating to total-loss quarantine.");
|
||||
SegmentQuarantine.QuarantineAll(
|
||||
stateDirectory, "log-head-corrupt-after-schema-mismatch", () => clock.UtcNow);
|
||||
|
||||
signalLog = new SignalLogWriter(signalLogPath);
|
||||
journal = new JournalWriter(journalPath, () => clock.UtcNow);
|
||||
eventSequence = new EventSequencePersistence(eventSequencePath);
|
||||
|
||||
branchTag = "a2-schema-mismatch-total-loss";
|
||||
}
|
||||
else
|
||||
{
|
||||
signalsToReplay = loggedSignals;
|
||||
branchTag = "a2-schema-mismatch-full-log-replay";
|
||||
}
|
||||
|
||||
wasStartupQuarantine = true;
|
||||
}
|
||||
else if (loadedState != null)
|
||||
{
|
||||
// Branch (b) — Snapshot loaded. Catch up any SignalLog tail past the snapshot.
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// or hour-long gaps that hide live UI from operators).
|
||||
private const int MinUploadIntervalSeconds = 5;
|
||||
private const int MaxUploadIntervalSeconds = 300;
|
||||
private const int DefaultUploadIntervalSeconds = 30;
|
||||
private const int DefaultUploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds;
|
||||
private const int MinUploadBatchSize = 1;
|
||||
private const int MaxUploadBatchSize = 500;
|
||||
private const int DefaultUploadBatchSize = 100;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Enforces the fixture convention documented on <see cref="DecisionSignalKind"/>:
|
||||
/// "New kinds or version bumps require a replay fixture in
|
||||
/// <c>tests/fixtures/signal-kinds/{kind}-v{n}.json</c> — missing fixture = merge block."
|
||||
/// The comment alone enforced nothing; this test does. Kinds without a fixture today are
|
||||
/// carried in an explicit debt set that is only allowed to shrink.
|
||||
/// </summary>
|
||||
public sealed class SignalKindFixtureCoverageTests
|
||||
{
|
||||
// DEBT: backfill — new kinds must NOT be added here. Every entry predates this test;
|
||||
// when a fixture is added for one of these kinds, the test forces its removal.
|
||||
private static readonly HashSet<DecisionSignalKind> KnownMissingFixtures = new HashSet<DecisionSignalKind>
|
||||
{
|
||||
DecisionSignalKind.EspPhaseChanged,
|
||||
DecisionSignalKind.EspExiting,
|
||||
DecisionSignalKind.EspResumed,
|
||||
DecisionSignalKind.EspTerminalFailure,
|
||||
DecisionSignalKind.DesktopArrived,
|
||||
DecisionSignalKind.HelloResolved,
|
||||
DecisionSignalKind.ImeUserSessionCompleted,
|
||||
DecisionSignalKind.DeviceSetupProvisioningComplete,
|
||||
DecisionSignalKind.WhiteGloveShellCoreSuccess,
|
||||
DecisionSignalKind.WhiteGloveSealingPatternDetected,
|
||||
DecisionSignalKind.AadUserJoinedLate,
|
||||
DecisionSignalKind.DeviceInfoCollected,
|
||||
DecisionSignalKind.AutopilotProfileRead,
|
||||
DecisionSignalKind.EspConfigDetected,
|
||||
DecisionSignalKind.HelloPolicyDetected,
|
||||
DecisionSignalKind.ClassifierVerdictIssued,
|
||||
DecisionSignalKind.SessionStarted,
|
||||
DecisionSignalKind.SessionAborted,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Kinds that exist in the enum but are never posted as their own signal: they are
|
||||
/// dispatched over the <see cref="DecisionSignalKind.DeadlineFired"/> rail (the reducer's
|
||||
/// DeadlineFiredV1 switch routes on the <c>deadline</c> payload). Their fixture is named
|
||||
/// after the kind, but its declared <c>Kind</c> is <c>DeadlineFired</c> — e.g.
|
||||
/// <c>realmjoin-timeout-v1.json</c> carries <c>Payload.deadline = "realmjoin_timeout"</c>.
|
||||
/// </summary>
|
||||
private static readonly HashSet<DecisionSignalKind> DeadlineDispatchedKinds = new HashSet<DecisionSignalKind>
|
||||
{
|
||||
DecisionSignalKind.RealmJoinTimeout,
|
||||
};
|
||||
|
||||
/// <summary>Locate <c>tests/fixtures/signal-kinds</c> by walking up to the repo root (AutopilotMonitor.sln).</summary>
|
||||
private static string FixtureRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln")))
|
||||
{
|
||||
return Path.Combine(dir, "tests", "fixtures", "signal-kinds");
|
||||
}
|
||||
var parent = Directory.GetParent(dir)?.FullName;
|
||||
if (parent == null || parent == dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not locate repo root (AutopilotMonitor.sln) walking up from " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> FixtureFiles()
|
||||
{
|
||||
var root = FixtureRoot();
|
||||
Assert.True(Directory.Exists(root), $"Fixture directory not found: {root}");
|
||||
var files = Directory.GetFiles(root, "*.json").OrderBy(f => f, StringComparer.Ordinal).ToArray();
|
||||
Assert.NotEmpty(files);
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>Signal JSONL lines of one fixture ('#' comments and blank lines stripped).</summary>
|
||||
private static IReadOnlyList<string> SignalLines(string path)
|
||||
{
|
||||
var lines = File.ReadAllLines(path)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 0 && l[0] != '#')
|
||||
.ToArray();
|
||||
Assert.True(lines.Length > 0, $"Fixture contains no signal lines: {path}");
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strictly parse the raw <c>Kind</c> of a fixture line. Deliberately NOT via
|
||||
/// <see cref="SignalSerializer"/> alone: its unknown-kind fallback maps typos to
|
||||
/// <see cref="DecisionSignalKind.SessionStarted"/>, which would silently mis-count coverage.
|
||||
/// </summary>
|
||||
private static DecisionSignalKind StrictKind(string line, string path)
|
||||
{
|
||||
var raw = JObject.Parse(line).Value<string>("Kind");
|
||||
Assert.False(string.IsNullOrEmpty(raw), $"Fixture line has no Kind: {path}");
|
||||
Assert.True(
|
||||
Enum.TryParse<DecisionSignalKind>(raw, ignoreCase: false, out var kind)
|
||||
&& Enum.IsDefined(typeof(DecisionSignalKind), kind),
|
||||
$"Fixture {Path.GetFileName(path)} declares unknown Kind '{raw}'.");
|
||||
return kind;
|
||||
}
|
||||
|
||||
/// <summary>Dash-insensitive lowercase — "realmjoin-detected-v1" and "RealmJoinDetected" both normalize consistently.</summary>
|
||||
private static string Normalize(string value) => value.Replace("-", string.Empty).ToLowerInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// Map a fixture file name to the kind it is named after: the longest enum member whose
|
||||
/// normalized name prefixes the normalized file name, followed by <c>v{n}[-variant]</c>
|
||||
/// (dash placement per the existing corpus, e.g. <c>realmjoin-detected-v1.json</c> /
|
||||
/// <c>admin-preemption-detected-v1-failed.json</c>).
|
||||
/// </summary>
|
||||
private static DecisionSignalKind KindFromFileName(string fileNameWithoutExtension)
|
||||
{
|
||||
var normalized = Normalize(fileNameWithoutExtension);
|
||||
var match = ((DecisionSignalKind[])Enum.GetValues(typeof(DecisionSignalKind)))
|
||||
.Where(k =>
|
||||
{
|
||||
var prefix = Normalize(k.ToString());
|
||||
return normalized.StartsWith(prefix, StringComparison.Ordinal)
|
||||
&& Regex.IsMatch(normalized.Substring(prefix.Length), "^v[0-9]+");
|
||||
})
|
||||
.OrderByDescending(k => k.ToString().Length)
|
||||
.Cast<DecisionSignalKind?>()
|
||||
.FirstOrDefault();
|
||||
Assert.True(
|
||||
match.HasValue,
|
||||
$"Fixture file name '{fileNameWithoutExtension}.json' does not follow " +
|
||||
"'{kind}-v{n}[-variant].json' for any DecisionSignalKind.");
|
||||
return match!.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load every fixture through the REAL deserialization path and return the set of kinds
|
||||
/// the corpus covers. Also enforces per-file consistency: the declared Kind must match
|
||||
/// the file name (or be the DeadlineFired dispatch shape for deadline-dispatched kinds).
|
||||
/// </summary>
|
||||
private static ISet<DecisionSignalKind> LoadAndVerifyFixtures()
|
||||
{
|
||||
var covered = new HashSet<DecisionSignalKind>();
|
||||
foreach (var file in FixtureFiles())
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var namedKind = KindFromFileName(fileName);
|
||||
|
||||
foreach (var line in SignalLines(file))
|
||||
{
|
||||
var declaredKind = StrictKind(line, file);
|
||||
|
||||
// Real deserialization path — the same call FixtureLoader/ReplayHarness use.
|
||||
var signal = SignalSerializer.Deserialize(line);
|
||||
Assert.Equal(declaredKind, signal.Kind);
|
||||
Assert.True(signal.KindSchemaVersion >= 1, $"KindSchemaVersion must be >= 1 in {fileName}.");
|
||||
|
||||
if (declaredKind == namedKind)
|
||||
{
|
||||
covered.Add(declaredKind);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Only the documented dispatch exception is allowed: a fixture named after
|
||||
// a deadline-dispatched kind carries a DeadlineFired signal with the
|
||||
// deadline name in its payload.
|
||||
string? deadlineName = null;
|
||||
signal.Payload?.TryGetValue("deadline", out deadlineName);
|
||||
Assert.True(
|
||||
DeadlineDispatchedKinds.Contains(namedKind)
|
||||
&& declaredKind == DecisionSignalKind.DeadlineFired
|
||||
&& !string.IsNullOrEmpty(deadlineName),
|
||||
$"Fixture '{fileName}.json' is named after {namedKind} but declares Kind={declaredKind}. " +
|
||||
"Either the file is misnamed, or a new deadline-dispatched kind must be " +
|
||||
"registered in DeadlineDispatchedKinds (with a payload.deadline entry).");
|
||||
covered.Add(namedKind);
|
||||
covered.Add(declaredKind); // it IS also a genuine DeadlineFired fixture
|
||||
}
|
||||
}
|
||||
}
|
||||
return covered;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_fixture_deserializes_through_the_real_signal_path_and_matches_its_filename()
|
||||
{
|
||||
LoadAndVerifyFixtures();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_kind_is_either_fixtured_or_registered_as_known_debt()
|
||||
{
|
||||
var fixtured = LoadAndVerifyFixtures();
|
||||
var allKinds = (DecisionSignalKind[])Enum.GetValues(typeof(DecisionSignalKind));
|
||||
|
||||
var unfixturedAndUntracked = allKinds
|
||||
.Where(k => !fixtured.Contains(k) && !KnownMissingFixtures.Contains(k))
|
||||
.ToArray();
|
||||
Assert.True(
|
||||
unfixturedAndUntracked.Length == 0,
|
||||
"DecisionSignalKind values without a replay fixture in tests/fixtures/signal-kinds/: " +
|
||||
$"[{string.Join(", ", unfixturedAndUntracked)}]. The convention on DecisionSignalKind is " +
|
||||
"'new kind => fixture' — add a {kind}-v{n}.json fixture (do NOT extend KnownMissingFixtures).");
|
||||
|
||||
// Debt only shrinks: once a fixture exists, the kind must leave the debt set.
|
||||
var fixturedButStillListedAsDebt = KnownMissingFixtures.Where(fixtured.Contains).ToArray();
|
||||
Assert.True(
|
||||
fixturedButStillListedAsDebt.Length == 0,
|
||||
"Kinds now have fixtures but are still listed in KnownMissingFixtures — remove them: " +
|
||||
$"[{string.Join(", ", fixturedButStillListedAsDebt)}].");
|
||||
}
|
||||
}
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
{
|
||||
/// <summary>
|
||||
/// Drift tripwire for the 4-site sync required when adding a field to
|
||||
/// <see cref="DecisionState"/>: ctor parameter + <see cref="DecisionStateBuilder"/>
|
||||
/// copy-ctor line + builder property + <c>Build()</c> argument. A forgotten copy-ctor
|
||||
/// line silently resets the fact on every reducer step; a forgotten <c>Build()</c>
|
||||
/// argument resets it on the first copy-with-changes. These tests are fully
|
||||
/// reflection-driven so a new field fails them until all four sites are wired
|
||||
/// (and until a value factory for any brand-new property type is added below).
|
||||
/// </summary>
|
||||
public sealed class DecisionStateBuilderCompletenessTests
|
||||
{
|
||||
private static readonly DateTime BaseUtc = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static PropertyInfo[] StateProperties() =>
|
||||
typeof(DecisionState)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
private static PropertyInfo[] BuilderProperties() =>
|
||||
typeof(DecisionStateBuilder)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.SetMethod != null && p.SetMethod.IsPublic)
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Produce a distinct, non-default value for a builder property. <paramref name="seed"/>
|
||||
/// is folded into every value so no two properties of the same type ever share an
|
||||
/// instance — a swapped <c>Build()</c> argument (e.g. espFinalExitUtc passed into
|
||||
/// desktopArrivedUtc) is therefore detected, not masked. A property type without a
|
||||
/// factory entry fails loudly: extend the factory when a new field type appears.
|
||||
/// </summary>
|
||||
private static object CreateDistinctValue(Type type, int seed)
|
||||
{
|
||||
long ordinal = 100 + seed;
|
||||
if (type == typeof(string)) return "test-value-" + seed;
|
||||
if (type == typeof(int)) return 7 + seed;
|
||||
if (type == typeof(long)) return 7L + seed;
|
||||
if (type == typeof(DateTime?)) return BaseUtc.AddMinutes(seed);
|
||||
if (type == typeof(SessionStage)) return SessionStage.AwaitingHello;
|
||||
if (type == typeof(SessionOutcome?)) return SessionOutcome.EnrollmentComplete;
|
||||
if (type == typeof(SignalFact<DateTime>)) return new SignalFact<DateTime>(BaseUtc.AddMinutes(seed), ordinal);
|
||||
if (type == typeof(SignalFact<string>)) return new SignalFact<string>("test-value-" + seed, ordinal);
|
||||
if (type == typeof(SignalFact<bool>)) return new SignalFact<bool>(true, ordinal);
|
||||
if (type == typeof(SignalFact<EnrollmentPhase>)) return new SignalFact<EnrollmentPhase>(EnrollmentPhase.AccountSetup, ordinal);
|
||||
if (type == typeof(List<ActiveDeadline>))
|
||||
{
|
||||
return new List<ActiveDeadline>
|
||||
{
|
||||
new ActiveDeadline(
|
||||
name: "deadline-" + seed,
|
||||
dueAtUtc: BaseUtc.AddMinutes(seed),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string> { ["k"] = "v" + seed }),
|
||||
};
|
||||
}
|
||||
// Immutable fact aggregates — built through their public APIs so the instances are
|
||||
// reference-distinct from their Empty defaults AND fully serializable (a dropped
|
||||
// copy-ctor line or Build() argument falls back to Empty → reference inequality).
|
||||
if (type == typeof(AppInstallFacts)) return AppInstallFacts.Empty.WithCompleted("Installed").WithFailed("app-" + seed);
|
||||
if (type == typeof(EnrollmentScenarioProfile))
|
||||
{
|
||||
return EnrollmentScenarioProfile.Empty.With(
|
||||
mode: EnrollmentMode.WhiteGlove,
|
||||
confidence: ProfileConfidence.High,
|
||||
evidenceOrdinal: ordinal,
|
||||
reason: "test-value-" + seed);
|
||||
}
|
||||
if (type == typeof(EnrollmentScenarioObservations)) return EnrollmentScenarioObservations.Empty.WithSkipUserEsp(true, ordinal);
|
||||
if (type == typeof(ClassifierOutcomes))
|
||||
{
|
||||
return ClassifierOutcomes.Empty.WithWhiteGloveSealing(new Hypothesis(
|
||||
level: HypothesisLevel.Strong,
|
||||
reason: "test-value-" + seed,
|
||||
score: 50,
|
||||
lastUpdatedUtc: BaseUtc.AddMinutes(seed),
|
||||
lastClassifierVerdictId: "verdict-" + seed));
|
||||
}
|
||||
if (type == typeof(RealmJoinFacts)) return RealmJoinFacts.Empty.WithDetected(BaseUtc.AddMinutes(seed), ordinal);
|
||||
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
$"No value factory for builder property type {type}. A new DecisionState field " +
|
||||
"introduced a new type — add a distinct-value factory entry so the completeness " +
|
||||
"tests can populate it.");
|
||||
}
|
||||
|
||||
/// <summary>Populate every settable builder property with a distinct non-default value.</summary>
|
||||
private static Dictionary<string, object> PopulateBuilder(DecisionStateBuilder builder)
|
||||
{
|
||||
var assigned = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
int seed = 0;
|
||||
foreach (var prop in BuilderProperties())
|
||||
{
|
||||
var value = CreateDistinctValue(prop.PropertyType, seed++);
|
||||
prop.SetValue(builder, value);
|
||||
assigned[prop.Name] = value;
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
private static void AssertMatches(string propertyName, object expected, object? actual)
|
||||
{
|
||||
// Deadlines: Build() copies the List into an array and the copy-ctor re-wraps it —
|
||||
// the container is a new instance by design, but the elements must be the SAME refs.
|
||||
if (expected is List<ActiveDeadline> expectedDeadlines)
|
||||
{
|
||||
var actualDeadlines = Assert.IsAssignableFrom<IEnumerable<ActiveDeadline>>(actual).ToList();
|
||||
Assert.Equal(expectedDeadlines.Count, actualDeadlines.Count);
|
||||
for (int i = 0; i < expectedDeadlines.Count; i++)
|
||||
{
|
||||
Assert.Same(expectedDeadlines[i], actualDeadlines[i]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Value types (enums, int, long, DateTime?) and strings compare by value; every
|
||||
// reference-typed fact must be the identical instance (a re-created equal-looking
|
||||
// value would still hide a dropped pass-through).
|
||||
if (expected is string || expected.GetType().IsValueType)
|
||||
{
|
||||
Assert.True(
|
||||
expected.Equals(actual),
|
||||
$"{propertyName}: expected {expected}, got {actual ?? "(null)"}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Same(expected, actual);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================= (a) name-set parity
|
||||
|
||||
[Fact]
|
||||
public void Builder_property_set_matches_DecisionState_property_set()
|
||||
{
|
||||
var stateNames = StateProperties().Select(p => p.Name).ToArray();
|
||||
var builderNames = BuilderProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
var missingOnBuilder = stateNames.Except(builderNames, StringComparer.Ordinal).ToArray();
|
||||
var missingOnState = builderNames.Except(stateNames, StringComparer.Ordinal).ToArray();
|
||||
|
||||
Assert.True(
|
||||
missingOnBuilder.Length == 0 && missingOnState.Length == 0,
|
||||
"DecisionState and DecisionStateBuilder property sets drifted apart. " +
|
||||
$"State-only: [{string.Join(", ", missingOnBuilder)}]; " +
|
||||
$"Builder-only: [{string.Join(", ", missingOnState)}]. " +
|
||||
"Adding a DecisionState field requires: ctor parameter, builder copy-ctor line, " +
|
||||
"builder property, and Build() argument.");
|
||||
}
|
||||
|
||||
// ============================================================= (b) Build() completeness
|
||||
|
||||
[Fact]
|
||||
public void Build_carries_every_builder_property_onto_the_state()
|
||||
{
|
||||
var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder();
|
||||
var assigned = PopulateBuilder(builder);
|
||||
|
||||
var state = builder.Build();
|
||||
|
||||
foreach (var stateProp in StateProperties())
|
||||
{
|
||||
Assert.True(
|
||||
assigned.TryGetValue(stateProp.Name, out var expected),
|
||||
$"DecisionState.{stateProp.Name} has no builder counterpart — see the name-set parity test.");
|
||||
AssertMatches($"DecisionState.{stateProp.Name}", expected!, stateProp.GetValue(state));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================= (c) copy-ctor completeness
|
||||
|
||||
[Fact]
|
||||
public void ToBuilder_copies_every_property_back_from_the_state()
|
||||
{
|
||||
var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder();
|
||||
var assigned = PopulateBuilder(builder);
|
||||
var state = builder.Build();
|
||||
|
||||
var roundtrippedBuilder = state.ToBuilder();
|
||||
|
||||
foreach (var builderProp in BuilderProperties())
|
||||
{
|
||||
var expected = assigned[builderProp.Name];
|
||||
AssertMatches($"DecisionStateBuilder.{builderProp.Name}", expected, builderProp.GetValue(roundtrippedBuilder));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================= (d) serializer roundtrip
|
||||
|
||||
[Fact]
|
||||
public void StateSerializer_roundtrip_of_fully_populated_state_is_lossless()
|
||||
{
|
||||
// Catches Newtonsoft ctor-binding mismatches: deserialization binds JSON properties
|
||||
// onto the single public ctor by (case-insensitive) parameter name, so a renamed
|
||||
// ctor parameter silently drops the field back to its default. Serializing the
|
||||
// deserialized state must therefore reproduce the exact same JSON.
|
||||
var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder();
|
||||
PopulateBuilder(builder);
|
||||
var state = builder.Build();
|
||||
|
||||
var json = StateSerializer.Serialize(state);
|
||||
var roundtrippedJson = StateSerializer.Serialize(StateSerializer.Deserialize(json));
|
||||
|
||||
Assert.Equal(json, roundtrippedJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -418,6 +418,21 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
Assert.Same(seed, profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeUserSessionCompleted_devicePreparationMedium_isNoOp()
|
||||
{
|
||||
// WDP guard: the IME user channel runs on Device Preparation too, so this signal
|
||||
// cannot discriminate Classic from WDP. The registry-deterministic v2 seed sits at
|
||||
// only Medium confidence — without the explicit guard the High-confidence-only bail
|
||||
// would let this signal silently reclassify every WDP session as Classic.
|
||||
var seed = DevicePreparationSeed();
|
||||
var signal = MakeSignal(DecisionSignalKind.ImeUserSessionCompleted, ordinal: 10, payload: null);
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(seed, signal);
|
||||
|
||||
Assert.Same(seed, profile);
|
||||
}
|
||||
|
||||
// ================================================================== ApplySelfDeployingDeadlineConfirmed
|
||||
// Plan v9 — ApplyDeviceSetupProvisioningComplete was removed (signal-time Mode promotion
|
||||
// caused false-positive SelfDeploying terminations on Classic flows, session 88a53223).
|
||||
@@ -483,6 +498,19 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
Assert.Same(seed, profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfDeployingDeadlineConfirmed_devicePreparationMedium_isNoOp()
|
||||
{
|
||||
// WDP guard mirror: the DeviceOnlyEspDetection deadline is ESP-derived and should be
|
||||
// unreachable on WDP, but a misfire must not displace the registry v2 fact.
|
||||
var seed = DevicePreparationSeed();
|
||||
var signal = MakeSignal(DecisionSignalKind.DeadlineFired, ordinal: 6, payload: null);
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplySelfDeployingDeadlineConfirmed(seed, signal);
|
||||
|
||||
Assert.Same(seed, profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveSealingConfirmed_setsWhiteGloveHigh_andTechnicianSide()
|
||||
{
|
||||
@@ -497,6 +525,20 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
Assert.Equal("classifier_whiteglove_sealing_confirmed", profile.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveSealingConfirmed_devicePreparationMedium_isNoOp()
|
||||
{
|
||||
// WhiteGlove pre-provisioning is an ESP technician flow that cannot occur on WDP —
|
||||
// a Confirmed sealing verdict against a v2-profiled session is a classifier misfire.
|
||||
// This updater previously had NO guard at all, so it could overwrite any mode.
|
||||
var seed = DevicePreparationSeed();
|
||||
var signal = MakeSignal(DecisionSignalKind.ClassifierVerdictIssued, ordinal: 15, payload: null);
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyWhiteGloveSealingConfirmed(seed, signal);
|
||||
|
||||
Assert.Same(seed, profile);
|
||||
}
|
||||
|
||||
// ================================================================== AadUserJoinedLate
|
||||
|
||||
[Fact]
|
||||
@@ -608,6 +650,17 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Profile exactly as seeded by ApplyEnrollmentFactsObserved for enrollmentType "v2" —
|
||||
/// the state the WDP downgrade-guards protect.
|
||||
/// </summary>
|
||||
private static EnrollmentScenarioProfile DevicePreparationSeed() =>
|
||||
EnrollmentScenarioProfile.Empty.With(
|
||||
mode: EnrollmentMode.DevicePreparation,
|
||||
confidence: ProfileConfidence.Medium,
|
||||
reason: "enrollment_facts_observed:v2",
|
||||
evidenceOrdinal: 0);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
DecisionSignalKind kind,
|
||||
long ordinal,
|
||||
|
||||
@@ -869,9 +869,26 @@ namespace AutopilotMonitor.SummaryDialog
|
||||
|
||||
private static string FormatPhaseName(string phase)
|
||||
{
|
||||
// Convert "DeviceSetup" -> "Device Setup", "AccountSetup" -> "Account Setup"
|
||||
if (string.IsNullOrEmpty(phase)) return phase;
|
||||
|
||||
// Canonical display names, mirroring EnrollmentEvent.GetPhaseName in Shared.
|
||||
// This dialog is deliberately dependency-free (standalone EXE), so the mapping is
|
||||
// restated here instead of referencing Shared — the camel-case splitter below is
|
||||
// only the fallback for unknown tokens and cannot produce "Apps (Device)".
|
||||
switch (phase)
|
||||
{
|
||||
case "Start": return "Start";
|
||||
case "DevicePreparation": return "Device Preparation";
|
||||
case "DeviceSetup": return "Device Setup";
|
||||
case "AppsDevice": return "Apps (Device)";
|
||||
case "AccountSetup": return "Account Setup";
|
||||
case "AppsUser": return "Apps (User)";
|
||||
case "FinalizingSetup": return "Finalizing Setup";
|
||||
case "Complete": return "Complete";
|
||||
case "Failed": return "Failed";
|
||||
}
|
||||
|
||||
// Fallback: "SomeNewPhase" -> "Some New Phase"
|
||||
var result = new System.Text.StringBuilder();
|
||||
for (int i = 0; i < phase.Length; i++)
|
||||
{
|
||||
|
||||
+29
@@ -21,6 +21,8 @@ public class AnalyzeOnEnrollmentEndEnvelopeSerializationTests
|
||||
[InlineData("enrollment_complete")]
|
||||
[InlineData("enrollment_failed")]
|
||||
[InlineData("vulnerability_correlated")]
|
||||
[InlineData("whiteglove_sealed")]
|
||||
[InlineData("interim_trigger")]
|
||||
public void Envelope_round_trips_through_newtonsoft_for_all_reasons(string reason)
|
||||
{
|
||||
var original = new AnalyzeOnEnrollmentEndEnvelope
|
||||
@@ -43,6 +45,33 @@ public class AnalyzeOnEnrollmentEndEnvelopeSerializationTests
|
||||
Assert.Equal(EnqueuedAt, restored.EnqueuedAt.ToUniversalTime());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_trigger_event_types_round_trip_and_default_to_null()
|
||||
{
|
||||
// interim_trigger envelopes carry the batch's matched trigger types; every other
|
||||
// reason leaves the field null so legacy consumers see an unchanged shape.
|
||||
var original = new AnalyzeOnEnrollmentEndEnvelope
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
Reason = "interim_trigger",
|
||||
TriggerEventTypes = new List<string> { "hybrid_login_pending", "custom_probe_signal" },
|
||||
EnqueuedAt = EnqueuedAt,
|
||||
};
|
||||
|
||||
var restored = JsonConvert.DeserializeObject<AnalyzeOnEnrollmentEndEnvelope>(
|
||||
JsonConvert.SerializeObject(original));
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal(new List<string> { "hybrid_login_pending", "custom_probe_signal" }, restored!.TriggerEventTypes);
|
||||
|
||||
// A legacy envelope without the field deserializes to null, not empty.
|
||||
var legacy = JsonConvert.DeserializeObject<AnalyzeOnEnrollmentEndEnvelope>(
|
||||
$"{{\"EnvelopeVersion\":\"1\",\"TenantId\":\"{TenantId}\",\"SessionId\":\"{SessionId}\",\"Reason\":\"enrollment_complete\"}}");
|
||||
Assert.NotNull(legacy);
|
||||
Assert.Null(legacy!.TriggerEventTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_default_envelope_version_is_one()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Apps;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the app-version duration regression radar (<see cref="AppVersionRegressionRadar"/>):
|
||||
/// detection gates (≥10 measured installs on both sides, median lift ≥2×, ≥300s absolute),
|
||||
/// first-seen version ordering (never string sort), the measured-population exclusions,
|
||||
/// re-arm semantics, the tracker keyspace round-trip, the wording contract, and the
|
||||
/// versionBreakdown duration fields on the analytics wire shape.
|
||||
/// </summary>
|
||||
public class AppVersionRegressionRadarTests
|
||||
{
|
||||
private const string TenantA = "00000000-0000-0000-0000-0000000000a1";
|
||||
private const string App = "Contoso VPN";
|
||||
private static readonly DateTime Now = new(2026, 8, 13, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static int _sessionCounter;
|
||||
|
||||
private static AppInstallSummary Install(
|
||||
string version,
|
||||
int durationSeconds,
|
||||
DateTime? startedAt = null,
|
||||
string appName = App,
|
||||
string status = "Succeeded",
|
||||
string terminalState = "Installed",
|
||||
bool collision = false)
|
||||
=> new()
|
||||
{
|
||||
TenantId = TenantA,
|
||||
SessionId = $"s-{++_sessionCounter}",
|
||||
AppName = appName,
|
||||
AppVersion = version,
|
||||
Status = status,
|
||||
TerminalState = terminalState,
|
||||
DurationSeconds = durationSeconds,
|
||||
StartedAt = startedAt ?? Now.AddDays(-1),
|
||||
AppIdCollision = collision,
|
||||
};
|
||||
|
||||
/// <summary>n measured installs of one version, all with the same duration, spread over one day.</summary>
|
||||
private static List<AppInstallSummary> Batch(string version, int count, int durationSeconds, DateTime firstSeen)
|
||||
=> Enumerable.Range(0, count)
|
||||
.Select(i => Install(version, durationSeconds, firstSeen.AddMinutes(i)))
|
||||
.ToList();
|
||||
|
||||
// ── detection gates ─────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
// currentCount, previousCount, previousMedian, currentMedian, expectFire
|
||||
[InlineData(10, 10, 300, 900, true)] // lift 3×, +600s — fires
|
||||
[InlineData(10, 10, 300, 600, true)] // exactly 2× AND exactly +300s — boundary fires
|
||||
[InlineData(9, 10, 300, 900, false)] // current side below sample floor
|
||||
[InlineData(10, 9, 300, 900, false)] // previous side below sample floor
|
||||
[InlineData(10, 10, 200, 499, false)] // lift 2.5× but only +299s — absolute floor blocks
|
||||
[InlineData(10, 10, 1000, 1900, false)] // +900s but lift 1.9× — lift gate blocks
|
||||
public void Evaluate_Gates(int currentCount, int previousCount, int previousMedian, int currentMedian, bool expectFire)
|
||||
{
|
||||
var rows = Batch("1.0", previousCount, previousMedian, Now.AddDays(-20));
|
||||
rows.AddRange(Batch("2.0", currentCount, currentMedian, Now.AddDays(-5)));
|
||||
|
||||
var findings = AppVersionRegressionRadar.Evaluate(rows);
|
||||
|
||||
if (!expectFire)
|
||||
{
|
||||
Assert.Empty(findings);
|
||||
return;
|
||||
}
|
||||
|
||||
var finding = Assert.Single(findings);
|
||||
Assert.Equal(App, finding.AppName);
|
||||
Assert.Equal("2.0", finding.CurrentVersion);
|
||||
Assert.Equal("1.0", finding.PreviousVersion);
|
||||
Assert.Equal(currentMedian, finding.CurrentMedianSeconds);
|
||||
Assert.Equal(previousMedian, finding.PreviousMedianSeconds);
|
||||
Assert.Equal(currentCount, finding.CurrentMeasuredCount);
|
||||
Assert.Equal(previousCount, finding.PreviousMeasuredCount);
|
||||
Assert.Equal(Math.Round((double)currentMedian / previousMedian, 1), finding.Lift);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MedianIsNearestRank_HandComputed()
|
||||
{
|
||||
// Even n: nearest-rank p50 takes the LOWER middle value (rank = ceil(0.5*4)-1 = 1).
|
||||
var rows = new List<AppInstallSummary>();
|
||||
foreach (var d in new[] { 100, 200, 300, 400 }) // median 200
|
||||
rows.AddRange(Batch("1.0", 3, d, Now.AddDays(-20)));
|
||||
foreach (var d in new[] { 500, 600, 700, 800 }) // median 600
|
||||
rows.AddRange(Batch("2.0", 3, d, Now.AddDays(-5)));
|
||||
|
||||
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
|
||||
Assert.Equal(600, finding.CurrentMedianSeconds);
|
||||
Assert.Equal(200, finding.PreviousMedianSeconds);
|
||||
Assert.Equal(3.0, finding.Lift);
|
||||
}
|
||||
|
||||
// ── version ordering ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_VersionOrder_ByFirstSeen_NeverStringSort()
|
||||
{
|
||||
// Ordinally "9.1" > "2024.10", but "2024.10" is the NEWER version by first-seen.
|
||||
// A string sort would flip current/previous and compare in the wrong direction.
|
||||
var rows = Batch("9.1", 10, 300, Now.AddDays(-20));
|
||||
rows.AddRange(Batch("2024.10", 10, 900, Now.AddDays(-5)));
|
||||
|
||||
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
|
||||
Assert.Equal("2024.10", finding.CurrentVersion);
|
||||
Assert.Equal("9.1", finding.PreviousVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MultipleVersionsInFlight_PreviousIsLatestFirstSeenBeforeCurrent()
|
||||
{
|
||||
var rows = Batch("1.0", 10, 300, Now.AddDays(-30)); // oldest
|
||||
rows.AddRange(Batch("1.1", 10, 320, Now.AddDays(-18))); // the true predecessor
|
||||
rows.AddRange(Batch("2.0", 10, 960, Now.AddDays(-4))); // newest, regressed
|
||||
|
||||
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
|
||||
Assert.Equal("2.0", finding.CurrentVersion);
|
||||
Assert.Equal("1.1", finding.PreviousVersion); // not 1.0
|
||||
Assert.Equal(320, finding.PreviousMedianSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_SingleVersion_NeverFires()
|
||||
{
|
||||
var rows = Batch("1.0", 25, 900, Now.AddDays(-5));
|
||||
Assert.Empty(AppVersionRegressionRadar.Evaluate(rows));
|
||||
}
|
||||
|
||||
// ── measured-population exclusions ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ExcludedRows_DoNotCountTowardSamplesOrMedians()
|
||||
{
|
||||
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
|
||||
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)));
|
||||
|
||||
// None of these may move the samples or medians:
|
||||
rows.Add(Install("2.0", 0, Now.AddDays(-2))); // unmeasured (start unobserved)
|
||||
rows.Add(Install("2.0", 21601, Now.AddDays(-2))); // over the 6h plausibility cap
|
||||
rows.Add(Install("2.0", 50, Now.AddDays(-2), terminalState: "Skipped")); // skip — not an attempt
|
||||
rows.Add(Install("2.0", 50, Now.AddDays(-2), terminalState: "Postponed")); // skip — not an attempt
|
||||
rows.Add(Install("2.0", 50, Now.AddDays(-2), status: "Failed")); // failed — no duration signal
|
||||
rows.Add(Install("2.0", 50, Now.AddDays(-2), collision: true)); // AppId collision — foreign outcomes
|
||||
rows.Add(Install("", 50, Now.AddDays(-2))); // no version
|
||||
rows.Add(Install("2.0", 50, Now.AddDays(-2), appName: "")); // no app name
|
||||
|
||||
var finding = Assert.Single(AppVersionRegressionRadar.Evaluate(rows));
|
||||
Assert.Equal(10, finding.CurrentMeasuredCount);
|
||||
Assert.Equal(900, finding.CurrentMedianSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_DeterministicOrdering_LiftDesc_ThenAppNameOrdinal()
|
||||
{
|
||||
var rows = new List<AppInstallSummary>();
|
||||
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)).Select(s => { s.AppName = "App B"; return s; }));
|
||||
rows.AddRange(Batch("2.0", 10, 900, Now.AddDays(-5)).Select(s => { s.AppName = "App B"; return s; })); // lift 3.0
|
||||
rows.AddRange(Batch("1.0", 10, 300, Now.AddDays(-20)).Select(s => { s.AppName = "App A"; return s; }));
|
||||
rows.AddRange(Batch("2.0", 10, 1500, Now.AddDays(-5)).Select(s => { s.AppName = "App A"; return s; })); // lift 5.0
|
||||
|
||||
var findings = AppVersionRegressionRadar.Evaluate(rows);
|
||||
Assert.Equal(2, findings.Count);
|
||||
Assert.Equal("App A", findings[0].AppName); // higher lift first
|
||||
Assert.Equal("App B", findings[1].AppName);
|
||||
}
|
||||
|
||||
// ── re-arm ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static AppVersionRegressionAlert Alert(
|
||||
string currentVersion = "2.0", string previousVersion = "1.0")
|
||||
=> new()
|
||||
{
|
||||
TenantId = TenantA,
|
||||
AppName = App,
|
||||
CurrentVersion = currentVersion,
|
||||
PreviousVersion = previousVersion,
|
||||
CurrentMedianSeconds = 900,
|
||||
PreviousMedianSeconds = 300,
|
||||
CurrentMeasuredCount = 10,
|
||||
PreviousMeasuredCount = 10,
|
||||
Lift = 3.0,
|
||||
FirstNotifiedAt = Now.AddDays(-3),
|
||||
LastEvaluatedAt = Now.AddDays(-1),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ShouldReArm_WhenCurrentVersionDrainedOutOfHorizon()
|
||||
{
|
||||
// Only the previous version still has measured installs — the alerted version is gone.
|
||||
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
|
||||
Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldReArm_WhenCurrentVersionBelowSampleFloor()
|
||||
{
|
||||
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
|
||||
rows.AddRange(Batch("2.0", 9, 900, Now.AddDays(-5)));
|
||||
Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldReArm_WhenMedianFellBackUnderReArmFactor()
|
||||
{
|
||||
// 400 < 1.5 × 300 — the regression recovered (e.g. CDN warmed up).
|
||||
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
|
||||
rows.AddRange(Batch("2.0", 10, 400, Now.AddDays(-5)));
|
||||
Assert.True(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldNotReArm_WhileMedianStaysElevated()
|
||||
{
|
||||
// 500 ≥ 1.5 × 300 — still elevated (even though below the 2× fire gate).
|
||||
var rows = Batch("1.0", 10, 300, Now.AddDays(-20));
|
||||
rows.AddRange(Batch("2.0", 10, 500, Now.AddDays(-5)));
|
||||
Assert.False(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldNotReArm_WhenPreviousVersionDrained_NoComparisonBasis()
|
||||
{
|
||||
// The predecessor left the horizon; the episode is kept until the current
|
||||
// version itself drains (fires-stopped-style re-arm only).
|
||||
var rows = Batch("2.0", 10, 900, Now.AddDays(-5));
|
||||
Assert.False(AppVersionRegressionRadar.ShouldReArm(rows, Alert()));
|
||||
}
|
||||
|
||||
// ── tracker keyspace (round-trip + key shape) ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionRowKey_PrefixedCaseFoldedAndSanitized()
|
||||
{
|
||||
Assert.Equal("appversionregression|contoso app_2|1.0_beta_",
|
||||
TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(
|
||||
" Contoso App/2 ", " 1.0#Beta? "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionRowKey_NoPrefixCollisionWithOtherKeyspaces()
|
||||
{
|
||||
var key = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionRowKey(App, "1.0");
|
||||
Assert.StartsWith("appversionregression|", key, StringComparison.Ordinal);
|
||||
// The prefix range [prefix, "appversionregression}") must not swallow the sibling
|
||||
// keyspaces ("ruleregression|", "tpmpss|", "{mfr}|{model}").
|
||||
Assert.True(string.CompareOrdinal("ruleregression|", "appversionregression}") > 0);
|
||||
Assert.True(string.CompareOrdinal("tpmpss|", "appversionregression}") > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppVersionRegressionEntity_RoundTripsAllFields()
|
||||
{
|
||||
var alert = new AppVersionRegressionAlert
|
||||
{
|
||||
TenantId = TenantA,
|
||||
AppName = App,
|
||||
CurrentVersion = "2.4.0",
|
||||
PreviousVersion = "2.3.9",
|
||||
CurrentMedianSeconds = 1740,
|
||||
PreviousMedianSeconds = 660,
|
||||
CurrentMeasuredCount = 12,
|
||||
PreviousMeasuredCount = 40,
|
||||
Lift = 2.6,
|
||||
FirstNotifiedAt = Now,
|
||||
LastEvaluatedAt = Now.AddHours(2),
|
||||
};
|
||||
|
||||
var entity = TableHardwareRejectionNotificationTracker.BuildAppVersionRegressionEntity(TenantA, alert);
|
||||
Assert.Equal(TenantA.ToLowerInvariant(), entity.PartitionKey);
|
||||
Assert.Equal("appversionregression|contoso vpn|2.4.0", entity.RowKey);
|
||||
|
||||
var mapped = TableHardwareRejectionNotificationTracker.MapToAppVersionRegressionAlert(entity);
|
||||
Assert.Equal(TenantA, mapped.TenantId);
|
||||
Assert.Equal(App, mapped.AppName);
|
||||
Assert.Equal("2.4.0", mapped.CurrentVersion);
|
||||
Assert.Equal("2.3.9", mapped.PreviousVersion);
|
||||
Assert.Equal(1740, mapped.CurrentMedianSeconds);
|
||||
Assert.Equal(660, mapped.PreviousMedianSeconds);
|
||||
Assert.Equal(12, mapped.CurrentMeasuredCount);
|
||||
Assert.Equal(40, mapped.PreviousMeasuredCount);
|
||||
Assert.Equal(2.6, mapped.Lift);
|
||||
Assert.Equal(Now, mapped.FirstNotifiedAt);
|
||||
Assert.Equal(Now.AddHours(2), mapped.LastEvaluatedAt);
|
||||
}
|
||||
|
||||
// ── wording contract ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RegressionMessage_CarriesTheFullNumbers_MinutesOneDecimal()
|
||||
{
|
||||
var finding = new AppVersionDurationRegressionFinding
|
||||
{
|
||||
TenantId = TenantA,
|
||||
AppName = App,
|
||||
CurrentVersion = "2.4.0",
|
||||
PreviousVersion = "2.3.9",
|
||||
CurrentMedianSeconds = 1740,
|
||||
PreviousMedianSeconds = 660,
|
||||
CurrentMeasuredCount = 12,
|
||||
PreviousMeasuredCount = 40,
|
||||
Lift = 2.6,
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
"Median install duration rose from 11 to 29 min after version 2.4.0 " +
|
||||
"(12 measured installs vs 40 on version 2.3.9) — lift 2.6x over the last 35 days.",
|
||||
MaintenanceService.BuildAppVersionRegressionMessage(finding));
|
||||
}
|
||||
|
||||
// ── analytics wire shape (versionBreakdown duration fields) ─────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_VersionBreakdown_CarriesMeasuredDurationStats()
|
||||
{
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var rows = new List<AppInstallSummary>
|
||||
{
|
||||
Install("1.0", 100), Install("1.0", 200), Install("1.0", 300), Install("1.0", 400),
|
||||
Install("1.0", 0), // unmeasured — excluded from stats
|
||||
Install("1.0", 50, terminalState: "Skipped"), // skip — excluded
|
||||
Install("1.0", 50, status: "Failed"), // failed — excluded from durations
|
||||
};
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
rows, sessionRepo.Object, App, days: 30));
|
||||
|
||||
var version = root.GetProperty("versionBreakdown")[0];
|
||||
Assert.Equal("1.0", version.GetProperty("appVersion").GetString());
|
||||
Assert.Equal(7, version.GetProperty("installs").GetInt32());
|
||||
Assert.Equal(4, version.GetProperty("measuredInstalls").GetInt32());
|
||||
// Nearest-rank over [100, 200, 300, 400]: p50 = 200 (lower middle), p95 = 400.
|
||||
Assert.Equal(200, version.GetProperty("medianDurationSeconds").GetInt32());
|
||||
Assert.Equal(400, version.GetProperty("p95DurationSeconds").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_VersionRegressions_SerializedCamelCase_AndPresentWhenEmptyData()
|
||||
{
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((SessionSummary?)null);
|
||||
|
||||
var alert = Alert();
|
||||
// The Functions host serializes camelCase on the wire; mirror that here 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(
|
||||
new List<AppInstallSummary>(), sessionRepo.Object, App, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }), wireOptions);
|
||||
Assert.Equal(1, emptyRoot.GetProperty("versionRegressions").GetArrayLength());
|
||||
|
||||
var root = JsonSerializer.SerializeToElement(await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
|
||||
new List<AppInstallSummary> { Install("1.0", 100) }, sessionRepo.Object, App, days: 30,
|
||||
new List<AppVersionRegressionAlert> { alert }), wireOptions);
|
||||
|
||||
var regression = root.GetProperty("versionRegressions")[0];
|
||||
Assert.Equal("2.0", regression.GetProperty("currentVersion").GetString());
|
||||
Assert.Equal(900, regression.GetProperty("currentMedianSeconds").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ public class AuthenticationMiddlewareTests
|
||||
[InlineData("GET", "/api/auth/me")] // AuthenticatedUser
|
||||
[InlineData("GET", "/api/auth/is-global-admin")] // AuthenticatedUser
|
||||
[InlineData("POST", "/api/realtime/negotiate")] // AuthenticatedUser
|
||||
[InlineData("GET", "/api/progress/sessions")] // AuthenticatedUser
|
||||
[InlineData("GET", "/api/progress/sessions/lookup")] // AuthenticatedUserWithRole
|
||||
[InlineData("GET", "/api/sessions")] // MemberRead
|
||||
[InlineData("GET", "/api/audit/logs")] // MemberRead
|
||||
[InlineData("PUT", "/api/config/11111111-1111-1111-1111-111111111111")] // TenantAdminOrGA
|
||||
|
||||
@@ -238,6 +238,7 @@ public class CrossTenantAccessTests
|
||||
[InlineData("GET", "/api/bootstrap/sessions")]
|
||||
[InlineData("GET", "/api/diagnostics/download-url")]
|
||||
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
|
||||
[InlineData("GET", "/api/progress/sessions/lookup")]
|
||||
public void QueryParam_NoQueryTenant_DefaultsToJwt(string httpMethod, string path)
|
||||
{
|
||||
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: false, queryTenantId: null);
|
||||
@@ -254,6 +255,7 @@ public class CrossTenantAccessTests
|
||||
[InlineData("GET", "/api/bootstrap/sessions")]
|
||||
[InlineData("GET", "/api/diagnostics/download-url")]
|
||||
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
|
||||
[InlineData("GET", "/api/progress/sessions/lookup")]
|
||||
public void QueryParam_SameTenant_IsAllowed(string httpMethod, string path)
|
||||
{
|
||||
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: false, queryTenantId: TenantA);
|
||||
@@ -274,6 +276,7 @@ public class CrossTenantAccessTests
|
||||
[InlineData("DELETE", "/api/bootstrap/sessions/CODE123")]
|
||||
[InlineData("GET", "/api/diagnostics/download-url")]
|
||||
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
|
||||
[InlineData("GET", "/api/progress/sessions/lookup")]
|
||||
public void QueryParam_CrossTenant_NonGA_IsBlocked(string httpMethod, string path)
|
||||
{
|
||||
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: false, queryTenantId: TenantB);
|
||||
@@ -291,6 +294,7 @@ public class CrossTenantAccessTests
|
||||
[InlineData("GET", "/api/bootstrap/sessions")]
|
||||
[InlineData("GET", "/api/diagnostics/download-url")]
|
||||
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
|
||||
[InlineData("GET", "/api/progress/sessions/lookup")]
|
||||
public void QueryParam_CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string path)
|
||||
{
|
||||
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: true, queryTenantId: TenantB);
|
||||
@@ -316,6 +320,7 @@ public class CrossTenantAccessTests
|
||||
[InlineData("DELETE", "bootstrap/sessions/{code}")]
|
||||
[InlineData("GET", "diagnostics/download-url")]
|
||||
[InlineData("GET", "progress/sessions/{sessionId}/events")]
|
||||
[InlineData("GET", "progress/sessions/lookup")]
|
||||
public void QueryParamRoutes_HaveCorrectScoping(string httpMethod, string routeTemplate)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.Entries
|
||||
|
||||
@@ -75,9 +75,9 @@ public class DeletionManifestBuilderTests
|
||||
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
|
||||
Assert.Equal(11, c["signalsByKind"]);
|
||||
|
||||
// 19 cascade tables (incl. SessionTimeBreakdowns F1 PR2 + 3 SessionAnnotations lanes)
|
||||
// + tombstone = 20 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(20, manifest.Steps.Count);
|
||||
// 20 cascade tables (incl. SessionTimeBreakdowns F1 PR2, 3 SessionAnnotations lanes +
|
||||
// SessionTenantLookup P6.1) + tombstone = 21 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(21, manifest.Steps.Count);
|
||||
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@ public class DeletionManifestBuilderTests
|
||||
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker")
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
|
||||
.WithSessionAnnotations(propValue: "annotation_marker"));
|
||||
.WithSessionAnnotations(propValue: "annotation_marker")
|
||||
.WithSessionTenantLookup(propValue: "lookup_marker"));
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
TenantId, SessionId, "admin_delete",
|
||||
@@ -148,9 +149,9 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 19 cascade-table steps
|
||||
// (16 + the 3 SessionAnnotations lane steps).
|
||||
Assert.Equal(19, nonTombstoneSteps.Count);
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 20 cascade-table steps
|
||||
// (16 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
|
||||
Assert.Equal(20, nonTombstoneSteps.Count);
|
||||
foreach (var step in nonTombstoneSteps)
|
||||
{
|
||||
Assert.Equal(0, step.RowCount);
|
||||
@@ -179,8 +180,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
// Expect: 19 cascade + 2 inventory + 1 tombstone = 22 steps.
|
||||
Assert.Equal(22, manifest.Steps.Count);
|
||||
// Expect: 20 cascade + 2 inventory + 1 tombstone = 23 steps.
|
||||
Assert.Equal(23, manifest.Steps.Count);
|
||||
|
||||
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
|
||||
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
|
||||
@@ -292,6 +293,11 @@ public class DeletionManifestBuilderTests
|
||||
Assert.NotEqual(SessionId, tombstone.Rows[0].Rk); // SessionsIndex first
|
||||
Assert.Contains(SessionId, tombstone.Rows[0].Rk, StringComparison.Ordinal); // RK still references the session
|
||||
Assert.Equal(SessionId, tombstone.Rows[1].Rk); // Sessions row second
|
||||
|
||||
// FINAL rows carry their source table explicitly (DeletionStep.Table is null on this
|
||||
// step); delete/restore route by it instead of the legacy RowKey-shape heuristic.
|
||||
Assert.Equal(Constants.TableNames.SessionsIndex, tombstone.Rows[0].Table);
|
||||
Assert.Equal(Constants.TableNames.Sessions, tombstone.Rows[1].Table);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -646,6 +652,7 @@ public class DeletionManifestBuilderTests
|
||||
private TableEntity? _eventSessionIndex;
|
||||
private TableEntity? _sessionTimeBreakdown;
|
||||
private TableEntity? _sessionInventoryContributions;
|
||||
private TableEntity? _sessionTenantLookup;
|
||||
private readonly Dictionary<string, TableEntity> _sessionAnnotations = new(StringComparer.Ordinal);
|
||||
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
|
||||
|
||||
@@ -801,6 +808,17 @@ public class DeletionManifestBuilderTests
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithSessionTenantLookup(string propValue = "lookup_marker")
|
||||
{
|
||||
_sessionTenantLookup = new TableEntity(SessionId, "tenant")
|
||||
{
|
||||
["TenantId"] = TenantId,
|
||||
["Marker"] = propValue,
|
||||
};
|
||||
_sessionTenantLookup.ETag = new ETag("0xLOOKUP");
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISessionDeletionInventoryReader Build()
|
||||
{
|
||||
var reader = new Mock<ISessionDeletionInventoryReader>();
|
||||
@@ -833,6 +851,8 @@ public class DeletionManifestBuilderTests
|
||||
.ReturnsAsync(_sessionTimeBreakdown);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionInventoryContributions);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionTenantLookup, SessionId, "tenant", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionTenantLookup);
|
||||
foreach (var (lane, row) in _sessionAnnotations)
|
||||
{
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny<CancellationToken>()))
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the FINAL-step table resolution: an explicit <see cref="DeletionRowDump.Table"/> wins
|
||||
/// unconditionally; only legacy manifests (written before the field existed) fall back to the
|
||||
/// RowKey-shape heuristic. The adversarial rows deliberately contradict the heuristic so a
|
||||
/// regression to Contains('_') routing flips the assertion.
|
||||
/// </summary>
|
||||
public class DeletionTombstoneTablesTests
|
||||
{
|
||||
[Fact]
|
||||
public void Explicit_table_wins_over_contradicting_rowkey_shape()
|
||||
{
|
||||
// RK contains '_' (heuristic would say SessionsIndex) but Table says Sessions — and vice versa.
|
||||
var sessionsRow = new DeletionRowDump { Pk = "t", Rk = "id_with_underscores", Table = Constants.TableNames.Sessions };
|
||||
var indexRow = new DeletionRowDump { Pk = "t", Rk = "plainkey", Table = Constants.TableNames.SessionsIndex };
|
||||
|
||||
Assert.Equal(Constants.TableNames.Sessions, DeletionTombstoneTables.Resolve(sessionsRow));
|
||||
Assert.Equal(Constants.TableNames.SessionsIndex, DeletionTombstoneTables.Resolve(indexRow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_rows_without_table_fall_back_to_rowkey_shape()
|
||||
{
|
||||
// Pre-field manifests: Sessions RK is the bare session GUID, index RK is composed.
|
||||
var legacySessions = new DeletionRowDump { Pk = "t", Rk = "22222222-2222-2222-2222-222222222222" };
|
||||
var legacyIndex = new DeletionRowDump { Pk = "t", Rk = "6299999999999999999_22222222-2222-2222-2222-222222222222" };
|
||||
|
||||
Assert.Equal(Constants.TableNames.Sessions, DeletionTombstoneTables.Resolve(legacySessions));
|
||||
Assert.Equal(Constants.TableNames.SessionsIndex, DeletionTombstoneTables.Resolve(legacyIndex));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Table_field_round_trips_through_manifest_json()
|
||||
{
|
||||
var dump = new DeletionRowDump { Pk = "t", Rk = "rk", Table = Constants.TableNames.SessionsIndex };
|
||||
var json = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions);
|
||||
Assert.Contains("\"table\":", json);
|
||||
|
||||
var roundTripped = JsonSerializer.Deserialize<DeletionRowDump>(json, DeletionManifestJson.SerializerOptions);
|
||||
Assert.Equal(Constants.TableNames.SessionsIndex, roundTripped!.Table);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_manifest_json_without_table_deserializes_to_null_and_is_omitted_on_write()
|
||||
{
|
||||
// WhenWritingNull keeps rows of table-targeted steps (Table always null there) byte-
|
||||
// identical to the pre-field format — SchemaHash of those steps is unaffected.
|
||||
var legacyJson = "{\"pk\":\"t\",\"rk\":\"rk\",\"props\":{}}";
|
||||
var dump = JsonSerializer.Deserialize<DeletionRowDump>(legacyJson, DeletionManifestJson.SerializerOptions);
|
||||
Assert.Null(dump!.Table);
|
||||
|
||||
var rewritten = JsonSerializer.Serialize(dump, DeletionManifestJson.SerializerOptions);
|
||||
Assert.DoesNotContain("table", rewritten);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the API-latency aggregation of
|
||||
/// <see cref="GetGeographicMetricsFunction.ComputeGeographicMetrics"/> (2026-07-28): both the
|
||||
/// per-location and the global figure are REQUEST-WEIGHTED averages —
|
||||
/// Σ(sessionAvg·requests)/Σ(requests) — not averages of session averages, so a long session
|
||||
/// with many uploads counts proportionally. Sessions without latency data (agents predating
|
||||
/// <c>AvgApiLatencyMs</c>) drop out of the weighting entirely.
|
||||
/// <see cref="GetGeographicMetricsFunction.ComputeGeographicMetrics"/>: the avg figures are
|
||||
/// REQUEST-WEIGHTED averages — Σ(sessionAvg·requests)/Σ(requests) — not averages of session
|
||||
/// averages, so a long session with many uploads counts proportionally. The median figures
|
||||
/// (upper median over per-session averages, duration convention) are the robust display
|
||||
/// statistic and the baseline for <c>ApiLatencyVsGlobalPct</c>: one corrupt session average
|
||||
/// (a request spanning a sleep/hibernate gap) must not drag a whole location (2026-08-15).
|
||||
/// Sessions without latency data (agents predating <c>AvgApiLatencyMs</c>) drop out entirely.
|
||||
/// </summary>
|
||||
public class GeographicMetricsApiLatencyTests
|
||||
{
|
||||
@@ -47,9 +49,37 @@ public class GeographicMetricsApiLatencyTests
|
||||
var loc = Assert.Single(result.Locations);
|
||||
// (1000·300 + 500·100) / 400 = 875 — an unweighted mean would say 750.
|
||||
Assert.Equal(875d, loc.AvgApiLatencyMs);
|
||||
// Upper median of [500, 1000].
|
||||
Assert.Equal(1000d, loc.MedianApiLatencyMs);
|
||||
Assert.Equal(2, loc.ApiLatencySessionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MedianLatency_IsRobustAgainstCorruptSessionAverage()
|
||||
{
|
||||
// Real-world shape (Amersfoort 2026-08): one session whose average was inflated to
|
||||
// ~23 minutes per request by a sleep-spanning sample, among ordinary ~150-200 ms peers.
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
S("NL", "Amersfoort", 150, 100),
|
||||
S("NL", "Amersfoort", 170, 80),
|
||||
S("NL", "Amersfoort", 200, 120),
|
||||
S("NL", "Amersfoort", 1_382_551.5, 115),
|
||||
};
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(
|
||||
sessions, new List<AppInstallSummary>(), "country");
|
||||
|
||||
var loc = Assert.Single(result.Locations);
|
||||
// The weighted mean is wrecked by the corrupt session…
|
||||
Assert.True(loc.AvgApiLatencyMs > 100_000);
|
||||
// …the median is not: upper median of [150, 170, 200, 1382551.5] = 200.
|
||||
Assert.Equal(200d, loc.MedianApiLatencyMs);
|
||||
Assert.Equal(200d, result.GlobalAverages.MedianApiLatencyMs);
|
||||
// Median vs global median (same single location) → no deviation.
|
||||
Assert.Equal(0d, loc.ApiLatencyVsGlobalPct);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsWithoutLatencyData_DropOutOfWeighting_ButStayInSessionCount()
|
||||
{
|
||||
@@ -67,6 +97,7 @@ public class GeographicMetricsApiLatencyTests
|
||||
Assert.Equal(3, loc.SessionCount);
|
||||
Assert.Equal(1, loc.ApiLatencySessionCount);
|
||||
Assert.Equal(200d, loc.AvgApiLatencyMs);
|
||||
Assert.Equal(200d, loc.MedianApiLatencyMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -79,9 +110,11 @@ public class GeographicMetricsApiLatencyTests
|
||||
|
||||
var loc = Assert.Single(result.Locations);
|
||||
Assert.Equal(0d, loc.AvgApiLatencyMs);
|
||||
Assert.Equal(0d, loc.MedianApiLatencyMs);
|
||||
Assert.Equal(0, loc.ApiLatencySessionCount);
|
||||
Assert.Equal(0d, loc.ApiLatencyVsGlobalPct);
|
||||
Assert.Equal(0d, result.GlobalAverages.AvgApiLatencyMs);
|
||||
Assert.Equal(0d, result.GlobalAverages.MedianApiLatencyMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -98,11 +131,13 @@ public class GeographicMetricsApiLatencyTests
|
||||
|
||||
// Equal request weights → global = (200 + 800) / 2 = 500.
|
||||
Assert.Equal(500d, result.GlobalAverages.AvgApiLatencyMs);
|
||||
// Upper median of [200, 800].
|
||||
Assert.Equal(800d, result.GlobalAverages.MedianApiLatencyMs);
|
||||
|
||||
var de = result.Locations.Single(l => l.Country == "DE");
|
||||
var id = result.Locations.Single(l => l.Country == "ID");
|
||||
Assert.Equal(-60d, de.ApiLatencyVsGlobalPct); // 200 vs 500
|
||||
Assert.Equal(60d, id.ApiLatencyVsGlobalPct); // 800 vs 500
|
||||
Assert.Equal(-75d, de.ApiLatencyVsGlobalPct); // median 200 vs global median 800
|
||||
Assert.Equal(0d, id.ApiLatencyVsGlobalPct); // median 800 vs global median 800
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -119,6 +154,7 @@ public class GeographicMetricsApiLatencyTests
|
||||
|
||||
var fr = result.Locations.Single(l => l.Country == "FR");
|
||||
Assert.Equal(0d, fr.AvgApiLatencyMs);
|
||||
Assert.Equal(0d, fr.MedianApiLatencyMs);
|
||||
Assert.Equal(0d, fr.ApiLatencyVsGlobalPct); // no data ≠ "0 ms fast"
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -640,6 +640,12 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public override Task<int> WipeByRowKeyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
WipeCallCount++;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public override Task<int> WipeBlobsByTenantPrefixAsync(string containerName, string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
WipeCallCount++;
|
||||
|
||||
+30
@@ -293,6 +293,33 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
Assert.Contains(Constants.TableNames.ConfigurationBackups, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostDrain_RowKeyWipe_CoversUsageMetrics()
|
||||
{
|
||||
// UsageMetrics rows are PK=date, RK=tenantId — the former exact-PK wipe matched 0
|
||||
// rows, so per-tenant snapshots survived offboarding. The RowKey-anchored Variant D
|
||||
// wipe is their only tenant-lifecycle deletion.
|
||||
var harness = Harness.New();
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope());
|
||||
|
||||
Assert.Contains(Constants.TableNames.UsageMetrics, harness.SafeWipeProbe.RowKeyWipes);
|
||||
Assert.DoesNotContain(Constants.TableNames.UsageMetrics, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostDrain_ExactPartitionWipe_CoversTenantStatsAndScriptNameCache()
|
||||
{
|
||||
// PlatformStats carries per-tenant counter rows (PK=tenantId, RK="current") and
|
||||
// ScriptNameCache is keyed PK=tenantId — both previously survived offboarding.
|
||||
var harness = Harness.New();
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope());
|
||||
|
||||
Assert.Contains(Constants.TableNames.PlatformStats, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
Assert.Contains(Constants.TableNames.ScriptNameCache, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
}
|
||||
|
||||
// ── Harness (copied minimal — only what these tests need) ───────────────────
|
||||
|
||||
private sealed class Harness
|
||||
@@ -442,6 +469,8 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public List<string> PropertyOnlyWipes { get; } = new();
|
||||
/// <summary>Table names passed to the exact-partition (Variant A) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> ExactPartitionWipes { get; } = new();
|
||||
/// <summary>Table names passed to the RowKey-anchored (Variant D) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> RowKeyWipes { get; } = new();
|
||||
public CountingSafeWipeService() : base(
|
||||
new TableStorageService(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance),
|
||||
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
@@ -451,6 +480,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeByRowKeyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; RowKeyWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ public class OffboardingFiltersTests
|
||||
Assert.Throws<ArgumentException>(() => OffboardingFilters.TenantIdProperty(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-guid")]
|
||||
[InlineData("")]
|
||||
public void RowKeyEquals_ThrowsForNonGuid(string input)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => OffboardingFilters.RowKeyEquals(input));
|
||||
}
|
||||
|
||||
// ── Filter shapes ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -97,6 +105,17 @@ public class OffboardingFiltersTests
|
||||
Assert.DoesNotContain("PartitionKey", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowKeyEquals_EmitsRowKeyOnlyFilter()
|
||||
{
|
||||
// Variant D anchors on the ROW key (UsageMetrics: PK=date, RK=tenantId) — the
|
||||
// filter must not constrain PartitionKey, otherwise it degenerates back into the
|
||||
// exact-PK wipe that matched 0 rows.
|
||||
var filter = OffboardingFilters.RowKeyEquals(ValidTenant);
|
||||
Assert.Equal($"RowKey eq '{ValidTenant}'", filter);
|
||||
Assert.DoesNotContain("PartitionKey", filter);
|
||||
}
|
||||
|
||||
// ── Injection neutralization (defense in depth even after EnsureValidGuid) ──
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -605,7 +605,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
// ── Transactional config patch / backups / revert — GlobalAdminOnly (phase 1) ──
|
||||
// ── Transactional config patch (TenantAdminOrGA) / backups + revert (GlobalAdminOnly) ──
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_GlobalAdmin_IsAllowed()
|
||||
@@ -658,13 +658,12 @@ public class PolicyEnforcementMiddlewareTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
|
||||
public async Task ConfigBackupAndRevertRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
|
||||
{
|
||||
// Phase 1 is deliberately GA-only — even the tenant's OWN admin is denied until the
|
||||
// phase-2 flip to TenantAdminOrGA (+ the caller-tier field deny-list in the service).
|
||||
// Backups + revert stay deliberately GA-only (operator restore surface) — even the
|
||||
// tenant's OWN admin is denied. Only the PATCH line was widened in phase 2.
|
||||
foreach (var (method, path) in new[]
|
||||
{
|
||||
("PATCH", $"/api/config/{TenantA}/fields"),
|
||||
("GET", $"/api/config/{TenantA}/backups"),
|
||||
("POST", $"/api/config/{TenantA}/revert"),
|
||||
})
|
||||
@@ -672,7 +671,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
var ownAdmin = BuildHarness();
|
||||
ownAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var adminResult = await ownAdmin.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, "admin@contoso.com"));
|
||||
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin in phase 1");
|
||||
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin");
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
@@ -682,10 +681,49 @@ public class PolicyEnforcementMiddlewareTests
|
||||
var delegated = BuildHarness();
|
||||
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
|
||||
var delegatedResult = await delegated.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "msp@partner.example"));
|
||||
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin in phase 1");
|
||||
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatch_OwnTenantAdmin_IsAllowed()
|
||||
{
|
||||
// Phase 2: the web's per-section save PATCHes the caller's own config row. The
|
||||
// GA-only field protection moved into the service's TenantAdmin caller tier
|
||||
// (explicit 400 per field) — the route itself admits the own-tenant admin.
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantA, "admin@contoso.com"));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatch_ForeignTenant_Reader_Delegated_AreForbidden()
|
||||
{
|
||||
// RouteParam scoping: a tenant admin cannot patch ANOTHER tenant's row; the read-only
|
||||
// Global Reader and delegated (MSP) admins stay excluded entirely.
|
||||
var foreignAdmin = BuildHarness();
|
||||
foreignAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var foreignResult = await foreignAdmin.Middleware.DecideAsync(
|
||||
"PATCH", $"/api/config/{TenantB}/fields", null, AuthedPrincipal(TenantA, "admin@contoso.com"));
|
||||
Assert.False(foreignResult.Allowed, "own-tenant admin must not patch a foreign tenant's config");
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
var readerResult = await reader.Middleware.DecideAsync(
|
||||
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "reader@vendor.example"));
|
||||
Assert.False(readerResult.Allowed, "read-only Global Reader must not patch config fields");
|
||||
|
||||
var delegated = BuildHarness();
|
||||
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
|
||||
var delegatedResult = await delegated.Middleware.DecideAsync(
|
||||
"PATCH", $"/api/config/{TenantA}/fields", null, AuthedPrincipal(TenantB, "msp@partner.example"));
|
||||
Assert.False(delegatedResult.Allowed, "delegated (MSP) admin must not patch config fields");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delegated_OwnHomeTenant_NonMember_IsStillForbidden()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using AutopilotMonitor.Functions.Functions.Progress;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Progress Portal's serial-knowledge authorization model: the policy-catalog
|
||||
/// registration (no tenant-wide list route; lookup resolves the role flags), the role-based
|
||||
/// lookup matching (<see cref="ProgressPortalFunction.FindBestMatch"/>), and the serial proof
|
||||
/// both the events route and the SignalR session-group join share (<see cref="SerialKnowledgeProof"/>).
|
||||
/// </summary>
|
||||
public class ProgressPortalFunctionTests
|
||||
{
|
||||
private const string Serial = "PF3XKQ7";
|
||||
private const string Device = "AP-LAB-042";
|
||||
|
||||
private static SessionSummary Session(
|
||||
string serial = Serial, string device = Device, int ageMinutes = 0, string? sessionId = null)
|
||||
=> new()
|
||||
{
|
||||
SessionId = sessionId ?? Guid.NewGuid().ToString(),
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SerialNumber = serial,
|
||||
DeviceName = device,
|
||||
StartedAt = new DateTime(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc).AddMinutes(-ageMinutes),
|
||||
};
|
||||
|
||||
// ── Policy catalog ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TenantWide_list_route_is_gone_from_the_catalog()
|
||||
{
|
||||
// THE FINDING: GET progress/sessions shipped the tenant's latest 100 sessions to any
|
||||
// authenticated (roleless) user, with the serial filter applied only in the browser.
|
||||
// The route must stay unregistered — the catalog fail-closes unknown routes to 403.
|
||||
Assert.Null(EndpointAccessPolicyCatalog.FindPolicy("GET", "progress/sessions"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lookup_route_is_AuthenticatedUserWithRole_with_QueryParam_scoping()
|
||||
{
|
||||
// AuthenticatedUserWithRole, NOT MemberRead (the portal's audience is roleless end
|
||||
// users — regression history c4dabeee) and NOT plain AuthenticatedUser (the function
|
||||
// needs the resolved role flags to grant members substring search).
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "progress/sessions/lookup");
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(EndpointPolicy.AuthenticatedUserWithRole, entry!.Policy);
|
||||
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Events_route_stays_AuthenticatedUser_with_QueryParam_scoping()
|
||||
{
|
||||
// The serial proof is enforced in-function; the tier must remain reachable for
|
||||
// roleless end users.
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy(
|
||||
"GET", "progress/sessions/22222222-2222-2222-2222-222222222222/events");
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(EndpointPolicy.AuthenticatedUser, entry!.Policy);
|
||||
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
|
||||
}
|
||||
|
||||
// ── FindBestMatch: roleless callers need the EXACT serial / device name ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(Serial)]
|
||||
[InlineData("pf3xkq7")] // case-insensitive
|
||||
[InlineData(" PF3XKQ7 ")] // trimmed
|
||||
[InlineData(Device)]
|
||||
[InlineData("ap-lab-042")]
|
||||
public void Roleless_exact_serial_or_device_name_matches(string search)
|
||||
{
|
||||
var match = ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: false);
|
||||
|
||||
Assert.NotNull(match);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PF3")] // serial prefix
|
||||
[InlineData("3XKQ")] // serial fragment
|
||||
[InlineData("AP-LAB")] // device-name prefix
|
||||
[InlineData("P")] // one-character fishing
|
||||
public void Roleless_substring_does_NOT_match(string search)
|
||||
{
|
||||
// The exact serial IS the authorization proof. Substring matching would degrade it to
|
||||
// "knows any one character" and let a roleless user fish through the tenant.
|
||||
var match = ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: false);
|
||||
|
||||
Assert.Null(match);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PF3")]
|
||||
[InlineData("AP-LAB")]
|
||||
[InlineData("lab-042")]
|
||||
public void Member_substring_still_matches(string search)
|
||||
{
|
||||
// Members/GA keep the portal's original fuzzy search — for them the same data is
|
||||
// MemberRead at REST anyway (regression guard for the helpdesk workflow).
|
||||
var match = ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: true);
|
||||
|
||||
Assert.NotNull(match);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Newest_matching_session_wins()
|
||||
{
|
||||
var older = Session(ageMinutes: 120, sessionId: "older");
|
||||
var newest = Session(ageMinutes: 0, sessionId: "newest");
|
||||
var unrelated = Session(serial: "OTHER01", device: "OTHER-DEV", ageMinutes: 1, sessionId: "unrelated");
|
||||
|
||||
var match = ProgressPortalFunction.FindBestMatch(
|
||||
new[] { older, unrelated, newest }, Serial, allowSubstring: false);
|
||||
|
||||
Assert.Equal("newest", match!.SessionId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("NO-SUCH-DEVICE")]
|
||||
public void No_match_returns_null(string search)
|
||||
{
|
||||
Assert.Null(ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: false));
|
||||
Assert.Null(ProgressPortalFunction.FindBestMatch(new[] { Session() }, search, allowSubstring: true));
|
||||
}
|
||||
|
||||
// ── SerialKnowledgeProof: the shared events/SignalR-join comparison ──────
|
||||
|
||||
[Theory]
|
||||
[InlineData(Serial, Serial)]
|
||||
[InlineData(Serial, "pf3xkq7")] // case-insensitive
|
||||
[InlineData(Serial, " PF3XKQ7 ")] // trimmed
|
||||
[InlineData(" PF3XKQ7 ", Serial)] // stored value trimmed too
|
||||
public void SerialProof_matches_exact_serial(string stored, string provided)
|
||||
{
|
||||
Assert.True(SerialKnowledgeProof.Matches(stored, provided));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Serial, "PF3XKQ")] // near-miss
|
||||
[InlineData(Serial, "PF3")] // prefix is NOT proof
|
||||
[InlineData(Serial, null)]
|
||||
[InlineData(Serial, "")]
|
||||
[InlineData(Serial, " ")]
|
||||
[InlineData(null, Serial)] // session without a serial is unreachable (fail-closed)
|
||||
[InlineData("", Serial)]
|
||||
[InlineData(null, null)]
|
||||
public void SerialProof_rejects_everything_else(string? stored, string? provided)
|
||||
{
|
||||
Assert.False(SerialKnowledgeProof.Matches(stored, provided));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the single inverted-tick RowKey encoder every table-key builder now routes through.
|
||||
/// The encodings are PERSISTED — a change here reorders live tables against their existing
|
||||
/// rows, so the exact widths and boundary literals are asserted, not just round-trips.
|
||||
/// </summary>
|
||||
public class RowKeyCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void InvertedTicks_boundary_literals_are_pinned()
|
||||
{
|
||||
// rev(MaxValue) = 0 and rev(MinValue) = MaxValue.Ticks — exact strings, no derivation.
|
||||
Assert.Equal("0000000000000000000", RowKeyCodec.InvertedTicks(DateTime.MaxValue));
|
||||
Assert.Equal("3155378975999999999", RowKeyCodec.InvertedTicks(DateTime.MinValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvertedTicks_is_fixed_width_19_digits()
|
||||
{
|
||||
foreach (var utc in SampleInstants())
|
||||
{
|
||||
var key = RowKeyCodec.InvertedTicks(utc);
|
||||
Assert.Equal(19, key.Length);
|
||||
Assert.All(key, c => Assert.InRange(c, '0', '9'));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvertedTicksD20_is_the_same_value_one_zero_wider()
|
||||
{
|
||||
// UserActivity legacy width: identical rev value, padded to 20 digits. Existing D20
|
||||
// rows pin this — a D19 key would sort after every D20 key in that table.
|
||||
foreach (var utc in SampleInstants())
|
||||
{
|
||||
var d20 = RowKeyCodec.InvertedTicksD20(utc);
|
||||
Assert.Equal(20, d20.Length);
|
||||
Assert.Equal("0" + RowKeyCodec.InvertedTicks(utc), d20);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Later_instants_sort_lexicographically_first()
|
||||
{
|
||||
var earlier = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var later = earlier.AddSeconds(1);
|
||||
Assert.True(string.CompareOrdinal(RowKeyCodec.InvertedTicks(later), RowKeyCodec.InvertedTicks(earlier)) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_round_trips_encode_exactly()
|
||||
{
|
||||
foreach (var utc in SampleInstants())
|
||||
{
|
||||
Assert.True(RowKeyCodec.TryDecodeInvertedTicks(RowKeyCodec.InvertedTicks(utc), out var decoded));
|
||||
Assert.Equal(utc.Ticks, decoded.Ticks);
|
||||
Assert.Equal(DateTimeKind.Utc, decoded.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")] // empty
|
||||
[InlineData("123456789012345678x")] // non-digit
|
||||
[InlineData("!155378975999999999")] // prefix leaked into the digit run
|
||||
[InlineData("9999999999999999999")] // rev > MaxValue.Ticks → negative tick value
|
||||
public void Decode_rejects_foreign_input(string digits)
|
||||
{
|
||||
Assert.False(RowKeyCodec.TryDecodeInvertedTicks(digits, out _));
|
||||
}
|
||||
|
||||
private static DateTime[] SampleInstants() => new[]
|
||||
{
|
||||
new DateTime(2026, 8, 13, 18, 30, 12, DateTimeKind.Utc),
|
||||
new DateTime(2001, 2, 3, 4, 5, 6, DateTimeKind.Utc).AddTicks(7),
|
||||
DateTime.MinValue,
|
||||
DateTime.MaxValue,
|
||||
};
|
||||
}
|
||||
@@ -108,4 +108,35 @@ public class RuleEngineDataPathResolverTests
|
||||
|
||||
Assert.Null(RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryFlatLookup_ListValue_FlattensToJoinedString()
|
||||
{
|
||||
// Regression: app_tracking_summary.pendingNames is a List<object> after DataJson
|
||||
// deserialization. ToString() on it yields the .NET type name, which broke both
|
||||
// contains-matching and {{pendingNames}} interpolation in ANALYZE-APP-015 findings.
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["pendingNames"] = new List<object> { "Contoso VPN", "Fabrikam Agent" },
|
||||
};
|
||||
|
||||
var ok = RuleEngine.TryFlatLookup(data, "pendingNames", out var value);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("Contoso VPN, Fabrikam Agent", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDotPath_ListLeaf_FlattensToJoinedString()
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["summary"] = new Dictionary<string, object>
|
||||
{
|
||||
["names"] = new List<object> { "a", "b" },
|
||||
},
|
||||
};
|
||||
|
||||
Assert.Equal("a, b", RuleEngine.ResolveDotPath(data, "summary.names"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public class RuleEngineDetectionFailureTests
|
||||
// and there's a co-located esp_failure_settle_started with errorCode=0x87d1041c.
|
||||
// The rule must fire and the explanation must interpolate to the Office app.
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-APP-013");
|
||||
Assert.Equal("1.1.0", rule.Version);
|
||||
Assert.Equal("1.2.0", rule.Version);
|
||||
Assert.True(rule.Enabled);
|
||||
Assert.Equal("critical", rule.Severity);
|
||||
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// evaluateOn interim-trigger semantics (docs/rules/analyze-rule-triggers.md):
|
||||
/// trigger grammar + matching helpers, the engine's interim rule filter, the
|
||||
/// RuleResult update/finalize/resolve lifecycle replacing the permanent dedupe
|
||||
/// freeze, KO suppression on interim runs, and the ingest-side trigger registry.
|
||||
/// </summary>
|
||||
public class RuleEngineEvaluateOnTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
private const string ProbeEventType = "custom_probe_signal";
|
||||
|
||||
// ── Trigger grammar helpers ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EffectiveTriggers_absent_or_empty_defaults_to_enrollment_end()
|
||||
{
|
||||
Assert.Equal(new[] { "enrollment_end" }, AnalyzeRuleTriggers.EffectiveTriggers(new AnalyzeRule()));
|
||||
Assert.Equal(new[] { "enrollment_end" },
|
||||
AnalyzeRuleTriggers.EffectiveTriggers(new AnalyzeRule { EvaluateOn = new List<string>() }));
|
||||
|
||||
var rule = new AnalyzeRule();
|
||||
Assert.True(AnalyzeRuleTriggers.RunsAtEnrollmentEnd(rule));
|
||||
Assert.False(AnalyzeRuleTriggers.RunsAtWhitegloveSealed(rule));
|
||||
Assert.Empty(AnalyzeRuleTriggers.OnEventTypes(rule));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnEventTypes_parses_prefix_and_matching_is_case_insensitive()
|
||||
{
|
||||
var rule = new AnalyzeRule
|
||||
{
|
||||
EvaluateOn = new List<string> { "enrollment_end", "on_event:Hybrid_Login_Pending", "whiteglove_sealed" },
|
||||
};
|
||||
|
||||
Assert.Equal(new[] { "hybrid_login_pending" }, AnalyzeRuleTriggers.OnEventTypes(rule));
|
||||
Assert.True(AnalyzeRuleTriggers.RunsAtWhitegloveSealed(rule));
|
||||
Assert.True(AnalyzeRuleTriggers.MatchesOnEvent(rule, new[] { "HYBRID_LOGIN_PENDING" }));
|
||||
Assert.False(AnalyzeRuleTriggers.MatchesOnEvent(rule, new[] { "desktop_arrived" }));
|
||||
Assert.False(AnalyzeRuleTriggers.MatchesOnEvent(rule, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("enrollment_end", true)]
|
||||
[InlineData("whiteglove_sealed", true)]
|
||||
[InlineData("on_event:hybrid_login_pending", true)]
|
||||
[InlineData("on_event:x1_2", true)]
|
||||
[InlineData("on_event:", false)]
|
||||
[InlineData("on_event:Not-Snake", false)]
|
||||
[InlineData("phase_exit:AccountSetup", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsValidTrigger_enforces_the_grammar(string? trigger, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AnalyzeRuleTriggers.IsValidTrigger(trigger));
|
||||
}
|
||||
|
||||
// ── Engine: interim rule filter ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Interim_run_evaluates_only_rules_matching_the_trigger()
|
||||
{
|
||||
var matching = ProbeRule("ANALYZE-TEST-9101", evaluateOn: new List<string> { "enrollment_end", $"on_event:{ProbeEventType}" });
|
||||
var terminalOnly = ProbeRule("ANALYZE-TEST-9102", evaluateOn: null);
|
||||
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { matching, terminalOnly }, ProbeEvents());
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(
|
||||
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-TEST-9101", result.RuleId);
|
||||
Assert.True(result.IsInterim);
|
||||
Assert.NotNull(result.FirstDetectedAt);
|
||||
Assert.NotNull(result.LastEvaluatedAt);
|
||||
Assert.Equal("ANALYZE-TEST-9101", Assert.Single(outcome.EvaluatedRules).RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interim_run_with_no_matching_rules_skips_the_event_read()
|
||||
{
|
||||
var terminalOnly = ProbeRule("ANALYZE-TEST-9103", evaluateOn: null);
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { terminalOnly }, ProbeEvents());
|
||||
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(
|
||||
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
|
||||
|
||||
Assert.Empty(outcome.Results);
|
||||
Assert.Empty(outcome.EvaluatedRules);
|
||||
fixture.SessionRepo.Verify(
|
||||
s => s.GetSessionEventsStrictAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Whiteglove_sealed_run_matches_only_whiteglove_rules()
|
||||
{
|
||||
var wgRule = ProbeRule("ANALYZE-TEST-9104", evaluateOn: new List<string> { "enrollment_end", "whiteglove_sealed" });
|
||||
var onEventRule = ProbeRule("ANALYZE-TEST-9105", evaluateOn: new List<string> { $"on_event:{ProbeEventType}" });
|
||||
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { wgRule, onEventRule }, ProbeEvents());
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(
|
||||
TenantId, SessionId, AnalyzeRunContext.WhitegloveSealed());
|
||||
|
||||
Assert.Equal("ANALYZE-TEST-9104", Assert.Single(outcome.Results).RuleId);
|
||||
}
|
||||
|
||||
// ── Engine: KO suppression ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Interim_run_suppresses_the_KO_escalation()
|
||||
{
|
||||
var koRule = ProbeRule("ANALYZE-TEST-9106", evaluateOn: new List<string> { $"on_event:{ProbeEventType}" });
|
||||
koRule.MarkSessionAsFailedDefault = true;
|
||||
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { koRule }, ProbeEvents());
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(
|
||||
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
|
||||
|
||||
Assert.Single(outcome.Results);
|
||||
// TryMarkSessionFailedFromRuleAsync starts with GetSessionAsync — never reached on interim.
|
||||
fixture.SessionRepo.Verify(s => s.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_run_still_applies_the_KO_escalation()
|
||||
{
|
||||
var koRule = ProbeRule("ANALYZE-TEST-9107", evaluateOn: null);
|
||||
koRule.MarkSessionAsFailedDefault = true;
|
||||
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { koRule }, ProbeEvents());
|
||||
await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
|
||||
|
||||
fixture.SessionRepo.Verify(s => s.GetSessionAsync(TenantId, SessionId), Times.Once);
|
||||
}
|
||||
|
||||
// ── Engine: result lifecycle (update semantics) ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_run_skips_settled_final_rows_but_finalizes_interim_rows()
|
||||
{
|
||||
var settled = ProbeRule("ANALYZE-TEST-9108", evaluateOn: null);
|
||||
var interim = ProbeRule("ANALYZE-TEST-9109", evaluateOn: new List<string> { "enrollment_end", $"on_event:{ProbeEventType}" });
|
||||
|
||||
var firstDetected = new DateTime(2026, 8, 13, 10, 0, 0, DateTimeKind.Utc);
|
||||
var notified = new DateTime(2026, 8, 13, 10, 5, 0, DateTimeKind.Utc);
|
||||
var existing = new List<RuleResult>
|
||||
{
|
||||
StoredResult("ANALYZE-TEST-9108", isInterim: false),
|
||||
StoredResult("ANALYZE-TEST-9109", isInterim: true, firstDetectedAt: firstDetected, notifiedAt: notified, resultId: "stable-result-id"),
|
||||
};
|
||||
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { settled, interim }, ProbeEvents(), existing);
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
|
||||
|
||||
// Settled final row: classic dedupe — not re-evaluated, not in EvaluatedRules.
|
||||
Assert.DoesNotContain(outcome.EvaluatedRules, r => r.RuleId == "ANALYZE-TEST-9108");
|
||||
|
||||
// Interim row: finalized with preserved identity + notification marker.
|
||||
var finalized = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-TEST-9109", finalized.RuleId);
|
||||
Assert.False(finalized.IsInterim);
|
||||
Assert.Null(finalized.ResolvedAt);
|
||||
Assert.Equal(firstDetected, finalized.FirstDetectedAt);
|
||||
Assert.Equal(firstDetected, finalized.DetectedAt);
|
||||
Assert.Equal(notified, finalized.NotifiedAt);
|
||||
Assert.Equal("stable-result-id", finalized.ResultId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_run_resolves_interim_rows_that_no_longer_fire()
|
||||
{
|
||||
// The rule requires an event type the session does NOT contain → no fire.
|
||||
var interim = ProbeRule("ANALYZE-TEST-9110",
|
||||
evaluateOn: new List<string> { "enrollment_end", "on_event:some_other_signal" },
|
||||
conditionEventType: "some_other_signal");
|
||||
|
||||
var existing = new List<RuleResult> { StoredResult("ANALYZE-TEST-9110", isInterim: true) };
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { interim }, ProbeEvents(), existing);
|
||||
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
|
||||
|
||||
Assert.Empty(outcome.Results);
|
||||
var resolved = Assert.Single(outcome.ResolvedResults);
|
||||
Assert.Equal("ANALYZE-TEST-9110", resolved.RuleId);
|
||||
Assert.NotNull(resolved.ResolvedAt);
|
||||
Assert.False(resolved.IsInterim); // terminal settles the row
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Terminal_run_settles_a_row_an_interim_pass_already_resolved()
|
||||
{
|
||||
// Interim resolve keeps IsInterim=true (a later interim run may re-fire it);
|
||||
// the terminal pass must settle it to a final resolved row.
|
||||
var interim = ProbeRule("ANALYZE-TEST-9118",
|
||||
evaluateOn: new List<string> { "enrollment_end", "on_event:some_other_signal" },
|
||||
conditionEventType: "some_other_signal");
|
||||
|
||||
var alreadyResolved = StoredResult("ANALYZE-TEST-9118", isInterim: true);
|
||||
alreadyResolved.ResolvedAt = new DateTime(2026, 8, 13, 13, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { interim }, ProbeEvents(),
|
||||
new List<RuleResult> { alreadyResolved });
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, AnalyzeRunContext.Terminal());
|
||||
|
||||
var settled = Assert.Single(outcome.ResolvedResults);
|
||||
Assert.False(settled.IsInterim);
|
||||
Assert.Equal(alreadyResolved.ResolvedAt, settled.ResolvedAt); // original resolve time kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interim_refresh_keeps_interim_flag_and_preserves_notification_marker()
|
||||
{
|
||||
var rule = ProbeRule("ANALYZE-TEST-9111", evaluateOn: new List<string> { $"on_event:{ProbeEventType}" });
|
||||
var notified = new DateTime(2026, 8, 13, 11, 0, 0, DateTimeKind.Utc);
|
||||
var existing = new List<RuleResult>
|
||||
{
|
||||
StoredResult("ANALYZE-TEST-9111", isInterim: true, notifiedAt: notified),
|
||||
};
|
||||
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { rule }, ProbeEvents(), existing);
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(
|
||||
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
|
||||
|
||||
var refreshed = Assert.Single(outcome.Results);
|
||||
Assert.True(refreshed.IsInterim);
|
||||
Assert.Equal(notified, refreshed.NotifiedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interim_run_never_reopens_a_settled_final_row()
|
||||
{
|
||||
var rule = ProbeRule("ANALYZE-TEST-9112", evaluateOn: new List<string> { $"on_event:{ProbeEventType}" });
|
||||
var existing = new List<RuleResult> { StoredResult("ANALYZE-TEST-9112", isInterim: false) };
|
||||
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { rule }, ProbeEvents(), existing);
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(
|
||||
TenantId, SessionId, AnalyzeRunContext.InterimTrigger(new[] { ProbeEventType }));
|
||||
|
||||
Assert.Empty(outcome.Results);
|
||||
Assert.Empty(outcome.ResolvedResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reanalyze_preserves_notification_marker_and_resolves_stale_rows()
|
||||
{
|
||||
// fires: probe rule matches; stale: rule over an absent event type with a stored FINAL row.
|
||||
var firing = ProbeRule("ANALYZE-TEST-9113", evaluateOn: null);
|
||||
var stale = ProbeRule("ANALYZE-TEST-9114", evaluateOn: null, conditionEventType: "some_other_signal");
|
||||
|
||||
var notified = new DateTime(2026, 8, 13, 12, 0, 0, DateTimeKind.Utc);
|
||||
var existing = new List<RuleResult>
|
||||
{
|
||||
StoredResult("ANALYZE-TEST-9113", isInterim: false, notifiedAt: notified),
|
||||
StoredResult("ANALYZE-TEST-9114", isInterim: false),
|
||||
};
|
||||
|
||||
var fixture = CreateFixture(new List<AnalyzeRule> { firing, stale }, ProbeEvents(), existing);
|
||||
var outcome = await fixture.Engine.AnalyzeSessionAsync(TenantId, SessionId, reanalyze: true);
|
||||
|
||||
var refired = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-TEST-9113", refired.RuleId);
|
||||
Assert.Equal(notified, refired.NotifiedAt);
|
||||
|
||||
var resolved = Assert.Single(outcome.ResolvedResults);
|
||||
Assert.Equal("ANALYZE-TEST-9114", resolved.RuleId);
|
||||
Assert.NotNull(resolved.ResolvedAt);
|
||||
}
|
||||
|
||||
// ── Ingest trigger registry ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Registry_aggregates_on_event_types_and_whiteglove_flag()
|
||||
{
|
||||
var rules = new List<AnalyzeRule>
|
||||
{
|
||||
ProbeRule("ANALYZE-TEST-9115", evaluateOn: new List<string> { "enrollment_end", $"on_event:{ProbeEventType}" }),
|
||||
ProbeRule("ANALYZE-TEST-9116", evaluateOn: new List<string> { "whiteglove_sealed" }),
|
||||
ProbeRule("ANALYZE-TEST-9117", evaluateOn: null),
|
||||
};
|
||||
var fixture = CreateFixture(rules, ProbeEvents());
|
||||
|
||||
var registry = new AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry(
|
||||
fixture.RuleService, NullLogger<AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry>.Instance);
|
||||
var triggers = await registry.GetAsync(TenantId);
|
||||
|
||||
Assert.Contains(ProbeEventType, triggers.OnEventTypes);
|
||||
Assert.Single(triggers.OnEventTypes);
|
||||
Assert.True(triggers.HasWhitegloveSealedRules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registry_is_fail_soft_on_rule_load_errors()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync(It.IsAny<string>()))
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
|
||||
var ruleService = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var registry = new AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry(
|
||||
ruleService, NullLogger<AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry>.Instance);
|
||||
|
||||
var triggers = await registry.GetAsync(TenantId);
|
||||
Assert.Empty(triggers.OnEventTypes);
|
||||
Assert.False(triggers.HasWhitegloveSealedRules);
|
||||
}
|
||||
|
||||
// ── Built-in catalog wiring ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuiltIn_ID004_carries_the_hybrid_interim_trigger()
|
||||
{
|
||||
// Pins the JSON → model deserialization of evaluateOn end-to-end through the
|
||||
// embedded catalog (rules/dist/analyze-rules.json).
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-ID-004");
|
||||
Assert.Equal(new List<string> { "enrollment_end", "on_event:hybrid_login_pending" }, rule.EvaluateOn);
|
||||
// Interim safety: the rule must not fire on a single overdue-login signal — the
|
||||
// threshold has to require the repetition factors (base 60 + count>=2 → 80).
|
||||
Assert.True(rule.ConfidenceThreshold > rule.BaseConfidence,
|
||||
"ANALYZE-ID-004 must be repetition-gated to stay interim-safe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuiltIn_catalog_evaluateOn_triggers_are_all_grammatical_and_unblocked()
|
||||
{
|
||||
foreach (var rule in BuiltInAnalyzeRules.GetAll())
|
||||
{
|
||||
if (rule.EvaluateOn is not { Count: > 0 })
|
||||
continue;
|
||||
foreach (var trigger in rule.EvaluateOn)
|
||||
{
|
||||
Assert.True(AnalyzeRuleTriggers.IsValidTrigger(trigger),
|
||||
$"Rule {rule.RuleId} ships invalid evaluateOn trigger '{trigger}'");
|
||||
|
||||
if (trigger.StartsWith("on_event:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var eventType = trigger.Substring("on_event:".Length);
|
||||
Assert.False(AnalyzeRuleTriggers.IsBlockedOnEventType(eventType),
|
||||
$"Rule {rule.RuleId} ships hard-blocked evaluateOn trigger '{trigger}' — the runtime would silently drop it");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hard-blocked high-frequency trigger types ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Blocked_on_event_types_are_dropped_by_the_runtime_matcher()
|
||||
{
|
||||
// Defense in depth: even a persisted row carrying a blocked trigger is inert.
|
||||
var rule = new AnalyzeRule
|
||||
{
|
||||
EvaluateOn = new List<string> { "on_event:performance_snapshot", $"on_event:{ProbeEventType}" },
|
||||
};
|
||||
Assert.Equal(new[] { ProbeEventType }, AnalyzeRuleTriggers.OnEventTypes(rule));
|
||||
Assert.False(AnalyzeRuleTriggers.MatchesOnEvent(rule, new[] { "performance_snapshot" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_rule_with_blocked_on_event_trigger_is_rejected()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync(It.IsAny<string>())).ReturnsAsync(new List<AnalyzeRule>());
|
||||
repo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
var rule = ProbeRule("ANALYZE-CUSTOM-901",
|
||||
evaluateOn: new List<string> { "enrollment_end", "on_event:performance_snapshot" });
|
||||
rule.IsBuiltIn = false;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CreateRuleAsync(TenantId, rule));
|
||||
Assert.Contains("blocked", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("performance_snapshot", ex.Message);
|
||||
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_set_matches_the_guardrails_json_mirror()
|
||||
{
|
||||
// Enforcement is code-only (AnalyzeRuleTriggers.BlockedOnEventTypes); the JSON mirror
|
||||
// feeds the MCP validate_rule pre-flight. This parity test keeps the two from drifting —
|
||||
// an addition on either side fails here until the other side follows.
|
||||
var guardrailsPath = Path.Combine(FindRepoRoot(), "rules", "guardrails.json");
|
||||
var json = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(guardrailsPath));
|
||||
var mirror = json["blockedInterimTriggerEventTypes"]!.ToObject<string[]>()!;
|
||||
|
||||
Assert.Equal(
|
||||
AnalyzeRuleTriggers.BlockedOnEventTypes.OrderBy(t => t, StringComparer.Ordinal),
|
||||
mirror.OrderBy(t => t, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Fixture ─────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed record Fixture(
|
||||
RuleEngine Engine,
|
||||
AnalyzeRuleService RuleService,
|
||||
Mock<IRuleRepository> RuleRepo,
|
||||
Mock<ISessionRepository> SessionRepo);
|
||||
|
||||
/// <summary>
|
||||
/// Global partition mocked EMPTY (seed writes go to a black-hole mock), rules under test
|
||||
/// live in the TENANT partition as custom rules — that keeps the embedded-catalog sunset
|
||||
/// filter and the 43 shipped rules out of the picture entirely.
|
||||
/// </summary>
|
||||
private static Fixture CreateFixture(
|
||||
List<AnalyzeRule> tenantRules, List<EnrollmentEvent> events, List<RuleResult>? existingResults = null)
|
||||
{
|
||||
foreach (var rule in tenantRules)
|
||||
{
|
||||
rule.IsBuiltIn = false;
|
||||
rule.IsCommunity = false;
|
||||
}
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(tenantRules);
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(TenantId, SessionId))
|
||||
.ReturnsAsync(existingResults ?? new List<RuleResult>());
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()))
|
||||
.ReturnsAsync(events);
|
||||
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object,
|
||||
NullLogger<RuleEngineEvaluateOnTests>.Instance);
|
||||
|
||||
return new Fixture(engine, ruleService, ruleRepo, sessionRepo);
|
||||
}
|
||||
|
||||
/// <summary>Minimal rule that fires when an event of <paramref name="conditionEventType"/> exists.</summary>
|
||||
private static AnalyzeRule ProbeRule(
|
||||
string ruleId, List<string>? evaluateOn, string conditionEventType = ProbeEventType)
|
||||
{
|
||||
return new AnalyzeRule
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = $"Probe {ruleId}",
|
||||
Severity = "warning",
|
||||
Category = "device",
|
||||
Enabled = true,
|
||||
EvaluateOn = evaluateOn,
|
||||
BaseConfidence = 60,
|
||||
ConfidenceThreshold = 50,
|
||||
Explanation = "probe",
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Signal = "probe",
|
||||
Source = "event_type",
|
||||
EventType = conditionEventType,
|
||||
Operator = "exists",
|
||||
Value = string.Empty,
|
||||
Required = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static List<EnrollmentEvent> ProbeEvents() => new()
|
||||
{
|
||||
new EnrollmentEvent
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = ProbeEventType,
|
||||
Timestamp = new DateTime(2026, 8, 13, 9, 0, 0, DateTimeKind.Utc),
|
||||
Message = "probe signal",
|
||||
},
|
||||
};
|
||||
|
||||
private static RuleResult StoredResult(
|
||||
string ruleId, bool isInterim, DateTime? firstDetectedAt = null, DateTime? notifiedAt = null,
|
||||
string? resultId = null)
|
||||
{
|
||||
return new RuleResult
|
||||
{
|
||||
ResultId = resultId ?? Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
RuleId = ruleId,
|
||||
RuleTitle = $"Probe {ruleId}",
|
||||
Severity = "warning",
|
||||
Category = "device",
|
||||
ConfidenceScore = 60,
|
||||
Explanation = "probe",
|
||||
DetectedAt = firstDetectedAt ?? new DateTime(2026, 8, 13, 10, 0, 0, DateTimeKind.Utc),
|
||||
FirstDetectedAt = firstDetectedAt,
|
||||
IsInterim = isInterim,
|
||||
NotifiedAt = notifiedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Window parsing for the fleet-context deep-link endpoint
|
||||
/// (<see cref="RuleHitSessionsFunction"/>, GET metrics/rule-hit-sessions).
|
||||
/// Default 14 days; clamped to 1..90 because RuleResults follow the session
|
||||
/// retention cascade — a larger window could never widen the hit set.
|
||||
/// </summary>
|
||||
public class RuleHitSessionsFunctionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, 14)] // not supplied → default window
|
||||
[InlineData("", 14)] // empty → default
|
||||
[InlineData("garbage", 14)] // non-numeric → default
|
||||
[InlineData("14", 14)]
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("0", 1)] // below range → clamped up
|
||||
[InlineData("-5", 1)]
|
||||
[InlineData("90", 90)]
|
||||
[InlineData("365", 90)] // above range → clamped down
|
||||
public void ParseDays_Defaults_And_Clamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, RuleHitSessionsFunction.ParseDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxSessionIds_Matches_Repository_Default_Cap()
|
||||
{
|
||||
// The truncated flag compares against this constant; it must stay in sync
|
||||
// with the GetRuleHitSessionIdsAsync default cap (2000).
|
||||
Assert.Equal(2000, RuleHitSessionsFunction.MaxSessionIds);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,49 @@ public class SafeWipeServiceTests
|
||||
harness.Sut.WipeByTenantIdPropertyAsync("FakeTable", TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowKey_AbortsWhenForeignRowKeyReturned()
|
||||
{
|
||||
// Variant D anchors on the RowKey (UsageMetrics: PK=date, RK=tenantId). The "global"
|
||||
// aggregate row shares partitions with tenant rows — verify must reject anything whose
|
||||
// RowKey is not exactly the tenant id.
|
||||
var harness = new Harness(
|
||||
queriedRows: new[]
|
||||
{
|
||||
new TableEntity("2026-08-01", TenantId),
|
||||
new TableEntity("2026-08-01", "global"), // <-- must never be wiped
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<SafeWipeVerificationException>(() =>
|
||||
harness.Sut.WipeByRowKeyAsync("FakeTable", TenantId));
|
||||
|
||||
Assert.Empty(harness.SubmittedBatches);
|
||||
Assert.Empty(harness.PerRowDeletes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowKey_DeletesAcrossPartitionsWithETagAll()
|
||||
{
|
||||
// One row per date partition — batches are grouped by PK, so 3 partitions = 3 batches.
|
||||
var harness = new Harness(
|
||||
queriedRows: new[]
|
||||
{
|
||||
new TableEntity("2026-08-01", TenantId),
|
||||
new TableEntity("2026-08-02", TenantId),
|
||||
new TableEntity("2026-08-03", TenantId),
|
||||
});
|
||||
|
||||
var deleted = await harness.Sut.WipeByRowKeyAsync("FakeTable", TenantId);
|
||||
|
||||
Assert.Equal(3, deleted);
|
||||
Assert.Equal(3, harness.SubmittedBatches.Count);
|
||||
foreach (var action in harness.SubmittedBatches.SelectMany(b => b))
|
||||
{
|
||||
Assert.Equal(TableTransactionActionType.Delete, action.ActionType);
|
||||
Assert.Equal(ETag.All, action.Entity.ETag);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactPartition_DeletesEveryRowWithETagAll()
|
||||
{
|
||||
@@ -175,6 +218,8 @@ public class SafeWipeServiceTests
|
||||
() => harness.Sut.WipeByDiscriminatorAndTenantPropertyAsync("t", "CodeLookup", "not-a-guid"));
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => harness.Sut.WipeByTenantIdPropertyAsync("t", "not-a-guid"));
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => harness.Sut.WipeByRowKeyAsync("t", "not-a-guid"));
|
||||
}
|
||||
|
||||
// ── Harness ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -92,6 +92,87 @@ public class SessionAnnotationFunctionsTests
|
||||
Assert.Equal(3, visible.Count);
|
||||
}
|
||||
|
||||
// ── GET writableLanes: server-computed write matrix ─────────────────────
|
||||
// The web renders lanes writable exactly per this list (it holds no matrix copy),
|
||||
// so the GET must derive it from the SAME function the PUT re-gates with,
|
||||
// including the own-tenant binding of the tenant-role lanes.
|
||||
|
||||
private static GetSessionAnnotationsFunction BuildGetFunction()
|
||||
{
|
||||
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
|
||||
annotationRepo.Setup(r => r.GetForSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new List<SessionAnnotation>());
|
||||
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
|
||||
sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
|
||||
return new GetSessionAnnotationsFunction(
|
||||
NullLogger<GetSessionAnnotationsFunction>.Instance,
|
||||
annotationRepo.Object, sessionRepo.Object);
|
||||
}
|
||||
|
||||
private static async Task<string[]> RunGetAndReadWritableLanes(RequestContext ctx)
|
||||
{
|
||||
var function = BuildGetFunction();
|
||||
var req = BuildRequest(Principal(("tid", ctx.TenantId)), ctx, new { });
|
||||
var res = await function.Run(req, SessionId);
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
|
||||
res.Body.Position = 0;
|
||||
using var reader = new StreamReader(res.Body);
|
||||
var json = JsonConvert.DeserializeAnonymousType(
|
||||
await reader.ReadToEndAsync(), new { writableLanes = Array.Empty<string>() });
|
||||
return json!.writableLanes;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_writableLanes_tenant_admin_own_tenant()
|
||||
{
|
||||
var lanes = await RunGetAndReadWritableLanes(TenantAdminContext());
|
||||
Assert.Equal(new[] { AnnotationLanes.Operator, AnnotationLanes.TenantAdmin }, lanes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_writableLanes_operator_own_tenant()
|
||||
{
|
||||
var lanes = await RunGetAndReadWritableLanes(new RequestContext
|
||||
{
|
||||
TenantId = TenantId,
|
||||
TargetTenantId = TenantId,
|
||||
UserPrincipalName = "op@contoso.com",
|
||||
UserRole = Constants.TenantRoles.Operator,
|
||||
});
|
||||
Assert.Equal(new[] { AnnotationLanes.Operator }, lanes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_writableLanes_ga_on_foreign_session_gets_only_globaladmin_lane()
|
||||
{
|
||||
// GA is Admin of their HOME tenant; the session resolves to a foreign tenant —
|
||||
// the home-tenant role must not leak, exactly like the PUT's 403 test above.
|
||||
var lanes = await RunGetAndReadWritableLanes(new RequestContext
|
||||
{
|
||||
TenantId = "99999999-9999-9999-9999-999999999999",
|
||||
TargetTenantId = "99999999-9999-9999-9999-999999999999",
|
||||
UserPrincipalName = "ga@fabrikam.com",
|
||||
IsGlobalAdmin = true,
|
||||
IsTenantAdmin = true,
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
});
|
||||
Assert.Equal(new[] { AnnotationLanes.GlobalAdmin }, lanes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_writableLanes_global_reader_gets_none()
|
||||
{
|
||||
var lanes = await RunGetAndReadWritableLanes(new RequestContext
|
||||
{
|
||||
TenantId = "99999999-9999-9999-9999-999999999999",
|
||||
TargetTenantId = "99999999-9999-9999-9999-999999999999",
|
||||
UserPrincipalName = "reader@fabrikam.com",
|
||||
IsGlobalReader = true,
|
||||
});
|
||||
Assert.Empty(lanes);
|
||||
}
|
||||
|
||||
// ── global list: GA-lane exclusion follows global scope ─────────────────
|
||||
// The route is GlobalReadOrAdmin, but the delegated ("MSP") read rescue admits a
|
||||
// delegated caller on its managed ?tenantId= path — the platform-internal
|
||||
@@ -210,7 +291,7 @@ public class SessionAnnotationFunctionsTests
|
||||
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
|
||||
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId });
|
||||
sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
|
||||
sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
|
||||
@@ -102,7 +102,7 @@ public class SessionEventsPaginationTests
|
||||
// Regression: previously the function called BuildNextLink with the raw
|
||||
// ?tenantId= query param, which is null when GA reads /api/sessions/{id}/events
|
||||
// without explicitly passing tenantId. Page 1 then bound the token to the
|
||||
// resolved tenant (via FindSessionTenantIdAsync), but the nextLink omitted
|
||||
// resolved tenant (via ResolveSessionTenantIdAsync), but the nextLink omitted
|
||||
// it — so page 2 validated against the GA's JWT tenant and the token was
|
||||
// rejected as cross_tenant. Fix: nextLink now carries the resolved tenantId
|
||||
// verbatim, and the function honours ?tenantId= on follow-up pages for GA.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fragilitätsaudit P6.2: server-side free-text search (<c>q=</c>) for the dashboard search
|
||||
/// box, replacing the client's unbounded loadAll() sweep. The predicate must mirror the web
|
||||
/// dashboard's client-side searchable field set (minus derived-only tokens) — see the doc on
|
||||
/// <c>TableStorageService.MatchesFreeText</c>.
|
||||
/// </summary>
|
||||
public class SessionFreeTextSearchTests
|
||||
{
|
||||
private static SessionSummary Sample() => new()
|
||||
{
|
||||
SessionId = "22222222-2222-2222-2222-222222222222",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
DeviceName = "DESKTOP-A1B2C3",
|
||||
SerialNumber = "5CG1234XYZ",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "EliteBook 840",
|
||||
Status = SessionStatus.Failed,
|
||||
GeoCountry = "DE",
|
||||
GeoRegion = "Hessen",
|
||||
GeoCity = "Frankfurt",
|
||||
AgentVersion = "2.0.1400",
|
||||
OsName = "Windows 11",
|
||||
OsBuild = "26100.1000",
|
||||
OsDisplayVersion = "24H2",
|
||||
OsEdition = "Enterprise",
|
||||
OsLanguage = "de-DE",
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("a1b2")] // device name, case-insensitive substring
|
||||
[InlineData("5cg1234")] // serial
|
||||
[InlineData("contoso")] // manufacturer
|
||||
[InlineData("elitebook")] // model
|
||||
[InlineData("failed")] // status text
|
||||
[InlineData("2222-2222")] // sessionId fragment
|
||||
[InlineData("frankfurt")] // geo city
|
||||
[InlineData("2.0.1400")] // agent version
|
||||
[InlineData("26100")] // os build
|
||||
[InlineData("24h2")] // os display version
|
||||
public void MatchesFreeText_FindsSubstringAcrossDashboardFields(string q)
|
||||
{
|
||||
Assert.True(TableStorageService.MatchesFreeText(Sample(), q));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tailspin")]
|
||||
[InlineData("succeeded")]
|
||||
[InlineData("99999")]
|
||||
public void MatchesFreeText_RejectsNonMatches(string q)
|
||||
{
|
||||
Assert.False(TableStorageService.MatchesFreeText(Sample(), q));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesFreeText_EmptyQuery_MatchesEverything()
|
||||
{
|
||||
Assert.True(TableStorageService.MatchesFreeText(Sample(), null));
|
||||
Assert.True(TableStorageService.MatchesFreeText(Sample(), string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_BindsQ_SoContinuationCannotCrossQueries()
|
||||
{
|
||||
var withQ = new SessionSearchFilter { Q = "desktop" };
|
||||
var otherQ = new SessionSearchFilter { Q = "laptop" };
|
||||
var noQ = new SessionSearchFilter();
|
||||
const string caller = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
var fp1 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, withQ);
|
||||
var fp2 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, otherQ);
|
||||
var fp3 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, noQ);
|
||||
|
||||
Assert.NotEqual(fp1, fp2);
|
||||
Assert.NotEqual(fp1, fp3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fragilitätsaudit P5.2: <see cref="SessionIndexFieldManifest"/> is the declared SessionsIndex
|
||||
/// column set; builder, reader, and merge guard are all pinned against it here.
|
||||
/// <list type="bullet">
|
||||
/// <item>Builder ↔ manifest bidirectional: a field added to <c>BuildSessionIndexEntity</c> but
|
||||
/// not the manifest fails, and vice versa.</item>
|
||||
/// <item>Reader coverage: every manifest field must influence <c>MapToSessionSummary</c> output —
|
||||
/// a projected-but-never-served column is dead weight; a served-but-not-projected one is
|
||||
/// the drift bug.</item>
|
||||
/// <item>Full-rebuild upsert is Replace-mode, so conditional fields the primary cleared do not
|
||||
/// survive as stale index columns.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class SessionIndexFieldManifestTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
private const string IndexRowKey = "2516000000000000000_" + SessionId;
|
||||
|
||||
private static readonly DateTime StartedAt = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly string[] SystemKeys = { "PartitionKey", "RowKey", "Timestamp", "odata.etag" };
|
||||
|
||||
/// <summary>
|
||||
/// One distinctly non-default sample value per manifest field. A new manifest field without
|
||||
/// an entry here fails the tests with a clear message — extend this map alongside the
|
||||
/// manifest. Values are typed the way the WRITER stores them (wire form).
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, object> SampleValues = new(StringComparer.Ordinal)
|
||||
{
|
||||
// SessionId sample deliberately differs from the RowKey suffix so the reader test can
|
||||
// detect that the stored property (not the RowKey fallback) is being served.
|
||||
["SessionId"] = "33333333-3333-3333-3333-333333333333",
|
||||
["SerialNumber"] = "SN-0001",
|
||||
["DeviceName"] = "PC-0001",
|
||||
["Manufacturer"] = "Contoso",
|
||||
["Model"] = "Elite-1",
|
||||
["StartedAt"] = new DateTimeOffset(StartedAt),
|
||||
["Status"] = "Failed",
|
||||
["CurrentPhase"] = 3,
|
||||
["CurrentPhaseDetail"] = "phase-detail-1",
|
||||
["EventCount"] = 42,
|
||||
["EnrollmentType"] = "v2",
|
||||
["IsPreProvisioned"] = true,
|
||||
["IsHybridJoin"] = true,
|
||||
["IsUserDriven"] = true,
|
||||
["IsSelfDeployingProfile"] = true,
|
||||
["IsCloudPc"] = true,
|
||||
["AgentVersion"] = "2.0.1400",
|
||||
["ImeAgentVersion"] = "1.2.3.4",
|
||||
["OsName"] = "Windows 11",
|
||||
["OsBuild"] = "26100.1000",
|
||||
["OsDisplayVersion"] = "24H2",
|
||||
["OsEdition"] = "Enterprise",
|
||||
["OsLanguage"] = "de-DE",
|
||||
["GeoCountry"] = "DE",
|
||||
["GeoRegion"] = "HE",
|
||||
["GeoCity"] = "Frankfurt",
|
||||
["GeoLoc"] = "50.11,8.68",
|
||||
["PlatformScriptCount"] = 2,
|
||||
["RemediationScriptCount"] = 3,
|
||||
["RebootCount"] = 4,
|
||||
["ExcessiveEventsAlerted"] = true,
|
||||
["ExcessiveEventsAutoActioned"] = true,
|
||||
["CompletedAt"] = new DateTimeOffset(StartedAt.AddMinutes(45)),
|
||||
["FailureReason"] = "esp timeout",
|
||||
["FailureSource"] = "ime",
|
||||
["ReconcileReason"] = "late completion",
|
||||
["EspSoftFailure"] = true,
|
||||
["CompletionSource"] = "esp_exit",
|
||||
["ValidatedBy"] = "AutopilotV1",
|
||||
["FailureSnapshotJson"] = "{\"x\":1}",
|
||||
["AdminMarkedAction"] = "failed",
|
||||
["DurationSeconds"] = 360,
|
||||
["DiagnosticsBlobName"] = "diag-0001.zip",
|
||||
["DiagnosticsBlobDestination"] = "Hosted",
|
||||
["LastEventAt"] = new DateTimeOffset(StartedAt.AddMinutes(44)),
|
||||
["ResumedAt"] = new DateTimeOffset(StartedAt.AddMinutes(10)),
|
||||
["StalledAt"] = new DateTimeOffset(StartedAt.AddMinutes(20)),
|
||||
["AvgApiLatencyMs"] = 513.5,
|
||||
["ApiRequestCount"] = 100,
|
||||
["ConnectionType"] = "Ethernet",
|
||||
};
|
||||
|
||||
// ── Manifest shape ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Manifest_IsInternallyConsistent()
|
||||
{
|
||||
Assert.Empty(SessionIndexFieldManifest.AlwaysProjected
|
||||
.Intersect(SessionIndexFieldManifest.ConditionallyProjected, StringComparer.Ordinal));
|
||||
Assert.Equal(SessionIndexFieldManifest.All.Length,
|
||||
SessionIndexFieldManifest.All.Distinct(StringComparer.Ordinal).Count());
|
||||
Assert.Empty(SessionIndexFieldManifest.PrimaryOnly
|
||||
.Intersect(SessionIndexFieldManifest.All, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryManifestField_HasASampleValueInThisTest()
|
||||
{
|
||||
var missing = SessionIndexFieldManifest.All
|
||||
.Where(f => !SampleValues.ContainsKey(f))
|
||||
.ToList();
|
||||
Assert.True(missing.Count == 0,
|
||||
"New manifest field(s) without a sample value in SessionIndexFieldManifestTests.SampleValues:\n - "
|
||||
+ string.Join("\n - ", missing));
|
||||
}
|
||||
|
||||
// ── Builder ↔ manifest (bidirectional) ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FullyPopulatedRow_BuilderOutputMatchesManifestExactly()
|
||||
{
|
||||
var idx = TableStorageService.BuildSessionIndexEntity(FullSessionRow(), IndexRowKey, StartedAt);
|
||||
var dataKeys = DataKeys(idx);
|
||||
|
||||
var missingFromBuilder = SessionIndexFieldManifest.All.Except(dataKeys, StringComparer.Ordinal).ToList();
|
||||
var missingFromManifest = dataKeys.Except(SessionIndexFieldManifest.All, StringComparer.Ordinal).ToList();
|
||||
|
||||
Assert.True(missingFromBuilder.Count == 0,
|
||||
"Manifest fields BuildSessionIndexEntity did not project from a fully-populated row:\n - "
|
||||
+ string.Join("\n - ", missingFromBuilder));
|
||||
Assert.True(missingFromManifest.Count == 0,
|
||||
"BuildSessionIndexEntity projects fields missing from SessionIndexFieldManifest:\n - "
|
||||
+ string.Join("\n - ", missingFromManifest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinimalRow_BuilderOutputMatchesAlwaysProjectedExactly()
|
||||
{
|
||||
var minimal = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(StartedAt),
|
||||
};
|
||||
|
||||
var idx = TableStorageService.BuildSessionIndexEntity(minimal, IndexRowKey, StartedAt);
|
||||
var dataKeys = DataKeys(idx);
|
||||
|
||||
var missing = SessionIndexFieldManifest.AlwaysProjected.Except(dataKeys, StringComparer.Ordinal).ToList();
|
||||
var unexpected = dataKeys.Except(SessionIndexFieldManifest.AlwaysProjected, StringComparer.Ordinal).ToList();
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
"AlwaysProjected fields absent from a minimal-row rebuild (default missing in builder):\n - "
|
||||
+ string.Join("\n - ", missing));
|
||||
Assert.True(unexpected.Count == 0,
|
||||
"Fields written for a minimal row although classified ConditionallyProjected (or unlisted):\n - "
|
||||
+ string.Join("\n - ", unexpected));
|
||||
}
|
||||
|
||||
// ── Reader coverage ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EveryManifestField_InfluencesMappedSessionSummary()
|
||||
{
|
||||
var sut = NewMapperService();
|
||||
var baseline = Serialize(sut.MapIndexEntityToSessionSummary(MinimalIndexEntity()));
|
||||
|
||||
var silent = new List<string>();
|
||||
foreach (var field in SessionIndexFieldManifest.All)
|
||||
{
|
||||
var entity = MinimalIndexEntity();
|
||||
entity[field] = SampleValues[field];
|
||||
var mapped = Serialize(sut.MapIndexEntityToSessionSummary(entity));
|
||||
if (mapped == baseline)
|
||||
silent.Add(field);
|
||||
}
|
||||
|
||||
Assert.True(silent.Count == 0,
|
||||
"Manifest fields whose value does NOT surface in MapToSessionSummary — either the reader "
|
||||
+ "ignores the column (dead projection) or the sample value needs adjusting:\n - "
|
||||
+ string.Join("\n - ", silent));
|
||||
}
|
||||
|
||||
// ── Merge guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FindNonManifestKeys_FlagsOnlyForeignDataKeys()
|
||||
{
|
||||
var merge = new TableEntity(TenantId, IndexRowKey)
|
||||
{
|
||||
["Status"] = "Failed",
|
||||
["RebootCount"] = 2,
|
||||
["BrandNewField"] = "x",
|
||||
};
|
||||
|
||||
var offenders = SessionIndexFieldManifest.FindNonManifestKeys(merge);
|
||||
|
||||
Assert.Equal(new[] { "BrandNewField" }, offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindNonManifestKeys_EmptyForPureManifestMerge()
|
||||
{
|
||||
var merge = new TableEntity(TenantId, IndexRowKey)
|
||||
{
|
||||
["EventCount"] = 7,
|
||||
["LastEventAt"] = new DateTimeOffset(StartedAt),
|
||||
};
|
||||
|
||||
Assert.Empty(SessionIndexFieldManifest.FindNonManifestKeys(merge));
|
||||
}
|
||||
|
||||
// ── Full-rebuild upsert semantics ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertSessionIndex_UsesReplaceMode()
|
||||
{
|
||||
// Replace (not merge) is what heals stale conditional columns after the primary
|
||||
// cleared them — merge-mode upsert cannot remove a column.
|
||||
var harness = new UpsertHarness();
|
||||
|
||||
await harness.Sut.UpsertSessionIndexAsync(FullSessionRow(), StartedAt);
|
||||
|
||||
harness.Index.Verify(t => t.UpsertEntityAsync(
|
||||
It.Is<TableEntity>(e => e.RowKey.EndsWith("_" + SessionId, StringComparison.Ordinal)),
|
||||
TableUpdateMode.Replace,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
// IndexRowKey written back onto the Sessions row (merge).
|
||||
harness.Sessions.Verify(t => t.UpdateEntityAsync(
|
||||
It.Is<ITableEntity>(e => e.PartitionKey == TenantId && e.RowKey == SessionId),
|
||||
It.IsAny<ETag>(),
|
||||
TableUpdateMode.Merge,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertSessionIndex_DeletesStaleRowKey_WhenStartedAtShifted()
|
||||
{
|
||||
var staleRowKey = "9999999999999999999_" + SessionId;
|
||||
var row = FullSessionRow();
|
||||
row["IndexRowKey"] = staleRowKey;
|
||||
|
||||
var harness = new UpsertHarness();
|
||||
|
||||
await harness.Sut.UpsertSessionIndexAsync(row, StartedAt);
|
||||
|
||||
harness.Index.Verify(t => t.DeleteEntityAsync(
|
||||
TenantId, staleRowKey, It.IsAny<ETag>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A Sessions row carrying every known field. Deliberately built from SampleValues (the
|
||||
/// test-local list), NOT from the manifest — deriving the input from the manifest would
|
||||
/// make the bidirectional check circular: a field dropped from the manifest would also
|
||||
/// vanish from the input row and the drift would pass unnoticed.
|
||||
/// </summary>
|
||||
private static TableEntity FullSessionRow()
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId);
|
||||
foreach (var (field, value) in SampleValues)
|
||||
{
|
||||
// SessionId comes from the RowKey; the builder never reads a SessionId property.
|
||||
if (field == "SessionId")
|
||||
continue;
|
||||
row[field] = value;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static TableEntity MinimalIndexEntity() => new(TenantId, IndexRowKey);
|
||||
|
||||
private static List<string> DataKeys(TableEntity entity)
|
||||
=> entity.Keys.Except(SystemKeys, StringComparer.Ordinal).ToList();
|
||||
|
||||
private static string Serialize(object o) => JsonSerializer.Serialize(o);
|
||||
|
||||
private static TableStorageService NewMapperService()
|
||||
=> new(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance);
|
||||
|
||||
private sealed class UpsertHarness
|
||||
{
|
||||
public Mock<TableClient> Sessions { get; } = new();
|
||||
public Mock<TableClient> Index { get; } = new();
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public UpsertHarness()
|
||||
{
|
||||
Index
|
||||
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
Index
|
||||
.Setup(t => t.DeleteEntityAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
Sessions
|
||||
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(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.SessionsIndex)).Returns(Index.Object);
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,36 @@ public class SessionRestoreServiceTests
|
||||
Assert.Equal(3, result.InventoryReIncrements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_routes_tombstone_rows_by_explicit_table_not_rowkey_shape()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetCompletedCascade();
|
||||
// Adversarial fixture: each row's explicit Table CONTRADICTS the legacy RowKey-shape
|
||||
// heuristic (Sessions RK carries '_', SessionsIndex RK is bare). New manifests always
|
||||
// carry Table — a call-site regression back to Contains('_') routing flips both
|
||||
// tables and this test goes red. (BuildFullManifest's tombstone rows deliberately
|
||||
// stay Table-less to keep exercising the legacy fallback in the other facts.)
|
||||
var tombstone = harness.Manifest.Steps.First(s => s.Class == DeletionStepClass.Final);
|
||||
tombstone.Rows = new List<DeletionRowDump>
|
||||
{
|
||||
new DeletionRowDump { Pk = TenantId, Rk = "bare-index-key", Table = Constants.TableNames.SessionsIndex },
|
||||
new DeletionRowDump { Pk = TenantId, Rk = $"under_scored_{SessionId}", Table = Constants.TableNames.Sessions },
|
||||
};
|
||||
|
||||
var result = await harness.Sut.RestoreAsync(TenantId, SessionId, ManifestId, dryRun: false, actor: "ga@example.com");
|
||||
|
||||
Assert.Equal(SessionRestoreOutcome.Restored, result.Outcome);
|
||||
harness.Storage.Verify(s => s.RestoreRowsByExactKeysInBatchesAsync(
|
||||
Constants.TableNames.SessionsIndex,
|
||||
It.Is<IReadOnlyList<DeletionRowDump>>(rows => rows.Count == 1 && rows[0].Rk == "bare-index-key"),
|
||||
RestoreMode.Full, It.IsAny<CancellationToken>()), Times.Once);
|
||||
harness.Storage.Verify(s => s.RestoreRowsByExactKeysInBatchesAsync(
|
||||
Constants.TableNames.Sessions,
|
||||
It.Is<IReadOnlyList<DeletionRowDump>>(rows => rows.Count == 1 && rows[0].Rk.StartsWith("under_scored_")),
|
||||
RestoreMode.Full, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restore_full_re_increments_software_inventory_via_contributions()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using RequestContext = AutopilotMonitor.Functions.Helpers.RequestContext;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fragilitätsaudit P5.3/P6.1: <c>RequestContextExtensions.ResolveSessionScopeAsync</c> is the
|
||||
/// single implementation of the former 15-site copy-paste session-scope fallback, and
|
||||
/// <c>TableStorageService.ResolveSessionTenantIdAsync</c> replaces the cross-partition
|
||||
/// SessionsIndex scan with a SessionTenantLookup point-read (legacy scan fallback + self-heal).
|
||||
/// These tests pin the semantics every migrated endpoint now inherits.
|
||||
/// </summary>
|
||||
public class SessionScopeResolutionTests
|
||||
{
|
||||
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OtherTenant = "44444444-4444-4444-4444-444444444444";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
// ── RequestContextExtensions.ResolveSessionScopeAsync ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task NonGlobalCaller_KeepsTargetTenant_WithoutTouchingTheRepo()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(HomeTenant, result);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlobalScope_CrossTenantSession_ResolvesToOwningTenant()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlobalScope_OwnTenantSession_KeepsTargetTenant()
|
||||
{
|
||||
// Case-insensitive comparison: a resolver returning a different casing must not
|
||||
// count as "different tenant".
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(HomeTenant.ToUpperInvariant());
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(HomeTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlobalScope_UnknownSession_KeepsTargetTenant_SoEndpointNotFoundHandlingStaysInCharge()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync((string?)null);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(HomeTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequireGlobalAdmin_ExcludesGlobalReader_FromCrossTenantWrites()
|
||||
{
|
||||
// Write paths (annotation upsert) gate the resolve on IsGlobalAdmin: a read-only
|
||||
// Global Reader keeps the middleware-validated tenant and can never steer a write
|
||||
// into a foreign tenant.
|
||||
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
|
||||
|
||||
Assert.Equal(HomeTenant, result);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequireGlobalAdmin_StillResolves_ForGlobalAdmin()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
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, requireGlobalAdmin: true);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatedCaller_GetsNoFallback_ByDesign()
|
||||
{
|
||||
// Deliberate: delegated (MSP) callers are NOT global scope — extending the fallback
|
||||
// to AllowedTenantIds would widen the authz surface and is a separate user decision.
|
||||
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
|
||||
var ctx = new RequestContext
|
||||
{
|
||||
TenantId = HomeTenant,
|
||||
TargetTenantId = OtherTenant,
|
||||
IsDelegatedReader = true,
|
||||
AllowedTenantIds = new[] { OtherTenant },
|
||||
};
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
// ── TableStorageService.ResolveSessionTenantIdAsync ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PointReadHit_ReturnsTenant_WithoutScanningTheIndex()
|
||||
{
|
||||
var harness = new ResolveHarness(lookupRow: LookupRow(OtherTenant));
|
||||
|
||||
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
harness.Index.Verify(t => t.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PointReadMiss_FallsBackToScan_AndHealsTheLookupRow()
|
||||
{
|
||||
var indexRow = new TableEntity(OtherTenant, "0000000000000000001_" + SessionId) { ["SessionId"] = SessionId };
|
||||
var harness = new ResolveHarness(lookupRow: null, indexRows: new[] { indexRow });
|
||||
|
||||
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(
|
||||
It.Is<TableEntity>(e => e.PartitionKey == SessionId
|
||||
&& e.RowKey == "tenant"
|
||||
&& e.GetString("TenantId") == OtherTenant),
|
||||
It.IsAny<TableUpdateMode>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownSession_ReturnsNull_AndWritesNoHealRow()
|
||||
{
|
||||
var harness = new ResolveHarness(lookupRow: null, indexRows: Array.Empty<TableEntity>());
|
||||
|
||||
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>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidSessionId_Throws_BeforeAnyStorageCall()
|
||||
{
|
||||
var harness = new ResolveHarness(lookupRow: null);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => harness.Sut.ResolveSessionTenantIdAsync("not-a-guid"));
|
||||
}
|
||||
|
||||
// ── RecomputeTriggerGate ─────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Admin", true)]
|
||||
[InlineData("Operator", true)]
|
||||
[InlineData("Viewer", false)]
|
||||
[InlineData("", false)]
|
||||
public void OwnTenantRoles_GateRecompute(string role, bool expected)
|
||||
{
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = role };
|
||||
Assert.Equal(expected, RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalAdmin_MayRecompute_CrossTenant()
|
||||
{
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalReader_MayNotRecompute_Anywhere()
|
||||
{
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalReader = true };
|
||||
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant));
|
||||
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdminRole_DoesNotCarryCrossTenant()
|
||||
{
|
||||
// A tenant Admin viewing a FOREIGN tenant's session (delegated read) must not be able
|
||||
// to trigger recomputes there — the role binds to the caller's own tenant.
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin };
|
||||
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
|
||||
}
|
||||
|
||||
// ── Harness ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static TableEntity LookupRow(string tenantId)
|
||||
=> new(SessionId, "tenant") { ["TenantId"] = tenantId };
|
||||
|
||||
private sealed class ResolveHarness
|
||||
{
|
||||
public Mock<TableClient> Lookup { get; } = new();
|
||||
public Mock<TableClient> Index { get; } = new();
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public ResolveHarness(TableEntity? lookupRow, TableEntity[]? indexRows = null)
|
||||
{
|
||||
if (lookupRow != null)
|
||||
{
|
||||
Lookup
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>(SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(lookupRow, Mock.Of<Response>()));
|
||||
}
|
||||
else
|
||||
{
|
||||
Lookup
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>(SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found"));
|
||||
}
|
||||
Lookup
|
||||
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
|
||||
var page = Page<TableEntity>.FromValues(indexRows ?? Array.Empty<TableEntity>(), null, Mock.Of<Response>());
|
||||
Index
|
||||
.Setup(t => t.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[] { page }));
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionTenantLookup)).Returns(Lookup.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(Index.Object);
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-language parity anchor: reflects the wire-relevant catalogs out of
|
||||
/// AutopilotMonitor.Shared (models, enums, string vocabularies) into a canonical JSON
|
||||
/// manifest committed at <c>src/Web/autopilot-monitor-web/utils/shared-manifests.json</c>.
|
||||
/// The web's vitest suite and compile-time checks compare their hand-written TS mirrors
|
||||
/// against that manifest — so a C# change that would silently drift a TS mirror first
|
||||
/// fails HERE, and the TS side fails until its mirror follows.
|
||||
///
|
||||
/// Regenerate after intentional changes:
|
||||
/// AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests
|
||||
/// then run the web codegen: node scripts/generate-shared-manifest-types.js
|
||||
/// </summary>
|
||||
public sealed class SharedManifestParityTests
|
||||
{
|
||||
private const string ManifestRepoPath = "src/Web/autopilot-monitor-web/utils/shared-manifests.json";
|
||||
private const string WriteEnvVar = "AM_WRITE_SHARED_MANIFESTS";
|
||||
|
||||
[Fact]
|
||||
public void Committed_manifest_matches_shared_assembly()
|
||||
{
|
||||
var expected = BuildManifestJson();
|
||||
var path = Path.Combine(FindRepoRoot(), ManifestRepoPath.Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
if (Environment.GetEnvironmentVariable(WriteEnvVar) == "1")
|
||||
{
|
||||
File.WriteAllText(path, expected);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(path),
|
||||
$"Manifest missing at {ManifestRepoPath}. Regenerate: {WriteEnvVar}=1 dotnet test --filter SharedManifestParityTests");
|
||||
|
||||
var committed = File.ReadAllText(path).Replace("\r\n", "\n");
|
||||
Assert.True(string.Equals(expected, committed, StringComparison.Ordinal),
|
||||
$"shared-manifests.json is out of date with AutopilotMonitor.Shared. " +
|
||||
$"Regenerate: {WriteEnvVar}=1 dotnet test --filter SharedManifestParityTests " +
|
||||
$"(then: node scripts/generate-shared-manifest-types.js in the web folder).");
|
||||
}
|
||||
|
||||
// ── manifest construction ───────────────────────────────────────────────
|
||||
|
||||
private static string BuildManifestJson()
|
||||
{
|
||||
var manifest = new Dictionary<string, object?>
|
||||
{
|
||||
["$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.",
|
||||
["adminConfiguration"] = new Dictionary<string, object?>
|
||||
{
|
||||
["fields"] = WireFieldNames(typeof(AdminConfiguration)),
|
||||
},
|
||||
["tenantConfiguration"] = new Dictionary<string, object?>
|
||||
{
|
||||
["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>(),
|
||||
["eventSeverities"] = EnumMap<EventSeverity>(),
|
||||
["webhookProviderTypes"] = EnumMap<WebhookProviderType>(),
|
||||
["annotationLanes"] = AnnotationLanes.All,
|
||||
["annotationVerdicts"] = AnnotationVerdicts.All,
|
||||
["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)),
|
||||
["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)),
|
||||
["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)),
|
||||
["analyzeRuleSources"] = DryRunAnalyzeRuleFunction.KnownSources.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||
["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||
["eventTypes"] = ConstStrings(typeof(Constants.EventTypes)).OrderBy(s => s, StringComparer.Ordinal).ToArray(),
|
||||
// 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)),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
});
|
||||
// Normalized to LF + trailing newline so the file is byte-stable across OSes.
|
||||
return json.Replace("\r\n", "\n") + "\n";
|
||||
}
|
||||
|
||||
/// <summary>Wire (camelCase) names of the public instance properties, declaration order.</summary>
|
||||
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));
|
||||
|
||||
private static string[] ConstStrings(Type constClass)
|
||||
=> constClass.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToArray();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -176,12 +176,90 @@ public class SignalRGroupHelperTests
|
||||
[Fact]
|
||||
public void BroadcastJoin_SessionAndNotifyGroups_NotCovered()
|
||||
{
|
||||
// Session groups stay joinable for roleless users (Progress Portal); notify groups have their own
|
||||
// gate (CheckNotifyGroupAccess).
|
||||
// Session groups have their own gate (RequiresSessionGroupSerialProof — serial knowledge,
|
||||
// not a role); notify groups have theirs (CheckNotifyGroupAccess).
|
||||
var ctx = new RequestContext { TenantId = HomeTenant };
|
||||
Assert.False(SignalRGroupHelper.IsTenantBroadcastJoinDenied(
|
||||
$"session-{HomeTenant}-{SessionId}", HomeTenant, ctx));
|
||||
Assert.False(SignalRGroupHelper.IsTenantBroadcastJoinDenied(MemberGroup(HomeTenant), HomeTenant, ctx));
|
||||
Assert.False(SignalRGroupHelper.IsTenantBroadcastJoinDenied(AdminGroup(HomeTenant), HomeTenant, ctx));
|
||||
}
|
||||
|
||||
// ── Session-group parsing ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ExtractSessionId_ParsesWellFormedSessionGroup()
|
||||
{
|
||||
Assert.Equal(SessionId,
|
||||
SignalRGroupHelper.ExtractSessionIdFromGroupName($"session-{TenantId}-{SessionId}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractSessionId_RejectsMalformedNames()
|
||||
{
|
||||
// Strict on purpose: the serial-proof gate treats an unparseable name as no-session →
|
||||
// fail-closed deny for a roleless caller.
|
||||
Assert.Null(SignalRGroupHelper.ExtractSessionIdFromGroupName($"tenant-{TenantId}"));
|
||||
Assert.Null(SignalRGroupHelper.ExtractSessionIdFromGroupName("global-admins"));
|
||||
Assert.Null(SignalRGroupHelper.ExtractSessionIdFromGroupName($"session-{TenantId}")); // no session part
|
||||
Assert.Null(SignalRGroupHelper.ExtractSessionIdFromGroupName($"session-{TenantId}-not-a-guid")); // wrong part count
|
||||
Assert.Null(SignalRGroupHelper.ExtractSessionIdFromGroupName($"session-{TenantId}-{SessionId}-x")); // trailing junk
|
||||
Assert.Null(SignalRGroupHelper.ExtractSessionIdFromGroupName(
|
||||
$"session-{TenantId}-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeez")); // 11 parts but not a GUID
|
||||
}
|
||||
|
||||
// ── RequiresSessionGroupSerialProof ──────────────────────────────────────
|
||||
// Session groups stream one device's live enrollment telemetry. Members may join any of their
|
||||
// tenant's session groups (the same data is MemberRead at REST); a roleless Progress-Portal end
|
||||
// user must instead present the device's serial (SerialKnowledgeProof, checked in the function).
|
||||
|
||||
[Fact]
|
||||
public void SessionSerialProof_RolelessSameTenantUser_Required()
|
||||
{
|
||||
// THE FIX: without this, any authenticated roleless employee could join any known/leaked
|
||||
// session-{tid}-{sid} group of their tenant and stream that device's live events.
|
||||
var ctx = new RequestContext { TenantId = HomeTenant };
|
||||
Assert.True(SignalRGroupHelper.RequiresSessionGroupSerialProof(
|
||||
$"session-{HomeTenant}-{SessionId}", HomeTenant, ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionSerialProof_SameTenantMember_NotRequired()
|
||||
{
|
||||
// Regression guard for the admin pages (sessions detail, diagnosis, dashboard): member
|
||||
// joins carry no serial and must keep working unchanged.
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, UserRole = Constants.TenantRoles.Viewer };
|
||||
Assert.False(SignalRGroupHelper.RequiresSessionGroupSerialProof(
|
||||
$"session-{HomeTenant}-{SessionId}", HomeTenant, ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionSerialProof_GlobalScope_NotRequired()
|
||||
{
|
||||
var ga = new RequestContext { TenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
var reader = new RequestContext { TenantId = HomeTenant, IsGlobalReader = true };
|
||||
Assert.False(SignalRGroupHelper.RequiresSessionGroupSerialProof(
|
||||
$"session-{HomeTenant}-{SessionId}", HomeTenant, ga));
|
||||
Assert.False(SignalRGroupHelper.RequiresSessionGroupSerialProof(
|
||||
$"session-{HomeTenant}-{SessionId}", HomeTenant, reader));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionSerialProof_CrossTenant_NotDecidedHere()
|
||||
{
|
||||
// A cross-tenant join only reaches this check after the upstream cross-tenant admission
|
||||
// (platform scope, or delegated scope over the group's tenant) — no serial demanded there.
|
||||
var ctx = new RequestContext { TenantId = HomeTenant };
|
||||
Assert.False(SignalRGroupHelper.RequiresSessionGroupSerialProof(
|
||||
$"session-{ManagedTenant}-{SessionId}", ManagedTenant, ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionSerialProof_NonSessionGroups_NotCovered()
|
||||
{
|
||||
var ctx = new RequestContext { TenantId = HomeTenant };
|
||||
Assert.False(SignalRGroupHelper.RequiresSessionGroupSerialProof($"tenant-{HomeTenant}", HomeTenant, ctx));
|
||||
Assert.False(SignalRGroupHelper.RequiresSessionGroupSerialProof(MemberGroup(HomeTenant), HomeTenant, ctx));
|
||||
Assert.False(SignalRGroupHelper.RequiresSessionGroupSerialProof("global-admins", null, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
+66
@@ -201,6 +201,72 @@ public class StoreSessionReregistrationPreserveTests
|
||||
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_enrollmentType_isStickyV2_acrossReregistration()
|
||||
{
|
||||
// EnrollmentRegistryDetector defaults to "v1" whenever the AutopilotSettings key is
|
||||
// momentarily unreadable, so a re-registration after such a degraded read must NOT
|
||||
// downgrade an already-observed Device Preparation session — sticky-v2, same contract
|
||||
// as the sticky-true booleans.
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
["EnrollmentType"] = "v2",
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var registration = new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
SerialNumber = "SN-1",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Model-X",
|
||||
DeviceName = "PC-1",
|
||||
EnrollmentType = "v1", // degraded registry read on restart
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_enrollmentType_upgradesV1ToV2_onReregistration()
|
||||
{
|
||||
// Inverse direction: first registration read "v1" (key not yet populated), a later
|
||||
// re-registration reads "v2" — the more informative value wins.
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
["EnrollmentType"] = "v1",
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var registration = new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
SerialNumber = "SN-1",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Model-X",
|
||||
DeviceName = "PC-1",
|
||||
EnrollmentType = "v2",
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("v2", harness.Written!.GetString("EnrollmentType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_cloudPc_upgradesFalseToTrue_onReregistration()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Functions.Services.Offboarding;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle-completeness net for <see cref="Constants.TableNames"/> (Fragilitätsaudit P5.1).
|
||||
/// Every table must be covered by at least one deletion lifecycle — tenant-offboarding wipe
|
||||
/// (read via reflection from <see cref="TenantOffboardingHandler"/>'s bucket arrays), the
|
||||
/// per-session deletion cascade (derived from a real <see cref="DeletionManifestBuilder"/>
|
||||
/// manifest), or the explicit <see cref="KeptByDesign"/> list below. A new table that lands
|
||||
/// in no bucket fails here with instructions; a kept-by-design table that someone starts
|
||||
/// wiping (or vice versa) fails too.
|
||||
///
|
||||
/// NOT covered mechanically: whether a bucket's KEY SHAPE matches the table's writer
|
||||
/// (the UsageMetrics bug this package fixed — PK=date/RK=tenantId sat in the exact-PK
|
||||
/// bucket and the wipe matched 0 rows). When classifying a new table, read its writer's
|
||||
/// <c>new TableEntity(pk, rk)</c> call before picking the bucket.
|
||||
/// </summary>
|
||||
public class TableLifecycleBucketTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
/// <summary>
|
||||
/// Tables that intentionally survive both tenant offboarding and session deletion.
|
||||
/// Each entry needs a reason — this list is the reviewed exception set, not a dumping
|
||||
/// ground for "forgot to classify".
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> KeptByDesign = new(StringComparer.Ordinal)
|
||||
{
|
||||
[Constants.TableNames.AdminConfiguration] = "single platform 'GlobalConfig' partition, no tenant rows",
|
||||
[Constants.TableNames.GlobalAdmins] = "platform operator identities",
|
||||
[Constants.TableNames.McpUsers] = "platform MCP identities (PK='McpUsers', RK=UPN)",
|
||||
[Constants.TableNames.TenantGroupAssignments] = "per-UPN rows without tenant anchor; offboarding never touches it (see Constants doc)",
|
||||
[Constants.TableNames.PreviewConfig] = "global TelegramBot config row",
|
||||
[Constants.TableNames.Feedback] = "product feedback deliberately survives offboarding (TableFeedbackRepositoryTests contract)",
|
||||
[Constants.TableNames.BlockedVersions] = "platform-wide agent version blocks",
|
||||
[Constants.TableNames.GlobalNotifications] = "GA notifications; time-retention only",
|
||||
[Constants.TableNames.VulnerabilityCache] = "CPE/CVE cache without tenant dimension",
|
||||
[Constants.TableNames.OpsEvents] = "platform ops stream; time-retention only",
|
||||
[Constants.TableNames.ImeVersionHistory] = "permanent platform archive by design",
|
||||
[Constants.TableNames.RuleStats] = "per-rule daily counters; time-retention only",
|
||||
[Constants.TableNames.OffboardingAudit] = "the audit trail OF the offboarding itself",
|
||||
[Constants.TableNames.TenantOffboardingCustomsArchive] = "operator-driven cleanup by design",
|
||||
[Constants.TableNames.BackupJobs] = "platform backup job log (PK='BackupJobs'); 365d retention",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Wipes the offboarding handler performs OUTSIDE its bucket arrays (one-off calls in
|
||||
/// HandleAsync). Reflection over the arrays cannot see these; keep in sync with the
|
||||
/// handler's phase code when adding one.
|
||||
/// </summary>
|
||||
private static readonly string[] OneOffOffboardingWipes =
|
||||
{
|
||||
// Phase 2.F-final: deleted LAST via SafeWipe after everything else completed.
|
||||
Constants.TableNames.TenantConfiguration,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tables allowed in MORE than one offboarding bucket. BootstrapSessions has main rows
|
||||
/// under PK=tenantId AND CodeLookup rows under the discriminator PK.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedMultiBucket =
|
||||
{
|
||||
Constants.TableNames.BootstrapSessions,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void TableNamesConstants_SetEquals_TableNamesAll()
|
||||
{
|
||||
var constants = ConstStrings(typeof(Constants.TableNames));
|
||||
var all = Constants.TableNames.All;
|
||||
|
||||
var missingFromAll = constants.Except(all, StringComparer.Ordinal).ToList();
|
||||
var orphansInAll = all.Except(constants, StringComparer.Ordinal).ToList();
|
||||
|
||||
Assert.True(missingFromAll.Count == 0,
|
||||
"TableNames constants missing from TableNames.All (table will never be created at startup):\n - "
|
||||
+ string.Join("\n - ", missingFromAll));
|
||||
Assert.True(orphansInAll.Count == 0,
|
||||
"TableNames.All entries without a matching constant:\n - " + string.Join("\n - ", orphansInAll));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryTable_HasALifecycleClassification()
|
||||
{
|
||||
var allTables = ConstStrings(typeof(Constants.TableNames)).ToHashSet(StringComparer.Ordinal);
|
||||
var offboardWiped = OffboardingBucketUnion();
|
||||
var perSession = await PerSessionCascadeTablesAsync();
|
||||
|
||||
var unclassified = allTables
|
||||
.Except(offboardWiped, StringComparer.Ordinal)
|
||||
.Except(perSession, StringComparer.Ordinal)
|
||||
.Except(KeptByDesign.Keys, StringComparer.Ordinal)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.True(unclassified.Count == 0,
|
||||
"Tables without any lifecycle classification. Either add them to an offboarding wipe bucket "
|
||||
+ "(TenantOffboardingHandler — check the writer's PK/RK shape first!), to the per-session "
|
||||
+ "deletion manifest (DeletionManifestBuilder), or to KeptByDesign in this test WITH a reason:\n - "
|
||||
+ string.Join("\n - ", unclassified));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KeptByDesign_IsDisjointFromEveryWipePath()
|
||||
{
|
||||
var offboardWiped = OffboardingBucketUnion();
|
||||
var perSession = await PerSessionCascadeTablesAsync();
|
||||
|
||||
var conflicts = KeptByDesign.Keys
|
||||
.Where(t => offboardWiped.Contains(t) || perSession.Contains(t))
|
||||
.ToList();
|
||||
|
||||
Assert.True(conflicts.Count == 0,
|
||||
"Tables listed as kept-by-design but ALSO wiped by offboarding or the session cascade — "
|
||||
+ "one of the two claims is wrong:\n - " + string.Join("\n - ", conflicts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryOffboardingBucketEntry_IsAKnownTableName()
|
||||
{
|
||||
var known = ConstStrings(typeof(Constants.TableNames)).ToHashSet(StringComparer.Ordinal);
|
||||
var orphans = OffboardingBuckets()
|
||||
.SelectMany(b => b.Tables.Select(t => $"{b.Name}: {t}"))
|
||||
.Where(entry => !known.Contains(entry.Split(": ", 2)[1]))
|
||||
.ToList();
|
||||
|
||||
Assert.True(orphans.Count == 0,
|
||||
"Offboarding bucket entries that are not TableNames constants:\n - " + string.Join("\n - ", orphans));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OffboardingBuckets_AreDisjoint_ExceptDocumentedMultiMembership()
|
||||
{
|
||||
var buckets = OffboardingBuckets().ToList();
|
||||
var violations = new List<string>();
|
||||
|
||||
for (var i = 0; i < buckets.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < buckets.Count; j++)
|
||||
{
|
||||
var overlap = buckets[i].Tables
|
||||
.Intersect(buckets[j].Tables, StringComparer.Ordinal)
|
||||
.Except(AllowedMultiBucket, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
violations.AddRange(overlap.Select(t => $"{t} (in {buckets[i].Name} AND {buckets[j].Name})"));
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(violations.Count == 0,
|
||||
"Tables in more than one offboarding wipe bucket without a documented exception — "
|
||||
+ "a double wipe with different key anchors usually means one of the two is wrong:\n - "
|
||||
+ string.Join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ── Bucket derivation ────────────────────────────────────────────────────────
|
||||
|
||||
private sealed record Bucket(string Name, IReadOnlyList<string> Tables);
|
||||
|
||||
private static IEnumerable<Bucket> OffboardingBuckets()
|
||||
{
|
||||
yield return new Bucket("TenantPartitionTables", HandlerArray("TenantPartitionTables"));
|
||||
yield return new Bucket("CompositePartitionTables", HandlerArray("CompositePartitionTables"));
|
||||
yield return new Bucket("RowKeyTables", HandlerArray("RowKeyTables"));
|
||||
yield return new Bucket("PropertyOnlyTables", HandlerArray("PropertyOnlyTables"));
|
||||
yield return new Bucket("DiscriminatorTables",
|
||||
HandlerTupleArray<(string Table, string Discriminator)>("DiscriminatorTables").Select(t => t.Table).ToList());
|
||||
yield return new Bucket("ArchivedRuleTables",
|
||||
HandlerTupleArray<(string Table, string Field)>("ArchivedRuleTables").Select(t => t.Table).ToList());
|
||||
yield return new Bucket("OneOffOffboardingWipes", OneOffOffboardingWipes);
|
||||
}
|
||||
|
||||
private static HashSet<string> OffboardingBucketUnion()
|
||||
=> OffboardingBuckets().SelectMany(b => b.Tables).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
private static string[] HandlerArray(string fieldName)
|
||||
=> (string[])HandlerField(fieldName).GetValue(null)!;
|
||||
|
||||
private static T[] HandlerTupleArray<T>(string fieldName)
|
||||
=> (T[])HandlerField(fieldName).GetValue(null)!;
|
||||
|
||||
private static FieldInfo HandlerField(string fieldName)
|
||||
=> typeof(TenantOffboardingHandler).GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static)
|
||||
?? throw new InvalidOperationException(
|
||||
$"TenantOffboardingHandler.{fieldName} not found — bucket array renamed? Update this test's derivation.");
|
||||
|
||||
/// <summary>
|
||||
/// Derives the per-session cascade table set by building a REAL deletion manifest against
|
||||
/// an empty-inventory fake, so new builder steps are picked up automatically. Synthetic
|
||||
/// steps map back to their tables: Tombstone → Sessions + SessionsIndex,
|
||||
/// SoftwareInventoryDecrement → SoftwareInventory.
|
||||
/// </summary>
|
||||
private static async Task<HashSet<string>> PerSessionCascadeTablesAsync()
|
||||
{
|
||||
var builder = new DeletionManifestBuilder(new EmptyInventoryReader(), NullLogger<DeletionManifestBuilder>.Instance);
|
||||
var manifest = await builder.BuildAsync(
|
||||
TenantId, SessionId, "lifecycle_bucket_test",
|
||||
new DeletionActor { Type = "admin", Actor = "alice@contoso.com" },
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
var tables = manifest.Steps
|
||||
.Where(s => !string.IsNullOrEmpty(s.Table))
|
||||
.Select(s => s.Table!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (manifest.Steps.Any(s => s.Step == DeletionStepNames.Tombstone))
|
||||
{
|
||||
tables.Add(Constants.TableNames.SessionsIndex);
|
||||
tables.Add(Constants.TableNames.Sessions);
|
||||
}
|
||||
if (manifest.Steps.Any(s => s.Step == DeletionStepNames.SoftwareInventoryDecrement))
|
||||
{
|
||||
tables.Add(Constants.TableNames.SoftwareInventory);
|
||||
}
|
||||
|
||||
// The inventory pair (steps 20/21) is only emitted when a contributions side-row
|
||||
// exists; the fake provides one so the derivation sees the complete step list.
|
||||
Assert.Contains(Constants.TableNames.SessionInventoryContributions, tables);
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
private static string[] ConstStrings(Type constClass)
|
||||
=> constClass.GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!)
|
||||
.ToArray();
|
||||
|
||||
// ── Fake inventory reader ────────────────────────────────────────────────────
|
||||
|
||||
private sealed class EmptyInventoryReader : ISessionDeletionInventoryReader
|
||||
{
|
||||
private const string IndexRowKey = "0000000000000000001_" + SessionId;
|
||||
|
||||
public Task<TableEntity?> GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<TableEntity?>(new TableEntity(tenantId, sessionId) { ["IndexRowKey"] = IndexRowKey });
|
||||
|
||||
public Task<TableEntity?> GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<TableEntity?>(new TableEntity(tenantId, indexRowKey) { ["SessionId"] = SessionId });
|
||||
|
||||
public async IAsyncEnumerable<TableEntity> QueryAsync(string tableName, string filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public Task<TableEntity?> GetEntityOrNullAsync(string tableName, string partitionKey, string rowKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Provide the SessionInventoryContributions side-row so steps 20/21 are emitted.
|
||||
if (tableName == Constants.TableNames.SessionInventoryContributions)
|
||||
{
|
||||
return Task.FromResult<TableEntity?>(new TableEntity(partitionKey, rowKey));
|
||||
}
|
||||
return Task.FromResult<TableEntity?>(null);
|
||||
}
|
||||
|
||||
public Task<TableEntity?> GetActiveSessionTombstoneAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<TableEntity?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint-level tests for <see cref="TenantAdminManagementFunction"/> role handling:
|
||||
/// the POST/PATCH role allow-list (Admin/Operator/Viewer) matches case-insensitively,
|
||||
/// canonicalizes to the exact constant casing before anything reaches storage, and
|
||||
/// rejects unknown roles with 400. Uses the same fake-HttpRequestData harness as
|
||||
/// <c>SessionAnnotationFunctionsTests</c>.
|
||||
/// </summary>
|
||||
public class TenantAdminManagementFunctionTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string CallerUpn = "admin@contoso.com";
|
||||
|
||||
// ── TryCanonicalizeRole ─────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Admin", "Admin")]
|
||||
[InlineData("admin", "Admin")]
|
||||
[InlineData("OPERATOR", "Operator")]
|
||||
[InlineData("operator", "Operator")]
|
||||
[InlineData("viewer", "Viewer")]
|
||||
[InlineData("Viewer", "Viewer")]
|
||||
[InlineData("VIEWER", "Viewer")]
|
||||
[InlineData("Member", null)]
|
||||
[InlineData("GlobalAdmin", null)]
|
||||
[InlineData("", null)]
|
||||
public void TryCanonicalizeRole_matches_allow_list_case_insensitively(string input, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, TenantAdminManagementFunction.TryCanonicalizeRole(input));
|
||||
}
|
||||
|
||||
// ── POST /tenants/{tenantId}/admins ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Post_lowercase_viewer_is_persisted_as_canonical_Viewer()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { upn = "user@contoso.com", role = "viewer" });
|
||||
|
||||
var response = await h.Function.AddTenantAdmin(req, TenantId, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
h.AdminRepo.Verify(r => r.AddTenantMemberAsync(
|
||||
TenantId, "user@contoso.com", CallerUpn, Constants.TenantRoles.Viewer, false), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_unknown_role_returns_400_and_never_writes()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { upn = "user@contoso.com", role = "superuser" });
|
||||
|
||||
var response = await h.Function.AddTenantAdmin(req, TenantId, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
h.AdminRepo.Verify(r => r.AddTenantMemberAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_missing_role_defaults_to_Admin()
|
||||
{
|
||||
// Backward compat: older clients post only the UPN.
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { upn = "user@contoso.com" });
|
||||
|
||||
var response = await h.Function.AddTenantAdmin(req, TenantId, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
h.AdminRepo.Verify(r => r.AddTenantMemberAsync(
|
||||
TenantId, "user@contoso.com", CallerUpn, Constants.TenantRoles.Admin, false), Times.Once);
|
||||
}
|
||||
|
||||
// ── PATCH /tenants/{tenantId}/admins/{adminUpn}/permissions ─────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_lowercase_viewer_is_persisted_as_canonical_Viewer()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { role = "viewer", canManageBootstrapTokens = false });
|
||||
|
||||
var response = await h.Function.UpdateMemberPermissions(req, TenantId, "user@contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
h.AdminRepo.Verify(r => r.UpdateMemberPermissionsAsync(
|
||||
TenantId, "user@contoso.com", Constants.TenantRoles.Viewer, false), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_unknown_role_returns_400_and_never_writes()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { role = "root", canManageBootstrapTokens = true });
|
||||
|
||||
var response = await h.Function.UpdateMemberPermissions(req, TenantId, "user@contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
h.AdminRepo.Verify(r => r.UpdateMemberPermissionsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_self_demotion_check_uses_canonical_role()
|
||||
{
|
||||
// Caller demotes THEMSELVES with a lowercase "admin" — canonicalization must
|
||||
// recognize it as Admin (no demotion) and not trip the last-admin guard.
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { role = "admin", canManageBootstrapTokens = true });
|
||||
|
||||
var response = await h.Function.UpdateMemberPermissions(req, TenantId, CallerUpn, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
h.AdminRepo.Verify(r => r.UpdateMemberPermissionsAsync(
|
||||
TenantId, CallerUpn, Constants.TenantRoles.Admin, true), Times.Once);
|
||||
}
|
||||
|
||||
// ── harness ─────────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TenantAdminManagementFunction Function = default!;
|
||||
public Mock<IAdminRepository> AdminRepo = default!;
|
||||
}
|
||||
|
||||
private static Harness BuildHarness()
|
||||
{
|
||||
var adminRepo = new Mock<IAdminRepository>(MockBehavior.Loose);
|
||||
adminRepo.Setup(r => r.AddTenantMemberAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(true);
|
||||
adminRepo.Setup(r => r.UpdateMemberPermissionsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = new TenantAdminsService(
|
||||
adminRepo.Object,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<TenantAdminsService>.Instance);
|
||||
|
||||
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
|
||||
|
||||
return new Harness
|
||||
{
|
||||
Function = new TenantAdminManagementFunction(
|
||||
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object),
|
||||
AdminRepo = adminRepo,
|
||||
};
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(object body)
|
||||
{
|
||||
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.
|
||||
services.Configure<WorkerOptions>(o => o.Serializer =
|
||||
new JsonObjectSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var requestContext = new RequestContext
|
||||
{
|
||||
TenantId = TenantId,
|
||||
TargetTenantId = TenantId,
|
||||
UserPrincipalName = CallerUpn,
|
||||
IsTenantAdmin = true,
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
};
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(
|
||||
new Dictionary<object, object> { [RequestContext.ItemsKey] = requestContext });
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
|
||||
req.SetupGet(r => r.Body).Returns(
|
||||
new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(body))));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object));
|
||||
return (req.Object, context.Object);
|
||||
}
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ public class TenantNotificationAudienceCatalogTests
|
||||
[InlineData("sla_breach", NotificationAudience.Member)]
|
||||
[InlineData("sla_consecutive_failures", NotificationAudience.Member)]
|
||||
[InlineData("sla_resolved", NotificationAudience.Member)]
|
||||
[InlineData("rule_frequency_regression", NotificationAudience.Admin)]
|
||||
[InlineData("app_version_duration_regression", NotificationAudience.Admin)]
|
||||
public void Resolve_KnownTypes_ReturnsExpectedAudience(string type, NotificationAudience expected)
|
||||
{
|
||||
Assert.Equal(expected, TenantNotificationAudienceCatalog.Resolve(type));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user