mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69c6353fb1 | |||
| 2b9bf3cd3f | |||
| d99e16314b | |||
| e4203e0f15 | |||
| 2a5366d9c3 | |||
| 9c9cf24ddc | |||
| ec0d8ad735 | |||
| fc9e9d13e6 | |||
| d0cb7e66f4 | |||
| bd66480ccc | |||
| b05f1f6bcf | |||
| 12ad9c9436 | |||
| c8ce7cdb37 | |||
| 56d4dd8b84 | |||
| 86fdc5aca9 | |||
| 87c5bc59dc | |||
| 21b30b21c2 | |||
| f94f1c0c4e | |||
| a6a9883451 | |||
| 03116a97d1 | |||
| 2abc78ab6d | |||
| 6e54b52376 | |||
| 7cef2ea361 | |||
| 5b5ee0e8fd | |||
| 7bcc4f00b1 | |||
| 34ec44e3d4 | |||
| f607e21c13 | |||
| 5940e9fc6a | |||
| 98cfd76232 | |||
| 09e68a614f | |||
| 88290caadc | |||
| f442dce9a4 | |||
| 4f03f39e9a | |||
| 38b667799c | |||
| a5f74d6abc | |||
| d0e09301a1 | |||
| 177915ea6b |
+1
-1
Submodule .claude/commands updated: 92d8689b1e...e8b8c08c1b
@@ -348,16 +348,6 @@ jobs:
|
||||
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
RELEASE_NOTES: ${{ inputs.release_notes }}
|
||||
RELEASE_BODY_TAIL: |
|
||||
### Installation
|
||||
1. Download `AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip`
|
||||
2. Extract to `C:\ProgramData\AutopilotMonitor\Agent\`
|
||||
3. Run `AutopilotMonitor.Agent.exe --install` to register the Scheduled Task
|
||||
|
||||
### Included Components
|
||||
- **AutopilotMonitor.Agent.exe** - Monitoring agent (V2 release line)
|
||||
- **AutopilotMonitor.SummaryDialog.exe** - Enrollment summary dialog (WPF)
|
||||
- **version-v2.json** - Per-line version manifest
|
||||
|
||||
### Version Info
|
||||
- **Version:** ${{ steps.version.outputs.version }}
|
||||
- **Build Number:** ${{ steps.version.outputs.build_number }}
|
||||
@@ -392,7 +382,7 @@ jobs:
|
||||
$compare = "`n`n**Full changelog:** https://github.com/$env:GITHUB_REPOSITORY/compare/$prevTag...$env:RELEASE_TAG`n"
|
||||
}
|
||||
|
||||
$body = "## $env:RELEASE_NAME`n`n### What's new`n$notes`n`n" + "$env:RELEASE_BODY_TAIL".TrimEnd() + $compare
|
||||
$body = "### What's new`n$notes`n`n" + "$env:RELEASE_BODY_TAIL".TrimEnd() + $compare
|
||||
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
|
||||
Set-Content -Path $notesFile -Value $body -Encoding utf8 -NoNewline
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ name: CI
|
||||
# red run reports rather than blocks. Read the result and follow up.
|
||||
#
|
||||
# Path filters: commits that only bump a private submodule gitlink (internal/, infra/,
|
||||
# .claude/commands) or touch prose cannot change anything the suites cover, so they are
|
||||
# skipped instead of burning ~15 runner-minutes each. A gitlink bump reports the bare
|
||||
# .claude/commands), touch prose or edit another workflow cannot change anything the
|
||||
# suites cover, so they are skipped instead of burning ~15 runner-minutes each. A gitlink bump reports the bare
|
||||
# path ('internal', no trailing slash), which 'internal/**' does NOT match — both forms
|
||||
# are listed. GitHub skips only when EVERY changed path matches; a commit that touches
|
||||
# docs AND code still runs.
|
||||
@@ -30,6 +30,23 @@ on:
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/dependabot.yml'
|
||||
- '.github/assets/**'
|
||||
- '.github/codeql/**'
|
||||
- '.github/scripts/**'
|
||||
# Every other workflow: nothing here builds or tests anything CI covers. paths-ignore
|
||||
# cannot negate, so they are listed one by one; ci.yml itself stays out of the list
|
||||
# so a change to CI runs CI. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
- '.github/workflows/codeql.yml'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
- '.github/workflows/deploy-backend.yml'
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
@@ -45,6 +62,23 @@ on:
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/dependabot.yml'
|
||||
- '.github/assets/**'
|
||||
- '.github/codeql/**'
|
||||
- '.github/scripts/**'
|
||||
# Every other workflow: nothing here builds or tests anything CI covers. paths-ignore
|
||||
# cannot negate, so they are listed one by one; ci.yml itself stays out of the list
|
||||
# so a change to CI runs CI. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
- '.github/workflows/codeql.yml'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
- '.github/workflows/deploy-backend.yml'
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -6,7 +6,7 @@ name: CodeQL
|
||||
# A gitlink bump reports the bare path ('internal', no trailing slash), which
|
||||
# 'internal/**' does NOT match — both forms are listed. A commit that touches docs
|
||||
# AND code still runs: GitHub skips only when EVERY changed path matches the filter.
|
||||
# Same list as ci.yml; keep them in step.
|
||||
# Same list as ci.yml except that each file leaves itself out; keep them in step.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -23,6 +23,22 @@ on:
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/dependabot.yml'
|
||||
- '.github/assets/**'
|
||||
- '.github/scripts/**'
|
||||
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
|
||||
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
|
||||
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
- '.github/workflows/deploy-backend.yml'
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
@@ -38,6 +54,22 @@ on:
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/dependabot.yml'
|
||||
- '.github/assets/**'
|
||||
- '.github/scripts/**'
|
||||
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
|
||||
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
|
||||
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
- '.github/workflows/deploy-backend.yml'
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
schedule:
|
||||
# Weekly, so advisories published after a merge still surface. Deliberately
|
||||
# unfiltered — it re-analyzes the whole tree regardless of what changed, which
|
||||
|
||||
+1
-1
Submodule internal updated: 2316e7c0f3...54efd666b0
@@ -2,14 +2,14 @@
|
||||
"$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.",
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.2.0",
|
||||
"version": "2.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"evaluateOn": ["enrollment_end", "on_event:hybrid_login_pending"],
|
||||
"evaluateOn": ["enrollment_end", "on_event:entra_user_affinity_pending"],
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 75,
|
||||
"preconditions": [
|
||||
@@ -17,14 +17,14 @@
|
||||
"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."
|
||||
"description": "Suppress when the session eventually completed — a single pending-affinity warning followed by a successful token acquisition is hybrid latency, not a broken affinity."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "hybrid_login_overdue",
|
||||
"signal": "user_affinity_pending",
|
||||
"source": "event_type",
|
||||
"eventType": "hybrid_login_pending",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
@@ -38,28 +38,27 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "placeholder_still_active",
|
||||
"signal": "no_user_token",
|
||||
"source": "event_type",
|
||||
"eventType": "aad_placeholder_user_detected",
|
||||
"operator": "exists",
|
||||
"eventType": "ime_user_token_acquired",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"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 }
|
||||
{ "signal": "entra_user_affinity_pending", "condition": "count >= 2", "weight": 20 },
|
||||
{ "signal": "entra_user_affinity_pending", "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.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it — sessions in this state typically show repeated reboots and network flapping while the placeholder identity stays active.\n\nA visible side effect: **the Windows Hello for Business wizard never appears**, because Hello provisioning requires an Entra user token (PRT). Hello is a downstream symptom here, not the cause.",
|
||||
"explanation": "**What was measured.** A real user signed in and reached the desktop (`desktop_arrived`), and by the end of the agent's wait window the Intune Management Extension (IME) had still not acquired a **Microsoft Entra user token** for that user (`entra_user_affinity_pending`). That event lists the IME token error codes observed after the desktop (`tokenFailureCodes`) and how often the acquisition failed (`tokenFailureCount`). Each agent run reports this at most once, so a second or third occurrence means the same outcome across reboots — that repetition is what raises the confidence of this finding. No `ime_user_token_acquired` event followed in this session.\n\n**Why this means no user affinity.** IME's user check-in acquires the user token through the Windows web account manager (WAM), which needs the signed-in user's Entra identity — the Primary Refresh Token (PRT) that a hybrid sign-in establishes at logon. When that PRT was never issued, every token request fails and the user has **no Entra user affinity** on this device. Consequences:\n\n- user-targeted required apps and policies cannot apply,\n- Windows Hello for Business cannot provision (it requires a user token),\n- with a user ESP the Account Setup page re-appears on every logon instead of completing.\n\n**What this diagnosis does not use.** The Autopilot provisioning placeholder in JoinInfo (`aad_placeholder_user_detected`) is not part of this finding — on hybrid devices it can stay present through a perfectly healthy sign-in. Likewise, `dsregcmd /status` collected by the agent runs as SYSTEM and shows only the **device** join state; the per-user PRT state is visible only when the command runs as the signed-in user.\n\n**Typical causes** — something structurally prevents 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.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it.\n\nThe `device_registration_event` entries in the timeline (User Device Registration events 304/305) carry the OS-side record of the failed registration; the error codes in `entra_user_affinity_pending` carry the token side. A missing Windows Hello for Business wizard is a downstream symptom here, not the cause.",
|
||||
"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."
|
||||
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join — they prove the device join only, not the user's registration.",
|
||||
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362). The device_registration_event entries (304/305) in the session timeline carry the same OS-side failure record.",
|
||||
"Enable the gather rule GATHER-DEVICE-006 (dsregcmd /status at the moment of the finding — device state only, the agent runs as SYSTEM). GATHER-DEVICE-007 and GATHER-DEVICE-008 (Microsoft-Windows-AAD/Operational events 1098 and 1097 at the same moment) are on by default."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+16
-21
@@ -2940,16 +2940,16 @@
|
||||
{
|
||||
"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.",
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.2.0",
|
||||
"version": "2.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"on_event:hybrid_login_pending"
|
||||
"on_event:entra_user_affinity_pending"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"confidenceThreshold": 75,
|
||||
@@ -2958,14 +2958,14 @@
|
||||
"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."
|
||||
"description": "Suppress when the session eventually completed — a single pending-affinity warning followed by a successful token acquisition is hybrid latency, not a broken affinity."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "hybrid_login_overdue",
|
||||
"signal": "user_affinity_pending",
|
||||
"source": "event_type",
|
||||
"eventType": "hybrid_login_pending",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
@@ -2979,40 +2979,35 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "placeholder_still_active",
|
||||
"signal": "no_user_token",
|
||||
"source": "event_type",
|
||||
"eventType": "aad_placeholder_user_detected",
|
||||
"operator": "exists",
|
||||
"eventType": "ime_user_token_acquired",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "hybrid_login_pending",
|
||||
"signal": "entra_user_affinity_pending",
|
||||
"condition": "count >= 2",
|
||||
"weight": 20
|
||||
},
|
||||
{
|
||||
"signal": "hybrid_login_pending",
|
||||
"condition": "count >= 3",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "aad_placeholder_user_detected",
|
||||
"signal": "entra_user_affinity_pending",
|
||||
"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.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it — sessions in this state typically show repeated reboots and network flapping while the placeholder identity stays active.\n\nA visible side effect: **the Windows Hello for Business wizard never appears**, because Hello provisioning requires an Entra user token (PRT). Hello is a downstream symptom here, not the cause.",
|
||||
"explanation": "**What was measured.** A real user signed in and reached the desktop (`desktop_arrived`), and by the end of the agent's wait window the Intune Management Extension (IME) had still not acquired a **Microsoft Entra user token** for that user (`entra_user_affinity_pending`). That event lists the IME token error codes observed after the desktop (`tokenFailureCodes`) and how often the acquisition failed (`tokenFailureCount`). Each agent run reports this at most once, so a second or third occurrence means the same outcome across reboots — that repetition is what raises the confidence of this finding. No `ime_user_token_acquired` event followed in this session.\n\n**Why this means no user affinity.** IME's user check-in acquires the user token through the Windows web account manager (WAM), which needs the signed-in user's Entra identity — the Primary Refresh Token (PRT) that a hybrid sign-in establishes at logon. When that PRT was never issued, every token request fails and the user has **no Entra user affinity** on this device. Consequences:\n\n- user-targeted required apps and policies cannot apply,\n- Windows Hello for Business cannot provision (it requires a user token),\n- with a user ESP the Account Setup page re-appears on every logon instead of completing.\n\n**What this diagnosis does not use.** The Autopilot provisioning placeholder in JoinInfo (`aad_placeholder_user_detected`) is not part of this finding — on hybrid devices it can stay present through a perfectly healthy sign-in. Likewise, `dsregcmd /status` collected by the agent runs as SYSTEM and shows only the **device** join state; the per-user PRT state is visible only when the command runs as the signed-in user.\n\n**Typical causes** — something structurally prevents 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.\n\nA frequent variant: the user's **first sign-in happens without domain-controller reachability** — for example when a pre-logon VPN tunnel (GlobalProtect, Always On VPN, Zscaler ZPA) is not established yet at the logon screen. Windows falls back to cached credentials, the hybrid PRT flow silently never runs, and later reboots do not fix it.\n\nThe `device_registration_event` entries in the timeline (User Device Registration events 304/305) carry the OS-side record of the failed registration; the error codes in `entra_user_affinity_pending` carry the token side. A missing Windows Hello for Business wizard is a downstream symptom here, not the cause.",
|
||||
"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."
|
||||
"Also check 'Device State': AzureAdJoined = YES and DomainJoined = YES are expected for hybrid join — they prove the device join only, not the user's registration.",
|
||||
"Check Event Viewer: Applications and Services Logs > Microsoft > Windows > User Device Registration > Admin for PRT / registration errors (e.g. event 304/305/362). The device_registration_event entries (304/305) in the session timeline carry the same OS-side failure record.",
|
||||
"Enable the gather rule GATHER-DEVICE-006 (dsregcmd /status at the moment of the finding — device state only, the agent runs as SYSTEM). GATHER-DEVICE-007 and GATHER-DEVICE-008 (Microsoft-Windows-AAD/Operational events 1098 and 1097 at the same moment) are on by default."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+52
-4
@@ -130,16 +130,16 @@
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"title": "Collect dsregcmd join status on pending user affinity",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending), capturing the device join and registration state at exactly the point where ANALYZE-ID-004 draws its evidence. 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",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": false,
|
||||
"collectorType": "command_allowlisted",
|
||||
"target": "dsregcmd /status",
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "hybrid_login_pending",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_dsregcmd_status",
|
||||
"outputSeverity": "Info",
|
||||
"tags": [
|
||||
@@ -149,6 +149,54 @@
|
||||
"hybrid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-DEVICE-007",
|
||||
"title": "Collect Entra operational log on pending user affinity (event 1098)",
|
||||
"description": "Collects the most recent Entra token-broker operational events with ID 1098 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-008 collects event 1097 from the same channel.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"collectorType": "eventlog",
|
||||
"target": "Microsoft-Windows-AAD/Operational",
|
||||
"parameters": {
|
||||
"eventId": "1098",
|
||||
"maxEntries": "20"
|
||||
},
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_aad_operational_events",
|
||||
"outputSeverity": "Warning",
|
||||
"tags": [
|
||||
"device",
|
||||
"entra",
|
||||
"hybrid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-DEVICE-008",
|
||||
"title": "Collect Entra operational log on pending user affinity (event 1097)",
|
||||
"description": "Collects the most recent Entra token-broker operational events with ID 1097 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-007 collects event 1098 from the same channel.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"collectorType": "eventlog",
|
||||
"target": "Microsoft-Windows-AAD/Operational",
|
||||
"parameters": {
|
||||
"eventId": "1097",
|
||||
"maxEntries": "20"
|
||||
},
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_aad_operational_events",
|
||||
"outputSeverity": "Warning",
|
||||
"tags": [
|
||||
"device",
|
||||
"entra",
|
||||
"hybrid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "GATHER-ENROLL-001",
|
||||
"title": "MDM enrollment diagnostic events",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"$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.",
|
||||
"title": "Collect dsregcmd join status on pending user affinity",
|
||||
"description": "Runs dsregcmd /status the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending), capturing the device join and registration state at exactly the point where ANALYZE-ID-004 draws its evidence. 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",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": false,
|
||||
"collectorType": "command_allowlisted",
|
||||
"target": "dsregcmd /status",
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "hybrid_login_pending",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_dsregcmd_status",
|
||||
"outputSeverity": "Info",
|
||||
"tags": ["device", "entra", "join", "hybrid"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../schema/gather-rule.schema.json",
|
||||
"ruleId": "GATHER-DEVICE-007",
|
||||
"title": "Collect Entra operational log on pending user affinity (event 1098)",
|
||||
"description": "Collects the most recent Entra token-broker operational events with ID 1098 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-008 collects event 1097 from the same channel.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"collectorType": "eventlog",
|
||||
"target": "Microsoft-Windows-AAD/Operational",
|
||||
"parameters": {
|
||||
"eventId": "1098",
|
||||
"maxEntries": "20"
|
||||
},
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_aad_operational_events",
|
||||
"outputSeverity": "Warning",
|
||||
"tags": ["device", "entra", "hybrid"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../schema/gather-rule.schema.json",
|
||||
"ruleId": "GATHER-DEVICE-008",
|
||||
"title": "Collect Entra operational log on pending user affinity (event 1097)",
|
||||
"description": "Collects the most recent Entra token-broker operational events with ID 1097 from Microsoft-Windows-AAD/Operational the moment the agent reports that the signed-in user still has no Entra user token (entra_user_affinity_pending). Captures the OS-side token-broker record at exactly the point where ANALYZE-ID-004 draws its evidence. The event log collector filters on one event ID per rule; GATHER-DEVICE-007 collects event 1098 from the same channel.",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"collectorType": "eventlog",
|
||||
"target": "Microsoft-Windows-AAD/Operational",
|
||||
"parameters": {
|
||||
"eventId": "1097",
|
||||
"maxEntries": "20"
|
||||
},
|
||||
"trigger": "on_event",
|
||||
"triggerEventType": "entra_user_affinity_pending",
|
||||
"outputEventType": "gather_aad_operational_events",
|
||||
"outputSeverity": "Warning",
|
||||
"tags": ["device", "entra", "hybrid"]
|
||||
}
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Source of the signal.",
|
||||
"description": "Source of the signal. phase_duration and app_install_duration never derive a duration from a span with mixed timestamp provenance (exactly one IME-log endpoint with sourceOffsetOrigin=reader-zone-fallback, i.e. the writer timezone was unknown to the agent for one endpoint only): such a condition evaluates false with evidence timestampProvenance=reader-zone-fallback. Two fallback endpoints share the same error and stay measurable.",
|
||||
"enum": ["event_type", "event_data", "event_data_array", "phase_duration", "event_count", "app_install_duration", "event_correlation", "clock_skew"]
|
||||
},
|
||||
"skewMetric": {
|
||||
|
||||
@@ -34,6 +34,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
|
||||
new Regex("\\[\"eventType\"\\]\\s*=\\s*\"([a-z][a-z0-9_]*)\"", RegexOptions.Compiled),
|
||||
};
|
||||
|
||||
// Multi-line callback shape (2026-09-04, D-072 gap): `OnTraceEvent?.Invoke(` at the end
|
||||
// of one line, the event-type literal alone on the NEXT line. The single-line patterns
|
||||
// above never saw `desktop_real_user_detected` / `desktop_excluded_user` this way.
|
||||
private static readonly Regex InvokeOpener = new Regex("\\.Invoke\\(\\s*$", RegexOptions.Compiled);
|
||||
private static readonly Regex BareLiteralLine = new Regex("^\\s*\"([a-z][a-z0-9_]*)\"\\s*,?\\s*$", RegexOptions.Compiled);
|
||||
|
||||
// The reducer/param key name itself ("eventType") and the like are not event types.
|
||||
private static readonly HashSet<string> NotEventTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -59,33 +65,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
|
||||
Assert.True(Directory.Exists(full), $"V2 source dir not found: {full}");
|
||||
|
||||
foreach (var file in EnumerateSourceFiles(full))
|
||||
{
|
||||
var lines = File.ReadAllLines(file);
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var trimmed = line.TrimStart();
|
||||
|
||||
// Skip comments and const DEFINITIONS (Constants/DeadlineNames key names etc.).
|
||||
if (trimmed.StartsWith("//") || trimmed.StartsWith("*") || trimmed.StartsWith("///"))
|
||||
continue;
|
||||
if (line.Contains("const ")) continue;
|
||||
|
||||
foreach (var pattern in EmitPatterns)
|
||||
{
|
||||
foreach (Match m in pattern.Matches(line))
|
||||
{
|
||||
var literal = m.Groups[1].Value;
|
||||
if (NotEventTypes.Contains(literal)) continue;
|
||||
if (!known.Contains(literal))
|
||||
{
|
||||
offenders.Add(
|
||||
$"{Path.GetFileName(file)}:{i + 1} \"{literal}\" -> {line.Trim()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offenders.AddRange(FindOffenders(Path.GetFileName(file), File.ReadAllLines(file), known));
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
@@ -95,6 +75,65 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
|
||||
string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guard_detects_multiline_Invoke_literal()
|
||||
{
|
||||
// Mutation proof for the multi-line shape: a synthetic emit site with an unknown
|
||||
// literal on the line after `OnTraceEvent?.Invoke(` must be reported; the same
|
||||
// shape with a known type must not.
|
||||
var known = new HashSet<string>(StringComparer.Ordinal) { "desktop_arrived" };
|
||||
string[] bad =
|
||||
{
|
||||
" OnTraceEvent?.Invoke(",
|
||||
" \"desktop_not_a_real_type\",",
|
||||
" $\"Real user desktop detected\",",
|
||||
};
|
||||
string[] good =
|
||||
{
|
||||
" OnTraceEvent?.Invoke(",
|
||||
" \"desktop_arrived\",",
|
||||
};
|
||||
|
||||
var offender = Assert.Single(FindOffenders("X.cs", bad, known));
|
||||
Assert.Contains("desktop_not_a_real_type", offender);
|
||||
Assert.Empty(FindOffenders("X.cs", good, known));
|
||||
}
|
||||
|
||||
private static List<string> FindOffenders(string fileName, string[] lines, HashSet<string> known)
|
||||
{
|
||||
var offenders = new List<string>();
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var trimmed = line.TrimStart();
|
||||
|
||||
// Skip comments and const DEFINITIONS (Constants/DeadlineNames key names etc.).
|
||||
if (trimmed.StartsWith("//") || trimmed.StartsWith("*") || trimmed.StartsWith("///"))
|
||||
continue;
|
||||
if (line.Contains("const ")) continue;
|
||||
|
||||
foreach (var pattern in EmitPatterns)
|
||||
{
|
||||
foreach (Match m in pattern.Matches(line))
|
||||
Check(m.Groups[1].Value, line, i);
|
||||
}
|
||||
|
||||
if (i > 0 && InvokeOpener.IsMatch(lines[i - 1]))
|
||||
{
|
||||
var bare = BareLiteralLine.Match(line);
|
||||
if (bare.Success) Check(bare.Groups[1].Value, line, i);
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
|
||||
void Check(string literal, string line, int index)
|
||||
{
|
||||
if (NotEventTypes.Contains(literal)) return;
|
||||
if (!known.Contains(literal))
|
||||
offenders.Add($"{fileName}:{index + 1} \"{literal}\" -> {line.Trim()}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>All string-const values declared on <see cref="Constants.EventTypes"/>.</summary>
|
||||
private static HashSet<string> KnownEventTypes()
|
||||
{
|
||||
@@ -113,18 +152,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
|
||||
.Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
|
||||
&& !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"));
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) return dir;
|
||||
var parent = Directory.GetParent(dir)?.FullName;
|
||||
if (parent == null || parent == dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not locate repo root (AutopilotMonitor.sln) from " + AppContext.BaseDirectory);
|
||||
}
|
||||
private static string RepoRoot() => Harness.RepoPaths.Root();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>Repository paths for tests that read or write checked-in files (guards, fixtures).</summary>
|
||||
internal static class RepoPaths
|
||||
{
|
||||
/// <summary>Walks up from the test binary to the directory holding <c>AutopilotMonitor.sln</c>.</summary>
|
||||
public static string Root()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) return dir;
|
||||
var parent = Directory.GetParent(dir)?.FullName;
|
||||
if (parent == null || parent == dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not locate repo root (AutopilotMonitor.sln) from " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
/// <summary><c>tests/fixtures/<name></c> under the repo root.</summary>
|
||||
public static string Fixtures(string name) => Path.Combine(Root(), "tests", "fixtures", name);
|
||||
}
|
||||
}
|
||||
+1
@@ -13,6 +13,7 @@ using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031 // SpinWait.SpinUntil for uploader-batch-arrival assertion
|
||||
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Entra user affinity after the real-user desktop (2026-09-04, session a7140f98). The
|
||||
/// contracts that matter: arm only on Hybrid devices and only at the desktop; the first
|
||||
/// IME user-token line AFTER the desktop cancels and posts <c>ime_user_token_acquired</c>;
|
||||
/// token lines from before the desktop (device phase) are ignored; the timer posts
|
||||
/// <c>entra_user_affinity_pending</c> with the failure codes seen since the desktop; both
|
||||
/// emissions are single-shot per process.
|
||||
/// </summary>
|
||||
public sealed class EntraUserAffinityDetectorTests
|
||||
{
|
||||
private static readonly DateTime Fixed = new DateTime(2026, 9, 4, 13, 54, 6, DateTimeKind.Utc);
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public AgentLogger Logger { get; }
|
||||
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||
public VirtualClock Clock { get; } = new VirtualClock(Fixed);
|
||||
public InformationalEventPost Post { get; }
|
||||
public bool Hybrid { get; set; } = true;
|
||||
public bool PlaceholderActive { get; set; } = true;
|
||||
|
||||
public Fixture()
|
||||
{
|
||||
Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
|
||||
Post = new InformationalEventPost(Ingress, Clock);
|
||||
}
|
||||
|
||||
public EntraUserAffinityDetector Build() => new EntraUserAffinityDetector(
|
||||
Post, Logger,
|
||||
isHybridJoinProbe: () => Hybrid,
|
||||
placeholderActiveProbe: () => PlaceholderActive,
|
||||
delay: TimeSpan.FromMinutes(10),
|
||||
utcNow: () => Clock.UtcNow);
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
private static FakeSignalIngressSink.PostedSignal? Find(FakeSignalIngressSink ingress, string eventType) =>
|
||||
ingress.Posted.FirstOrDefault(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType);
|
||||
|
||||
[Fact]
|
||||
public void Desktop_on_hybrid_device_arms()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
|
||||
Assert.True(d.IsArmedForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Desktop_on_non_hybrid_device_does_not_arm()
|
||||
{
|
||||
using var f = new Fixture { Hybrid = false };
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.TriggerFromTest();
|
||||
|
||||
Assert.False(d.IsArmedForTest);
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timer_without_token_emits_pending_with_failure_codes()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddMinutes(1));
|
||||
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddMinutes(3));
|
||||
d.NotifyTokenFailureLine("3400073247", f.Clock.UtcNow.AddMinutes(5));
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(10));
|
||||
d.TriggerFromTest();
|
||||
|
||||
var pending = Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending);
|
||||
Assert.NotNull(pending);
|
||||
Assert.Equal("Warning", pending!.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", pending.Payload[SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal("EntraUserAffinityDetector", pending.Payload[SignalPayloadKeys.Source]);
|
||||
Assert.Equal("10", pending.Payload["delayMinutes"]);
|
||||
Assert.Equal("timer_fired", pending.Payload["reason"]);
|
||||
Assert.Equal("3", pending.Payload["tokenFailureCount"]);
|
||||
Assert.Equal("3399548929,3400073247", pending.Payload["tokenFailureCodes"]);
|
||||
Assert.Equal("true", pending.Payload["placeholderActive"]);
|
||||
Assert.Equal("10.0", pending.Payload["minutesSinceDesktop"]);
|
||||
Assert.True(d.HasFiredForTest);
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_after_desktop_cancels_and_posts_token_acquired()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddSeconds(30));
|
||||
var tokenAt = f.Clock.UtcNow.AddMinutes(3.7);
|
||||
d.NotifyUserTokenAcquired(tokenAt);
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(10));
|
||||
d.TriggerFromTest();
|
||||
|
||||
var acquired = Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired);
|
||||
Assert.NotNull(acquired);
|
||||
Assert.Equal("Info", acquired!.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("3.7", acquired.Payload["minutesAfterDesktop"]);
|
||||
Assert.Equal("1", acquired.Payload["tokenFailuresBeforeSuccess"]);
|
||||
Assert.Equal(tokenAt, acquired.OccurredAtUtc);
|
||||
Assert.True(d.TokenAcquiredPostedForTest);
|
||||
Assert.False(d.HasFiredForTest);
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_lines_from_before_the_desktop_are_ignored()
|
||||
{
|
||||
// Device-phase token lines ("Failed to get AAD token" is routine there; a success
|
||||
// can be the device check-in) must neither count nor cancel — the 2-min tolerance
|
||||
// only covers the desktop poll interval.
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow.AddMinutes(-20));
|
||||
d.NotifyTokenFailureLine("3399548929", f.Clock.UtcNow.AddMinutes(-15));
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(10));
|
||||
d.TriggerFromTest();
|
||||
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
|
||||
var pending = Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending);
|
||||
Assert.NotNull(pending);
|
||||
Assert.Equal("0", pending!.Payload!["tokenFailureCount"]);
|
||||
Assert.Equal(string.Empty, pending.Payload["tokenFailureCodes"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_within_desktop_poll_tolerance_counts_as_after_desktop()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow.AddSeconds(-90));
|
||||
|
||||
Assert.NotNull(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
|
||||
Assert.Equal("0.0", Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired)!.Payload!["minutesAfterDesktop"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_without_arm_is_ignored()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow);
|
||||
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pending_fires_once_and_a_late_token_still_resolves_it_once()
|
||||
{
|
||||
// A token that arrives AFTER the warning is the most useful thing the timeline can
|
||||
// show ("affinity came late") and lets the backend rule drop the finding — so it is
|
||||
// posted, but like the warning only once per process.
|
||||
using var f = new Fixture();
|
||||
using var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.NotifyRealUserDesktop();
|
||||
f.Clock.Advance(TimeSpan.FromMinutes(10));
|
||||
d.TriggerFromTest();
|
||||
d.TriggerFromTest();
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow);
|
||||
d.NotifyUserTokenAcquired(f.Clock.UtcNow.AddMinutes(1));
|
||||
|
||||
Assert.Single(f.Ingress.Posted, p =>
|
||||
p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == SharedEventTypes.EntraUserAffinityPending);
|
||||
Assert.Single(f.Ingress.Posted, p =>
|
||||
p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == SharedEventTypes.ImeUserTokenAcquired);
|
||||
Assert.Equal("10.0", Find(f.Ingress, SharedEventTypes.ImeUserTokenAcquired)!.Payload!["minutesAfterDesktop"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hybrid_probe_throwing_does_not_arm()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var d = new EntraUserAffinityDetector(
|
||||
f.Post, f.Logger,
|
||||
isHybridJoinProbe: () => throw new InvalidOperationException("registry"),
|
||||
utcNow: () => f.Clock.UtcNow);
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
|
||||
Assert.False(d.IsArmedForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_after_arm_does_not_throw_and_blocks_emission()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
var d = f.Build();
|
||||
|
||||
d.NotifyRealUserDesktop();
|
||||
d.Dispose();
|
||||
d.TriggerFromTest();
|
||||
|
||||
Assert.Null(Find(f.Ingress, SharedEventTypes.EntraUserAffinityPending));
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -73,6 +73,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
Assert.Equal("true", info.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(304, "join phase")]
|
||||
[InlineData(305, "authentication phase")]
|
||||
public void ProcessHelloEvent_304_305_emit_device_registration_event_with_immediate_upload(int eventId, string phaseLabel)
|
||||
{
|
||||
// Hybrid affinity companion (2026-09-04): UDR 304/305 ride the already-armed
|
||||
// watcher and surface as device_registration_event — a Warning, flushed at once,
|
||||
// and never a Hello completion.
|
||||
using var f = new Fixture();
|
||||
|
||||
f.Tracker.ProcessHelloEvent(eventId, Fixed, "Microsoft-Windows-User Device Registration", isBackfill: false);
|
||||
|
||||
var info = f.InfoEvent("device_registration_event");
|
||||
Assert.Equal("Warning", info.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Contains(phaseLabel, info.Payload[SignalPayloadKeys.Message]);
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p =>
|
||||
p.Payload != null && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et.StartsWith("hello_provisioning"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloEvent_358_willlaunch_is_suppressed()
|
||||
{
|
||||
|
||||
+52
@@ -194,6 +194,58 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
Assert.NotNull(FindHybridLoginPending(f.Ingress));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Real-user desktop cancel path (2026-09-04, session a7140f98)
|
||||
// ============================================================================
|
||||
|
||||
[Fact]
|
||||
public void Real_user_desktop_after_Arm_cancels_emission()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger);
|
||||
|
||||
detector.Arm();
|
||||
detector.NotifyRealUserDesktop();
|
||||
detector.TriggerFromTest();
|
||||
|
||||
Assert.True(detector.IsCancelledByDesktopForTest);
|
||||
Assert.False(detector.HasFiredForTest);
|
||||
Assert.Null(FindHybridLoginPending(f.Ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Real_user_desktop_before_Arm_short_circuits_Arm()
|
||||
{
|
||||
// The DAD first poll runs seconds after start; the arm request comes from the
|
||||
// runtime host slightly later. A desktop seen first must make the arm a no-op.
|
||||
using var f = new Fixture();
|
||||
using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger);
|
||||
|
||||
detector.NotifyRealUserDesktop();
|
||||
detector.Arm();
|
||||
detector.TriggerFromTest();
|
||||
|
||||
Assert.False(detector.IsArmedForTest);
|
||||
Assert.Null(FindHybridLoginPending(f.Ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emission_reports_desktop_absence_and_placeholder_as_fact()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var detector = new HybridLoginPendingDetector(f.Watcher, f.Post, f.Logger);
|
||||
|
||||
detector.Arm();
|
||||
detector.TriggerFromTest();
|
||||
|
||||
var info = FindHybridLoginPending(f.Ingress);
|
||||
Assert.NotNull(info);
|
||||
Assert.Equal("false", info!.Payload!["realUserDesktopSeen"]);
|
||||
Assert.Equal("false", info.Payload["placeholderActive"]); // watcher never saw JoinInfo in this fixture
|
||||
Assert.Contains("no real-user desktop", info.Payload[SignalPayloadKeys.Message]);
|
||||
Assert.DoesNotContain("placeholder still active", info.Payload[SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lifecycle / Dispose
|
||||
// ============================================================================
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Backlog era anchoring (2026-09-04, session a7140f98). The property under test: the
|
||||
/// bootstrap execution record — stdout in AgentExecutor.log, policy result in the service
|
||||
/// log — measured against the install marker anchors exactly the era it sits in; a SKIP
|
||||
/// re-run, a missing marker, an MSI bootstrap and an off-grid residual anchor nothing; the
|
||||
/// anchor never crosses an "EMS Agent Started" boundary; sibling files join by local time
|
||||
/// only while the service eras are unambiguous.
|
||||
/// </summary>
|
||||
public sealed class ImeLogEraPreScanTests
|
||||
{
|
||||
private static readonly DateTime Deployed = new DateTime(2026, 9, 4, 13, 31, 49, 100, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Pacific = TimeSpan.FromHours(-7);
|
||||
private static readonly TimeSpan Eastern = TimeSpan.FromHours(-4);
|
||||
private const string PolicyId = "93d9df23-1111-2222-3333-444455556666";
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant, TimeSpan writerOffset)
|
||||
{
|
||||
var local = utcInstant + writerOffset;
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{local:HH:mm:ss.fffffff}\" date=\"{local:M-d-yyyy}\" " +
|
||||
"component=\"X\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
|
||||
private static string ScriptStart(DateTime utc, TimeSpan offset) =>
|
||||
Entry($@"Adding argument powershell with value C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\00000000-0000-0000-0000-000000000000_{PolicyId}.ps1", utc, offset);
|
||||
|
||||
private static string BootstrapStdout(DateTime utc, TimeSpan offset, string tail = "===== Bootstrap Completed Successfully =====") =>
|
||||
Entry("write output done. output = ===== Autopilot Monitor Bootstrap Started =====\nBootstrap script version: v2.0\nAgent install mode completed successfully\n" + tail + ", error = ", utc, offset);
|
||||
|
||||
private static string PolicyResult(DateTime utc, TimeSpan offset, string policyId = PolicyId) =>
|
||||
Entry($"[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = {policyId}, policy result = Success", utc, offset);
|
||||
|
||||
private static string ServiceStarted(DateTime utc, TimeSpan offset) => Entry("EMS Agent Started", utc, offset);
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
private readonly List<ImeLogEraPreScan.ScanInput> _inputs = new List<ImeLogEraPreScan.ScanInput>();
|
||||
|
||||
public string Write(string fileName, params string[] entries)
|
||||
{
|
||||
var path = Path.Combine(Tmp.Path, fileName);
|
||||
File.WriteAllText(path, string.Join(Environment.NewLine, entries) + Environment.NewLine);
|
||||
_inputs.Add(new ImeLogEraPreScan.ScanInput(path, 0));
|
||||
return path;
|
||||
}
|
||||
|
||||
public long OffsetOfLine(string fileName, int lineIndex)
|
||||
{
|
||||
// Entries are written line by line; the byte offset of entry N is the sum of the
|
||||
// preceding lines (CRLF-agnostic through Environment.NewLine).
|
||||
var path = Path.Combine(Tmp.Path, fileName);
|
||||
var lines = File.ReadAllLines(path);
|
||||
long offset = 0;
|
||||
for (var i = 0; i < lineIndex; i++)
|
||||
offset += System.Text.Encoding.UTF8.GetByteCount(lines[i]) + Environment.NewLine.Length;
|
||||
return offset;
|
||||
}
|
||||
|
||||
public CmTraceEraTable Build(DateTime? deployed) =>
|
||||
ImeLogEraPreScan.Build(_inputs, deployed, ImeLogTracker.MaxEntryBytes, null);
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bootstrap_record_anchors_service_era_and_executor_block_only_within_their_boundaries()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log",
|
||||
ScriptStart(Deployed.AddSeconds(-20), Pacific),
|
||||
BootstrapStdout(Deployed.AddMilliseconds(200), Pacific),
|
||||
Entry("Adding argument powershell with value C:\\x\\Policies\\Scripts\\u_aaaaaaaa-0000-0000-0000-000000000000.ps1", Deployed.AddMinutes(5), Pacific),
|
||||
Entry("write output done. output = other script, error = ", Deployed.AddMinutes(5).AddSeconds(2), Pacific));
|
||||
f.Write("IntuneManagementExtension.log",
|
||||
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(-1), Pacific), // line 0, era 0
|
||||
PolicyResult(Deployed.AddMilliseconds(260), Pacific), // line 1, anchor
|
||||
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(10), Pacific), // line 2, era 0
|
||||
ServiceStarted(Deployed.AddMinutes(20), Eastern), // line 3, era 1 (post-boot, new zone)
|
||||
Entry("Successfully get the token", Deployed.AddMinutes(21), Eastern)); // line 4, era 1
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.Equal(2, table.AnchoredEraCount);
|
||||
Assert.False(table.TransferAmbiguous);
|
||||
|
||||
// Service era 0: every offset before "EMS Agent Started" resolves to the Pacific offset.
|
||||
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 0), out var off0, out var kind0));
|
||||
Assert.Equal(Pacific, off0);
|
||||
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapPolicyResult, kind0);
|
||||
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 2), out var off2, out _));
|
||||
Assert.Equal(Pacific, off2);
|
||||
|
||||
// Era 1 has no anchor of its own — the Pacific anchor must not leak across the boundary.
|
||||
Assert.False(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 3), out _, out _));
|
||||
Assert.False(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 4), out _, out _));
|
||||
|
||||
// Executor: only the bootstrap execution block is anchored, not the next script's block.
|
||||
Assert.True(table.TryResolveByOffset("AgentExecutor.log", f.OffsetOfLine("AgentExecutor.log", 0), out var offX, out var kindX));
|
||||
Assert.Equal(Pacific, offX);
|
||||
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapStdout, kindX);
|
||||
Assert.False(table.TryResolveByOffset("AgentExecutor.log", f.OffsetOfLine("AgentExecutor.log", 5), out _, out _));
|
||||
|
||||
// Transfer for in-process sibling files: a local time inside era 0's range resolves,
|
||||
// one inside era 1's range does not.
|
||||
Assert.True(table.TryResolveByLocalTime(Deployed.AddMinutes(5) + Pacific, out var offT, out var kindT));
|
||||
Assert.Equal(Pacific, offT);
|
||||
Assert.EndsWith("/service-era-transfer", kindT);
|
||||
Assert.False(table.TryResolveByLocalTime(Deployed.AddMinutes(25) + Eastern, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_deployed_marker_nothing_is_anchored()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log", ScriptStart(Deployed.AddSeconds(-20), Pacific), BootstrapStdout(Deployed.AddMilliseconds(200), Pacific));
|
||||
f.Write("IntuneManagementExtension.log", PolicyResult(Deployed.AddMilliseconds(260), Pacific));
|
||||
|
||||
var table = f.Build(null);
|
||||
|
||||
Assert.Equal(0, table.AnchoredEraCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Msi_bootstrap_without_stdout_record_anchors_nothing()
|
||||
{
|
||||
// The MSI runner never passes through AgentExecutor; without the policy id the
|
||||
// service log's result lines cannot be told apart from any other script's.
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log", Entry("Adding argument powershell with value C:\\x\\Policies\\Scripts\\u_aaaaaaaa-0000-0000-0000-000000000000.ps1", Deployed.AddMinutes(3), Pacific));
|
||||
f.Write("IntuneManagementExtension.log", PolicyResult(Deployed.AddMilliseconds(260), Pacific));
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.Equal(0, table.AnchoredEraCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skip_rerun_record_is_never_offered_and_first_record_off_grid_anchors_nothing()
|
||||
{
|
||||
// The install run is the FIRST bootstrap record; a SKIP re-run 15 minutes later
|
||||
// would round onto the grid with zero residual and anchor a wrong offset.
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log",
|
||||
ScriptStart(Deployed.AddMinutes(-9), Pacific),
|
||||
BootstrapStdout(Deployed.AddMinutes(-8), Pacific), // first record: residual 8 min → rejected
|
||||
ScriptStart(Deployed.AddMinutes(15).AddSeconds(-2), Pacific),
|
||||
BootstrapStdout(Deployed.AddMinutes(15), Pacific)); // would be a perfect (wrong) grid hit
|
||||
f.Write("IntuneManagementExtension.log",
|
||||
PolicyResult(Deployed.AddMinutes(-8), Pacific),
|
||||
PolicyResult(Deployed.AddMinutes(15), Pacific));
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.Equal(0, table.AnchoredEraCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Service_era_continues_across_a_log_rotation()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log", ScriptStart(Deployed.AddSeconds(-20), Pacific), BootstrapStdout(Deployed.AddMilliseconds(200), Pacific));
|
||||
f.Write("IntuneManagementExtension-20260904-133000.log",
|
||||
PolicyResult(Deployed.AddMilliseconds(260), Pacific),
|
||||
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(2), Pacific));
|
||||
f.Write("IntuneManagementExtension.log",
|
||||
Entry("In EspPhase: DeviceSetup", Deployed.AddMinutes(12), Pacific), // same process, rotated file
|
||||
ServiceStarted(Deployed.AddMinutes(20), Eastern));
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", 0, out var off, out _));
|
||||
Assert.Equal(Pacific, off);
|
||||
Assert.False(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 1), out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overlapping_service_era_local_ranges_disable_the_transfer()
|
||||
{
|
||||
// A westward zone change between two service starts makes era 1 start "before"
|
||||
// era 0 in local time; the sibling-file transfer must refuse rather than guess.
|
||||
using var f = new Fixture();
|
||||
f.Write("AgentExecutor.log", ScriptStart(Deployed.AddSeconds(-20), TimeSpan.FromHours(2)), BootstrapStdout(Deployed.AddMilliseconds(200), TimeSpan.FromHours(2)));
|
||||
f.Write("IntuneManagementExtension.log",
|
||||
ServiceStarted(Deployed.AddMinutes(-30), TimeSpan.FromHours(2)),
|
||||
PolicyResult(Deployed.AddMilliseconds(260), TimeSpan.FromHours(2)),
|
||||
ServiceStarted(Deployed.AddMinutes(20), TimeSpan.FromHours(-7)));
|
||||
|
||||
var table = f.Build(Deployed);
|
||||
|
||||
Assert.True(table.TransferAmbiguous);
|
||||
Assert.False(table.TryResolveByLocalTime(Deployed.AddMinutes(5) + TimeSpan.FromHours(2), out _, out _));
|
||||
// Offset-based lookup is unaffected.
|
||||
Assert.True(table.TryResolveByOffset("IntuneManagementExtension.log", f.OffsetOfLine("IntuneManagementExtension.log", 1), out var off, out _));
|
||||
Assert.Equal(TimeSpan.FromHours(2), off);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deployment_marker_parses_roundtrip_utc()
|
||||
{
|
||||
Assert.Equal(Deployed, AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.Parse("2026-09-04T13:31:49.1000000Z"));
|
||||
Assert.Null(AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.Parse(""));
|
||||
Assert.Null(AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.Parse("not a date"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end through the tracker: a restart/first-sight backlog whose pre-reboot era was
|
||||
/// written under the OOBE default zone (a7140f98: Pacific) while the agent believes the
|
||||
/// zone Windows set later (Eastern). With the bootstrap record anchoring that era, every
|
||||
/// pre-reboot line resolves to its true instant with origin <c>era-anchored</c>; the
|
||||
/// post-reboot era without an anchor keeps the marked fallback; in-process sibling files
|
||||
/// join by local time; a fresh pass still self-anchors per line.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerEraAnchoringTests
|
||||
{
|
||||
private static readonly DateTime Deployed = new DateTime(2026, 9, 4, 13, 31, 49, 100, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Pacific = TimeSpan.FromHours(-7);
|
||||
private static readonly TimeSpan Eastern = TimeSpan.FromHours(-4);
|
||||
private const string PolicyId = "93d9df23-1111-2222-3333-444455556666";
|
||||
|
||||
private static List<ImeLogPattern> Patterns() => new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-ALL", Category = "always", Enabled = true,
|
||||
Pattern = @"marker (?<n>\d+)",
|
||||
Action = "noop",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
};
|
||||
|
||||
private static string Entry(string message, DateTime utcInstant, TimeSpan writerOffset)
|
||||
{
|
||||
var local = utcInstant + writerOffset;
|
||||
return $"<![LOG[{message}]LOG]!><time=\"{local:HH:mm:ss.fffffff}\" date=\"{local:M-d-yyyy}\" " +
|
||||
"component=\"X\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public ImeLogTracker Tracker { get; }
|
||||
public DateTime Now { get; set; } = Deployed.AddMinutes(30);
|
||||
public List<(string Message, DateTime? Ts, CmTraceOffsetOrigin Origin, int? Offset, string Anchor)> Resolved { get; } =
|
||||
new List<(string, DateTime?, CmTraceOffsetOrigin, int?, string)>();
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Tracker = new ImeLogTracker(
|
||||
logFolder: _tmp.Path,
|
||||
patterns: Patterns(),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info));
|
||||
Tracker.UtcNowProvider = () => Now;
|
||||
Tracker.DeployedUtcProvider = () => Deployed;
|
||||
Tracker.OnPatternMatched += _ => Resolved.Add((
|
||||
Tracker.LastMatchedPatternId ?? string.Empty,
|
||||
Tracker.LastMatchedLogTimestamp,
|
||||
Tracker.LastMatchedSourceOffsetOrigin,
|
||||
Tracker.LastMatchedSourceOffsetMinutes,
|
||||
Tracker.LastMatchedEraAnchorKind));
|
||||
}
|
||||
|
||||
public void Append(string fileName, params string[] lines)
|
||||
=> File.AppendAllText(Path.Combine(_tmp.Path, fileName), string.Join(Environment.NewLine, lines) + Environment.NewLine);
|
||||
|
||||
public Task Pass() => Tracker.CheckLogFilesAsync(CancellationToken.None);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backlog_lines_of_the_bootstrap_era_resolve_through_the_anchor_and_the_next_era_stays_on_fallback()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Append("AgentExecutor.log",
|
||||
Entry($@"Adding argument powershell with value C:\x\Policies\Scripts\u_{PolicyId}.ps1", Deployed.AddSeconds(-20), Pacific),
|
||||
Entry("write output done. output = ===== Autopilot Monitor Bootstrap Started =====\n===== Bootstrap Completed Successfully =====, error = ", Deployed.AddMilliseconds(200), Pacific),
|
||||
Entry("marker 10", Deployed.AddSeconds(1), Pacific));
|
||||
h.Append("IntuneManagementExtension.log",
|
||||
Entry("marker 1", Deployed.AddMinutes(-1), Pacific),
|
||||
Entry($"[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = {PolicyId}, policy result = Success", Deployed.AddMilliseconds(260), Pacific),
|
||||
Entry("marker 2", Deployed.AddMinutes(10), Pacific),
|
||||
Entry("EMS Agent Started", Deployed.AddMinutes(20), Eastern),
|
||||
Entry("marker 3", Deployed.AddMinutes(21), Eastern));
|
||||
h.Append("AppWorkload.log",
|
||||
Entry("marker 4", Deployed.AddMinutes(5), Pacific),
|
||||
Entry("marker 5", Deployed.AddMinutes(25), Eastern));
|
||||
|
||||
h.Tracker.PreScanEras();
|
||||
await h.Pass(); // first sight: backlog, never fresh
|
||||
|
||||
var byMarker = new Dictionary<string, (string Message, DateTime? Ts, CmTraceOffsetOrigin Origin, int? Offset, string Anchor)>();
|
||||
foreach (var r in h.Resolved) byMarker[r.Message] = r;
|
||||
Assert.Equal(6, h.Resolved.Count);
|
||||
|
||||
// Marker text is the pattern id (T-ALL) — index by resolution order instead.
|
||||
var r1 = h.Resolved[0]; // AgentExecutor marker 10 (files sort: AgentExecutor < AppWorkload < IntuneManagementExtension)
|
||||
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, r1.Origin);
|
||||
Assert.Equal(Deployed.AddSeconds(1), r1.Ts);
|
||||
Assert.Equal(-420, r1.Offset);
|
||||
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapStdout, r1.Anchor);
|
||||
|
||||
var r4 = h.Resolved[1]; // AppWorkload marker 4 — transfer by local time into the service era
|
||||
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, r4.Origin);
|
||||
Assert.Equal(Deployed.AddMinutes(5), r4.Ts);
|
||||
Assert.EndsWith("/service-era-transfer", r4.Anchor);
|
||||
|
||||
var r5 = h.Resolved[2]; // AppWorkload marker 5 — post-boot era, unanchored
|
||||
Assert.Equal(CmTraceOffsetOrigin.None, r5.Origin);
|
||||
Assert.Equal(string.Empty, r5.Anchor);
|
||||
|
||||
var rm1 = h.Resolved[3]; // IME marker 1
|
||||
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, rm1.Origin);
|
||||
Assert.Equal(Deployed.AddMinutes(-1), rm1.Ts);
|
||||
Assert.Equal(ImeLogEraPreScan.AnchorKindBootstrapPolicyResult, rm1.Anchor);
|
||||
|
||||
var rm2 = h.Resolved[4]; // IME marker 2
|
||||
Assert.Equal(CmTraceOffsetOrigin.EraAnchored, rm2.Origin);
|
||||
Assert.Equal(Deployed.AddMinutes(10), rm2.Ts);
|
||||
|
||||
var rm3 = h.Resolved[5]; // IME marker 3 — after "EMS Agent Started", no anchor
|
||||
Assert.Equal(CmTraceOffsetOrigin.None, rm3.Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fresh_lines_still_self_anchor_after_the_pre_scan()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Append("IntuneManagementExtension.log", Entry("marker 1", h.Now, Eastern));
|
||||
h.Tracker.PreScanEras();
|
||||
await h.Pass();
|
||||
|
||||
h.Now = h.Now.AddSeconds(10);
|
||||
var writtenAt = h.Now.AddMilliseconds(-80);
|
||||
h.Append("IntuneManagementExtension.log", Entry("marker 2", writtenAt, Eastern));
|
||||
await h.Pass();
|
||||
|
||||
var last = h.Resolved[h.Resolved.Count - 1];
|
||||
Assert.Equal(CmTraceOffsetOrigin.LineAnchored, last.Origin);
|
||||
Assert.Equal(writtenAt, last.Ts);
|
||||
Assert.Equal(string.Empty, last.Anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_marker_the_backlog_keeps_the_reader_zone_fallback()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Tracker.DeployedUtcProvider = () => null;
|
||||
h.Append("IntuneManagementExtension.log", Entry("marker 1", Deployed.AddMinutes(-1), Pacific));
|
||||
h.Tracker.PreScanEras();
|
||||
await h.Pass();
|
||||
|
||||
Assert.Single(h.Resolved);
|
||||
Assert.Equal(CmTraceOffsetOrigin.None, h.Resolved[0].Origin);
|
||||
Assert.Equal(0, h.Tracker.EraTable.AnchoredEraCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Raw per-line token callbacks for the Entra user-affinity detector (2026-09-04).
|
||||
/// <c>OnUserTokenAcquired</c> fires on EVERY IME-TOKEN-SUCCESS line — independent of the
|
||||
/// phase-change dedup that swallows the second AccountSetup detection — and
|
||||
/// <c>OnTokenFailureLine</c> on every failure line; both honour the historic-replay gate.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerTokenLineCallbackTests
|
||||
{
|
||||
private static readonly DateTime Now = new DateTime(2026, 9, 4, 14, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static List<ImeLogPattern> Patterns() => new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "IME-TOKEN-SUCCESS", Category = "always", Enabled = true,
|
||||
Pattern = "^Successfully get the token",
|
||||
Action = "espPhaseDetected",
|
||||
Parameters = new Dictionary<string, string> { ["phase"] = "AccountSetup" },
|
||||
},
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "IME-TOKEN-FAILURE", Category = "always", Enabled = true,
|
||||
Pattern = @"^Failed to get AAD token\..*?errorCode = (?<errorCode>.*?)$",
|
||||
Action = "imeTokenFailure",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
};
|
||||
|
||||
private static ImeLogTracker Build(TempDirectory tmp)
|
||||
{
|
||||
var tracker = new ImeLogTracker(
|
||||
logFolder: tmp.Path,
|
||||
patterns: Patterns(),
|
||||
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
|
||||
tracker.UtcNowProvider = () => Now;
|
||||
return tracker;
|
||||
}
|
||||
|
||||
private const string Success = "Successfully get the token with client id fc0f3af4-6835-4174-b806-f7db311fd2f3 and resource id 26a4ae64-5862-427f-a9b0-044e62572a4f";
|
||||
private const string Failure = "Failed to get AAD token. len = 34 using client id fc0f3af4-6835-4174-b806-f7db311fd2f3 and resource id 26a4ae64-5862-427f-a9b0-044e62572a4f, errorCode = 3399548929";
|
||||
|
||||
[Fact]
|
||||
public void Every_token_success_line_invokes_callback_even_when_phase_is_deduplicated()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = Build(tmp);
|
||||
var tokens = new List<DateTime?>();
|
||||
tracker.OnUserTokenAcquired = ts => tokens.Add(ts);
|
||||
|
||||
// The phase-change dedup lives in the adapter (AccountSetup is emitted once);
|
||||
// the raw token callback must fire for BOTH lines regardless.
|
||||
tracker.ProcessLogMessageForTest(Success, Now.AddMinutes(-6));
|
||||
tracker.ProcessLogMessageForTest(Success, Now.AddMinutes(-2));
|
||||
|
||||
Assert.Equal(2, tokens.Count);
|
||||
Assert.Equal(Now.AddMinutes(-6), tokens[0]);
|
||||
Assert.Equal(Now.AddMinutes(-2), tokens[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_failure_line_invokes_callback_with_error_code()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = Build(tmp);
|
||||
var failures = new List<(string code, DateTime? ts)>();
|
||||
tracker.OnTokenFailureLine = (code, ts) => failures.Add((code, ts));
|
||||
|
||||
tracker.ProcessLogMessageForTest(Failure, Now.AddMinutes(-5));
|
||||
tracker.ProcessLogMessageForTest(Failure, Now.AddMinutes(-4));
|
||||
|
||||
Assert.Equal(2, failures.Count);
|
||||
Assert.All(failures, f => Assert.Equal("3399548929", f.code));
|
||||
Assert.Equal(Now.AddMinutes(-5), failures[0].ts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Historic_replay_lines_do_not_invoke_either_callback()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = Build(tmp);
|
||||
var tokens = 0;
|
||||
var failures = 0;
|
||||
tracker.OnUserTokenAcquired = _ => tokens++;
|
||||
tracker.OnTokenFailureLine = (_, __) => failures++;
|
||||
|
||||
tracker.ProcessLogMessageForTest(Success, Now.AddDays(-3));
|
||||
tracker.ProcessLogMessageForTest(Failure, Now.AddDays(-3));
|
||||
|
||||
Assert.Equal(0, tokens);
|
||||
Assert.Equal(0, failures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Callback_exception_does_not_break_the_drain()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
using var tracker = Build(tmp);
|
||||
tracker.OnUserTokenAcquired = _ => throw new InvalidOperationException("boom");
|
||||
tracker.OnTokenFailureLine = (_, __) => throw new InvalidOperationException("boom");
|
||||
|
||||
tracker.ProcessLogMessageForTest(Success, Now.AddMinutes(-1));
|
||||
tracker.ProcessLogMessageForTest(Failure, Now.AddMinutes(-1));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -11,6 +11,7 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
@@ -42,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
// items must be a whiteglove_resumed event.
|
||||
Assert.DoesNotContain(
|
||||
rig.Uploader.Received.SelectMany(b => b),
|
||||
item => item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
|
||||
item => item.Kind == TelemetryItemKind.Event &&
|
||||
item.PayloadJson != null &&
|
||||
item.PayloadJson.Contains("\"whiteglove_resumed\""));
|
||||
}
|
||||
@@ -75,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
var resumedEvents = rig.Uploader.Received
|
||||
.SelectMany(b => b)
|
||||
.Where(item =>
|
||||
item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
|
||||
item.Kind == TelemetryItemKind.Event &&
|
||||
item.PayloadJson != null &&
|
||||
item.PayloadJson.Contains("\"whiteglove_resumed\""))
|
||||
.ToList();
|
||||
|
||||
@@ -15,6 +15,7 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Signals
|
||||
{
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Transitions
|
||||
{
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ using System.Net.Http;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Events;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Signals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// The agent → backend wire contract of <c>POST /api/agent/telemetry</c>, proven from the
|
||||
/// WRITER side. <see cref="TelemetryItemDto"/> (Shared) is the one class both ends serialise;
|
||||
/// <see cref="TelemetryItem.ToWire"/> is the only mapping onto it.
|
||||
/// <list type="bullet">
|
||||
/// <item>Round trip + reflection: every DTO property is populated by <c>ToWire()</c> and
|
||||
/// survives Newtonsoft defaults — a DTO field added without a mapping fails here.</item>
|
||||
/// <item>Frozen body: the real batch serialiser's output for one item per
|
||||
/// <see cref="TelemetryItemKind"/> is pinned under <c>tests/fixtures/telemetry-wire/</c>.
|
||||
/// The backend's <c>TelemetryWireFixtureTests</c> reads the same files and demands zero
|
||||
/// rejections, so a rename on either side breaks a test until both sides and the fixture
|
||||
/// agree. Regenerate with <c>AM_WRITE_WIRE_FIXTURES=1</c>.</item>
|
||||
/// <item>Poison body: the exact 422 shape the backend writes (pinned there by
|
||||
/// <c>TelemetryWireFixtureTests</c>) is parsed as an item-level poison signal here.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class TelemetryWireContractTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
private const string FixtureFile = "agent-v2.json";
|
||||
|
||||
private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TelemetryItem FullyPopulatedItem() =>
|
||||
new TelemetryItem(
|
||||
kind: TelemetryItemKind.DecisionTransition,
|
||||
partitionKey: $"{TenantId}_{SessionId}",
|
||||
rowKey: "0000000007",
|
||||
telemetryItemId: 42,
|
||||
sessionTraceOrdinal: 7,
|
||||
payloadJson: "{\"StepIndex\":7}",
|
||||
requiresImmediateFlush: true,
|
||||
enqueuedAtUtc: At,
|
||||
retryCount: 3);
|
||||
|
||||
// ============================================================ ToWire mapping
|
||||
|
||||
[Fact]
|
||||
public void ToWire_round_trips_every_property_through_Newtonsoft_defaults()
|
||||
{
|
||||
var item = FullyPopulatedItem();
|
||||
|
||||
var json = JsonConvert.SerializeObject(item.ToWire());
|
||||
var dto = JsonConvert.DeserializeObject<TelemetryItemDto>(json)!;
|
||||
|
||||
Assert.Equal("DecisionTransition", dto.Kind);
|
||||
Assert.Equal(item.PartitionKey, dto.PartitionKey);
|
||||
Assert.Equal(item.RowKey, dto.RowKey);
|
||||
Assert.Equal(item.TelemetryItemId, dto.TelemetryItemId);
|
||||
Assert.Equal(item.SessionTraceOrdinal, dto.SessionTraceOrdinal);
|
||||
Assert.Equal(item.PayloadJson, dto.PayloadJson);
|
||||
Assert.Equal(item.RequiresImmediateFlush, dto.RequiresImmediateFlush);
|
||||
Assert.Equal(item.EnqueuedAtUtc, dto.EnqueuedAtUtc);
|
||||
Assert.Equal(DateTimeKind.Utc, dto.EnqueuedAtUtc.Kind);
|
||||
Assert.Equal(item.RetryCount, dto.RetryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToWire_populates_every_TelemetryItemDto_property()
|
||||
{
|
||||
// A DTO property that ToWire() does not set keeps its default — this catches a field
|
||||
// added to the wire type without a mapping (the backend would read the default forever).
|
||||
var dto = FullyPopulatedItem().ToWire();
|
||||
|
||||
var unset = typeof(TelemetryItemDto)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => IsDefault(p.GetValue(dto), p.PropertyType))
|
||||
.Select(p => p.Name)
|
||||
.ToList();
|
||||
|
||||
Assert.True(unset.Count == 0,
|
||||
"TelemetryItem.ToWire() leaves these TelemetryItemDto properties at their default: " + string.Join(", ", unset));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelemetryItemDto_properties_mirror_TelemetryItem()
|
||||
{
|
||||
var itemProps = typeof(TelemetryItem).GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.ToDictionary(p => p.Name, p => p.PropertyType);
|
||||
|
||||
foreach (var dtoProp in typeof(TelemetryItemDto).GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
Assert.True(itemProps.TryGetValue(dtoProp.Name, out var itemType),
|
||||
$"TelemetryItemDto.{dtoProp.Name} has no counterpart on TelemetryItem");
|
||||
var expected = dtoProp.Name == nameof(TelemetryItemDto.Kind) ? typeof(TelemetryItemKind) : dtoProp.PropertyType;
|
||||
Assert.True(itemType == expected,
|
||||
$"TelemetryItemDto.{dtoProp.Name} is {dtoProp.PropertyType.Name}, TelemetryItem.{dtoProp.Name} is {itemType.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_kind_serialises_by_name_on_the_wire_and_on_the_spool_line()
|
||||
{
|
||||
foreach (var kind in TelemetryItemKinds.All)
|
||||
{
|
||||
var item = new TelemetryItem(kind, "T_S", "rk", 1, 1, "{}", false, At);
|
||||
|
||||
var wire = JObject.Parse(JsonConvert.SerializeObject(item.ToWire()));
|
||||
Assert.Equal(kind.ToString(), (string?)wire["Kind"]);
|
||||
|
||||
// The spool line (TelemetryItemSerializer) has always written the NAME too; the enum's
|
||||
// move to Shared must not turn it into an ordinal (a crash-restart rehydrates it).
|
||||
var spool = JObject.Parse(TelemetryItemSerializer.Serialize(item));
|
||||
Assert.Equal(kind.ToString(), (string?)spool["Kind"]);
|
||||
Assert.Equal(kind, TelemetryItemSerializer.Deserialize(spool.ToString(Formatting.None)).Kind);
|
||||
|
||||
Assert.True(TelemetryItemKinds.TryParse(kind.ToString(), out var parsed));
|
||||
Assert.Equal(kind, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ Frozen wire body
|
||||
|
||||
[Fact]
|
||||
public void Frozen_wire_body_matches_the_current_serializer()
|
||||
{
|
||||
var items = OneItemPerKind();
|
||||
Assert.Equal(TelemetryItemKinds.All.OrderBy(k => k), items.Select(i => i.Kind).Distinct().OrderBy(k => k));
|
||||
|
||||
var body = BackendTelemetryUploader.SerializeBatch(items);
|
||||
var path = Path.Combine(RepoPaths.Fixtures("telemetry-wire"), FixtureFile);
|
||||
|
||||
if (Environment.GetEnvironmentVariable("AM_WRITE_WIRE_FIXTURES") == "1")
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, body + "\n", new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(path), "Missing wire fixture " + path + " — run this test with AM_WRITE_WIRE_FIXTURES=1 to create it.");
|
||||
var frozen = File.ReadAllText(path).TrimEnd('\r', '\n');
|
||||
Assert.True(string.Equals(frozen, body, StringComparison.Ordinal),
|
||||
"The agent's telemetry wire body changed. If the change is intended, update the backend ingest " +
|
||||
"(TelemetryWireFixtureTests must still dispatch every frozen body) and regenerate with " +
|
||||
"AM_WRITE_WIRE_FIXTURES=1.\nExpected: " + frozen + "\nActual: " + body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One item per kind through the REAL emitters (payload shapes are theirs, not hand-typed),
|
||||
/// re-based onto fixed transport fields so the body is byte-stable.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<TelemetryItem> OneItemPerKind()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var counter = new EventSequenceCounter(new EventSequencePersistence(tmp.File("seq.json")));
|
||||
|
||||
new TelemetryEventEmitter(transport, counter, SessionId, TenantId).Emit(new EnrollmentEvent
|
||||
{
|
||||
EventId = "00000000-0000-4000-8000-000000000001",
|
||||
EventType = Constants.EventTypes.AgentStarted,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "Agent",
|
||||
Timestamp = At,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = "Agent started",
|
||||
Data = new Dictionary<string, object> { ["agentVersion"] = "2.0.0" },
|
||||
});
|
||||
|
||||
new TelemetrySignalEmitter(transport, SessionId, TenantId).Emit(new DecisionSignal(
|
||||
sessionSignalOrdinal: 1,
|
||||
sessionTraceOrdinal: 1,
|
||||
kind: DecisionSignalKind.SessionStarted,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: At,
|
||||
sourceOrigin: "Fixture",
|
||||
evidence: new Evidence(EvidenceKind.Raw, "raw-1", "session start")));
|
||||
|
||||
new TelemetryTransitionEmitter(transport, SessionId, TenantId).Emit(new DecisionTransition(
|
||||
stepIndex: 1,
|
||||
sessionTraceOrdinal: 2,
|
||||
signalOrdinalRef: 1,
|
||||
occurredAtUtc: At,
|
||||
trigger: "SessionStarted",
|
||||
fromStage: SessionStage.Unknown,
|
||||
toStage: SessionStage.AwaitingEspPhaseChange,
|
||||
taken: true,
|
||||
deadEndReason: null,
|
||||
reducerVersion: "1.0.0"));
|
||||
|
||||
return transport.Enqueued
|
||||
.Select(e => new TelemetryItem(e.Kind, e.PartitionKey, e.RowKey, e.TelemetryItemId, e.SessionTraceOrdinal,
|
||||
e.PayloadJson, e.RequiresImmediateFlush, At))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ============================================================ Poison body
|
||||
|
||||
/// <summary>
|
||||
/// The exact 422 body the backend writes for rejected items (pinned byte-for-byte in the
|
||||
/// backend's TelemetryWireFixtureTests). camelCase because the backend serialises with
|
||||
/// its wire options; the agent's parser accepts both casings.
|
||||
/// </summary>
|
||||
internal const string BackendPoisonBody =
|
||||
"{\"error\":\"2 of 3 telemetry item(s) cannot be ingested (unknown Kind or unusable payload); they were not stored.\"," +
|
||||
"\"code\":\"TelemetryItemsRejected\",\"correlationId\":\"cid-1\",\"poison\":true,\"rejectedRowKeys\":[\"rk-1\",\"rk-3\"]," +
|
||||
"\"reason\":\"unknown_kind=1;unparseable=1\",\"received\":3,\"rejected\":2}";
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_422_poison_body_is_parsed_as_item_level_poison()
|
||||
{
|
||||
using var handler = new RecordingHttpMessageHandler().QueueStatus((HttpStatusCode)422, BackendPoisonBody);
|
||||
using var http = new HttpClient(handler);
|
||||
var sut = new BackendTelemetryUploader(http, baseUrl: "https://backend.example.invalid", tenantId: TenantId, agentVersion: "2.0.0");
|
||||
|
||||
var result = await sut.UploadBatchAsync(new[] { FullyPopulatedItem() }, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.IsPoison);
|
||||
Assert.Equal(new[] { "rk-1", "rk-3" }, result.PoisonRowKeys);
|
||||
Assert.Equal("unknown_kind=1;unparseable=1", result.ErrorReason);
|
||||
}
|
||||
|
||||
private static bool IsDefault(object? value, Type type)
|
||||
{
|
||||
if (value == null) return true;
|
||||
if (type == typeof(string)) return ((string)value).Length == 0;
|
||||
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return underlying.IsValueType && value.Equals(Activator.CreateInstance(underlying));
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// A writer era: the stretch of log written by ONE process lifetime, i.e. under one
|
||||
/// timezone belief. For the IME service family (IntuneManagementExtension*.log) an era
|
||||
/// starts at an "EMS Agent Started" entry and may continue across a log rotation into the
|
||||
/// next file; for AgentExecutor.log every script execution is its own short-lived process
|
||||
/// and therefore its own era. An era carries an offset only when an anchor inside it was
|
||||
/// matched against an instant the agent knows in UTC (<see cref="ImeLogEraPreScan"/>).
|
||||
/// </summary>
|
||||
internal sealed class CmTraceEra
|
||||
{
|
||||
/// <summary>File segments (file name → [start, end) byte range) that belong to this era, in write order.</summary>
|
||||
public List<CmTraceEraSegment> Segments { get; } = new List<CmTraceEraSegment>();
|
||||
|
||||
/// <summary>Local time of the era's first entry as written (null for a leading era whose start predates the scanned region).</summary>
|
||||
public DateTime? StartLocal { get; set; }
|
||||
|
||||
/// <summary>Local time of the next era's first entry (null for the last era).</summary>
|
||||
public DateTime? EndLocal { get; set; }
|
||||
|
||||
/// <summary>The writer's UTC offset (local = UTC + offset) when anchored.</summary>
|
||||
public TimeSpan? Offset { get; set; }
|
||||
|
||||
/// <summary>What established <see cref="Offset"/> ("bootstrap-policy-result", "bootstrap-stdout", …).</summary>
|
||||
public string? AnchorKind { get; set; }
|
||||
|
||||
public bool IsAnchored => Offset.HasValue;
|
||||
}
|
||||
|
||||
internal sealed class CmTraceEraSegment
|
||||
{
|
||||
public CmTraceEraSegment(string fileName, long startOffset, long endOffset)
|
||||
{
|
||||
FileName = fileName;
|
||||
StartOffset = startOffset;
|
||||
EndOffset = endOffset;
|
||||
}
|
||||
|
||||
public string FileName { get; }
|
||||
public long StartOffset { get; }
|
||||
/// <summary><c>long.MaxValue</c> while the era is still open in this file.</summary>
|
||||
public long EndOffset { get; set; }
|
||||
|
||||
public bool Contains(string fileName, long offset) =>
|
||||
string.Equals(FileName, fileName, StringComparison.OrdinalIgnoreCase)
|
||||
&& offset >= StartOffset && offset < EndOffset;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Era lookup for backlog lines (2026-09-04, session a7140f98). Two lookups:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="TryResolveByOffset"/> — the line's own file and byte offset
|
||||
/// (IME service family and AgentExecutor.log: eras are byte ranges).</description></item>
|
||||
/// <item><description><see cref="TryResolveByLocalTime"/> — transfer for files written by the
|
||||
/// SAME process as the IME service log but without their own era marker (AppWorkload.log,
|
||||
/// AppActionProcessor.log: plugins hosted in IntuneManagementExtension.exe, verified in the
|
||||
/// decompiled build). Within one process every file shares the zone belief, so a local
|
||||
/// timestamp places the line in the service era whose local range contains it. Disabled
|
||||
/// entirely when the service eras' local ranges are not monotonic (a westward zone change
|
||||
/// between two service starts makes the ranges overlap) — ambiguity must never anchor.</description></item>
|
||||
/// </list>
|
||||
/// Everything not resolvable here stays on the marked reader-zone fallback; a partially
|
||||
/// corrected era is never produced because an anchor applies to a whole era or not at all.
|
||||
/// </summary>
|
||||
internal sealed class CmTraceEraTable
|
||||
{
|
||||
private readonly List<CmTraceEra> _offsetEras = new List<CmTraceEra>();
|
||||
private readonly List<CmTraceEra> _serviceEras = new List<CmTraceEra>();
|
||||
private bool _transferAmbiguous;
|
||||
|
||||
public IReadOnlyList<CmTraceEra> ServiceEras => _serviceEras;
|
||||
public bool TransferAmbiguous => _transferAmbiguous;
|
||||
public int AnchoredEraCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var n = 0;
|
||||
foreach (var e in _offsetEras) if (e.IsAnchored) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Register an era addressable by (file, offset). Service eras also take part in the local-time transfer.</summary>
|
||||
public void Add(CmTraceEra era, bool isServiceEra)
|
||||
{
|
||||
_offsetEras.Add(era);
|
||||
if (isServiceEra) _serviceEras.Add(era);
|
||||
}
|
||||
|
||||
/// <summary>Call once after all service eras are added: validates the local ranges used by the transfer.</summary>
|
||||
public void Seal()
|
||||
{
|
||||
_transferAmbiguous = false;
|
||||
DateTime? previousStart = null;
|
||||
foreach (var era in _serviceEras)
|
||||
{
|
||||
if (!era.StartLocal.HasValue) continue;
|
||||
if (previousStart.HasValue && era.StartLocal.Value <= previousStart.Value)
|
||||
{
|
||||
_transferAmbiguous = true;
|
||||
break;
|
||||
}
|
||||
previousStart = era.StartLocal;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryResolveByOffset(string fileName, long offset, out TimeSpan writerOffset, out string anchorKind)
|
||||
{
|
||||
writerOffset = TimeSpan.Zero;
|
||||
anchorKind = string.Empty;
|
||||
if (string.IsNullOrEmpty(fileName) || offset < 0) return false;
|
||||
|
||||
foreach (var era in _offsetEras)
|
||||
{
|
||||
if (!era.IsAnchored) continue;
|
||||
foreach (var seg in era.Segments)
|
||||
{
|
||||
if (seg.Contains(fileName, offset))
|
||||
{
|
||||
writerOffset = era.Offset!.Value;
|
||||
anchorKind = era.AnchorKind ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryResolveByLocalTime(DateTime localTimestamp, out TimeSpan writerOffset, out string anchorKind)
|
||||
{
|
||||
writerOffset = TimeSpan.Zero;
|
||||
anchorKind = string.Empty;
|
||||
if (_transferAmbiguous) return false;
|
||||
|
||||
var local = DateTime.SpecifyKind(localTimestamp, DateTimeKind.Unspecified);
|
||||
foreach (var era in _serviceEras)
|
||||
{
|
||||
if (!era.IsAnchored) continue;
|
||||
// A leading era with unknown start covers everything before its end.
|
||||
var afterStart = !era.StartLocal.HasValue || local >= era.StartLocal.Value;
|
||||
var beforeEnd = !era.EndLocal.HasValue || local < era.EndLocal.Value;
|
||||
if (afterStart && beforeEnd)
|
||||
{
|
||||
writerOffset = era.Offset!.Value;
|
||||
anchorKind = era.AnchorKind + "/service-era-transfer";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -27,6 +27,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <see cref="CmTraceOffsetCalibrator.TryMeasureOffset"/>.
|
||||
/// </summary>
|
||||
LineAnchored = 3,
|
||||
|
||||
/// <summary>
|
||||
/// A backlog line resolved through its writer ERA: the stretch of log written by one
|
||||
/// process lifetime, anchored by an entry inside that era whose UTC instant the agent
|
||||
/// knows exactly (the install marker vs. the IME's own record of the bootstrap script's
|
||||
/// result — a few hundred milliseconds apart). An anchor applies to a whole era or not
|
||||
/// at all, never across an "EMS Agent Started" boundary. See <c>ImeLogEraPreScan</c>
|
||||
/// (2026-09-04, session a7140f98).
|
||||
/// </summary>
|
||||
EraAnchored = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <see cref="CmTraceEraTable"/> for the backlog the tracker is about to read
|
||||
/// (2026-09-04, session a7140f98: the IME service wrote the whole device-ESP era under the
|
||||
/// OOBE default zone, Windows moved the zone before the agent started, and the restart
|
||||
/// replay put every pre-reboot line three hours early — a 20-minute DeviceSetup became a
|
||||
/// "blocking app timeout").
|
||||
/// <para>
|
||||
/// Backlog lines can never self-anchor (their age would round onto the offset grid), so an
|
||||
/// era needs an entry whose UTC instant is known independently. The one such entry every
|
||||
/// platform-script bootstrap leaves behind is the bootstrap script's own execution: the
|
||||
/// install marker (<c>HKLM\SOFTWARE\AutopilotMonitor\Deployed</c>, UTC) is written
|
||||
/// milliseconds before the script exits, and the IME logs that exit twice — AgentExecutor.log
|
||||
/// records the script's stdout ("Bootstrap Completed Successfully"), and the service log
|
||||
/// records "[PowerShell] … Policy id = <bootstrap>, policy result = …". Field measurement
|
||||
/// (4 sessions, 2026-09-04): 0.13–0.28 s between marker and stdout record, 0.02–0.08 s more to
|
||||
/// the service record — against a 15-minute grid with a 2-minute residual guard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Rules that keep this sound: only the FIRST bootstrap record of each file is offered (a
|
||||
/// SKIP re-run minutes later could round onto the grid); the residual guard is
|
||||
/// <see cref="CmTraceOffsetCalibrator.TryMeasureOffset"/>; an anchor applies to the whole
|
||||
/// era between two "EMS Agent Started" entries (service) or one script execution
|
||||
/// (AgentExecutor) and never beyond; nothing is anchored without a marker (MSI bootstrap,
|
||||
/// replay tooling). Everything else stays on the marked reader-zone fallback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class ImeLogEraPreScan
|
||||
{
|
||||
internal const string AnchorKindBootstrapStdout = "bootstrap-stdout";
|
||||
internal const string AnchorKindBootstrapPolicyResult = "bootstrap-policy-result";
|
||||
|
||||
private const string ServiceStartedPrefix = "EMS Agent Started";
|
||||
private const string ExecutorScriptStartPrefix = "Adding argument powershell with value ";
|
||||
private const string ExecutorOutputPrefix = "write output done.";
|
||||
private const string BootstrapInstallRunMarker = "Bootstrap Completed Successfully";
|
||||
private const string BootstrapInstallRunMarkerAlt = "Agent install mode completed successfully";
|
||||
|
||||
private static readonly Regex ExecutorScriptPolicyId = new Regex(
|
||||
@"Policies\\Scripts\\[a-z0-9\-]+_(?<id>[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\.ps1",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||
|
||||
private static readonly Regex ServicePolicyResult = new Regex(
|
||||
@"^\[PowerShell\] User Id = [a-f0-9\-]+, Policy id = (?<id>[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}), policy result = ",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||
|
||||
private const int MaxEntryLines = 100;
|
||||
|
||||
public sealed class ScanInput
|
||||
{
|
||||
public ScanInput(string filePath, long startOffset)
|
||||
{
|
||||
FilePath = filePath;
|
||||
StartOffset = startOffset;
|
||||
}
|
||||
|
||||
public string FilePath { get; }
|
||||
public long StartOffset { get; }
|
||||
}
|
||||
|
||||
public static bool IsServiceLogFile(string fileName) =>
|
||||
fileName.StartsWith("IntuneManagementExtension", StringComparison.OrdinalIgnoreCase)
|
||||
|| fileName.StartsWith("_IntuneManagementExtension", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsExecutorLogFile(string fileName) =>
|
||||
fileName.StartsWith("AgentExecutor", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Files written in-process by the IME service without an era marker of their own (transfer by local time).</summary>
|
||||
public static bool IsServiceHostedFile(string fileName) =>
|
||||
fileName.StartsWith("AppWorkload", StringComparison.OrdinalIgnoreCase)
|
||||
|| fileName.StartsWith("AppActionProcessor", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Scan the given files (sort order = write order; archived before current) from their
|
||||
/// start offsets to EOF and return the era table. Never throws; a file that cannot be
|
||||
/// read contributes no eras.
|
||||
/// </summary>
|
||||
public static CmTraceEraTable Build(
|
||||
IReadOnlyList<ScanInput> files,
|
||||
DateTime? deployedUtc,
|
||||
int maxEntryBytes,
|
||||
AgentLogger? logger)
|
||||
{
|
||||
var table = new CmTraceEraTable();
|
||||
if (!deployedUtc.HasValue)
|
||||
{
|
||||
logger?.Info("ImeLogEraPreScan: no Deployed marker — backlog eras stay on the reader-zone fallback");
|
||||
return table;
|
||||
}
|
||||
|
||||
string? bootstrapPolicyId = null;
|
||||
|
||||
// AgentExecutor first: it is where the bootstrap policy id becomes known.
|
||||
foreach (var input in files)
|
||||
{
|
||||
if (!IsExecutorLogFile(Path.GetFileName(input.FilePath))) continue;
|
||||
try { ScanExecutorFile(input, deployedUtc.Value, maxEntryBytes, table, ref bootstrapPolicyId, logger); }
|
||||
catch (Exception ex) { logger?.Debug($"ImeLogEraPreScan: {Path.GetFileName(input.FilePath)} skipped: {ex.Message}"); }
|
||||
}
|
||||
|
||||
var serviceFiles = new List<ScanInput>();
|
||||
foreach (var input in files)
|
||||
if (IsServiceLogFile(Path.GetFileName(input.FilePath))) serviceFiles.Add(input);
|
||||
|
||||
try { ScanServiceFiles(serviceFiles, deployedUtc.Value, maxEntryBytes, table, bootstrapPolicyId, logger); }
|
||||
catch (Exception ex) { logger?.Debug($"ImeLogEraPreScan: service scan aborted: {ex.Message}"); }
|
||||
|
||||
table.Seal();
|
||||
|
||||
logger?.Info(
|
||||
$"ImeLogEraPreScan: {table.AnchoredEraCount} anchored era(s), {table.ServiceEras.Count} service era(s), " +
|
||||
$"bootstrapPolicyId={(bootstrapPolicyId ?? "(unknown)")}, transfer={(table.TransferAmbiguous ? "ambiguous-disabled" : "enabled")}");
|
||||
return table;
|
||||
}
|
||||
|
||||
private static void ScanExecutorFile(
|
||||
ScanInput input, DateTime deployedUtc, int maxEntryBytes, CmTraceEraTable table,
|
||||
ref string? bootstrapPolicyId, AgentLogger? logger)
|
||||
{
|
||||
var fileName = Path.GetFileName(input.FilePath);
|
||||
CmTraceEra? current = null;
|
||||
string? currentPolicyId = null;
|
||||
var anchored = false;
|
||||
|
||||
foreach (var e in ReadEntries(input, maxEntryBytes))
|
||||
{
|
||||
if (e.Message.StartsWith(ExecutorScriptStartPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
if (current != null) current.Segments[0].EndOffset = e.Offset;
|
||||
current = new CmTraceEra();
|
||||
current.Segments.Add(new CmTraceEraSegment(fileName, e.Offset, long.MaxValue));
|
||||
current.StartLocal = e.HasTimestamp ? e.LocalTimestamp : (DateTime?)null;
|
||||
table.Add(current, isServiceEra: false);
|
||||
var m = ExecutorScriptPolicyId.Match(e.Message);
|
||||
currentPolicyId = m.Success ? m.Groups["id"].Value : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (anchored || current == null || !e.HasTimestamp) continue;
|
||||
if (!e.Message.StartsWith(ExecutorOutputPrefix, StringComparison.Ordinal)) continue;
|
||||
if (e.Message.IndexOf(BootstrapInstallRunMarker, StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& e.Message.IndexOf(BootstrapInstallRunMarkerAlt, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
|
||||
// First bootstrap install record of this file, offered once — accepted or not.
|
||||
anchored = true;
|
||||
if (CmTraceOffsetCalibrator.TryMeasureOffset(e.LocalTimestamp, deployedUtc, out var offset))
|
||||
{
|
||||
current.Offset = offset;
|
||||
current.AnchorKind = AnchorKindBootstrapStdout;
|
||||
if (bootstrapPolicyId == null) bootstrapPolicyId = currentPolicyId;
|
||||
logger?.Info($"ImeLogEraPreScan: {fileName} bootstrap execution anchored at {offset} (stdout local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff}, policy={currentPolicyId ?? "?"})");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.Info($"ImeLogEraPreScan: {fileName} bootstrap stdout record rejected by the residual guard (local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff}, deployed={deployedUtc:HH:mm:ss}Z)");
|
||||
if (bootstrapPolicyId == null) bootstrapPolicyId = currentPolicyId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ScanServiceFiles(
|
||||
IReadOnlyList<ScanInput> files, DateTime deployedUtc, int maxEntryBytes, CmTraceEraTable table,
|
||||
string? bootstrapPolicyId, AgentLogger? logger)
|
||||
{
|
||||
if (files.Count == 0) return;
|
||||
|
||||
// The leading era: whatever process was writing when the scanned region begins.
|
||||
var current = new CmTraceEra();
|
||||
table.Add(current, isServiceEra: true);
|
||||
var resultOffered = false;
|
||||
|
||||
foreach (var input in files)
|
||||
{
|
||||
var fileName = Path.GetFileName(input.FilePath);
|
||||
var segment = new CmTraceEraSegment(fileName, input.StartOffset, long.MaxValue);
|
||||
current.Segments.Add(segment);
|
||||
|
||||
foreach (var e in ReadEntries(input, maxEntryBytes))
|
||||
{
|
||||
if (e.Message.StartsWith(ServiceStartedPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
segment.EndOffset = e.Offset;
|
||||
var next = new CmTraceEra { StartLocal = e.HasTimestamp ? e.LocalTimestamp : (DateTime?)null };
|
||||
current.EndLocal = next.StartLocal;
|
||||
segment = new CmTraceEraSegment(fileName, e.Offset, long.MaxValue);
|
||||
next.Segments.Add(segment);
|
||||
table.Add(next, isServiceEra: true);
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resultOffered || bootstrapPolicyId == null || !e.HasTimestamp) continue;
|
||||
if (!e.Message.StartsWith("[PowerShell] User Id = ", StringComparison.Ordinal)) continue;
|
||||
var m = ServicePolicyResult.Match(e.Message);
|
||||
if (!m.Success || !string.Equals(m.Groups["id"].Value, bootstrapPolicyId, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
// First result record for the bootstrap policy, offered once.
|
||||
resultOffered = true;
|
||||
if (CmTraceOffsetCalibrator.TryMeasureOffset(e.LocalTimestamp, deployedUtc, out var offset))
|
||||
{
|
||||
current.Offset = offset;
|
||||
current.AnchorKind = AnchorKindBootstrapPolicyResult;
|
||||
logger?.Info($"ImeLogEraPreScan: {fileName} service era anchored at {offset} (policy result local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff})");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.Info($"ImeLogEraPreScan: {fileName} bootstrap policy result rejected by the residual guard (local={e.LocalTimestamp:yyyy-MM-ddTHH:mm:ss.fff}, deployed={deployedUtc:HH:mm:ss}Z)");
|
||||
}
|
||||
}
|
||||
|
||||
// The era continues into the next (newer) file: close this file's segment at EOF.
|
||||
try { segment.EndOffset = new FileInfo(input.FilePath).Length; } catch { segment.EndOffset = long.MaxValue; }
|
||||
}
|
||||
|
||||
// The last era stays open in its last file.
|
||||
var last = current.Segments[current.Segments.Count - 1];
|
||||
last.EndOffset = long.MaxValue;
|
||||
}
|
||||
|
||||
private sealed class ScannedEntry
|
||||
{
|
||||
public long Offset;
|
||||
public bool HasTimestamp;
|
||||
public DateTime LocalTimestamp;
|
||||
public string Message = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate CMTrace entries with their file offsets. Same multiline assembly as the
|
||||
/// tracker's read loop (an AgentExecutor stdout record spans many lines; the closing tag
|
||||
/// carries the time); oversized or over-long entries are dropped, never partially used.
|
||||
/// </summary>
|
||||
private static IEnumerable<ScannedEntry> ReadEntries(ScanInput input, int maxEntryBytes)
|
||||
{
|
||||
using (var stream = new FileStream(input.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
if (input.StartOffset > 0 && input.StartOffset < stream.Length)
|
||||
stream.Seek(input.StartOffset, SeekOrigin.Begin);
|
||||
else if (input.StartOffset >= stream.Length)
|
||||
yield break;
|
||||
|
||||
var reader = new BoundedLineReader(stream, maxEntryBytes);
|
||||
StringBuilder? buffer = null;
|
||||
var bufferLines = 0;
|
||||
long entryStart = input.StartOffset;
|
||||
var skipping = false;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var line = reader.ReadLineAsync(System.Threading.CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (line == null) yield break;
|
||||
if (buffer == null) entryStart = reader.LastLineStart;
|
||||
|
||||
if (reader.LastLineTruncated)
|
||||
{
|
||||
buffer = null; bufferLines = 0; skipping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skipping)
|
||||
{
|
||||
if (line.StartsWith("<![LOG[", StringComparison.Ordinal)) skipping = false;
|
||||
else
|
||||
{
|
||||
if (line.Contains("]LOG]!>")) skipping = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer != null)
|
||||
{
|
||||
buffer.Append('\n').Append(line);
|
||||
bufferLines++;
|
||||
if (line.Contains("]LOG]!>"))
|
||||
{
|
||||
line = buffer.ToString();
|
||||
buffer = null; bufferLines = 0;
|
||||
}
|
||||
else if (bufferLines >= MaxEntryLines || buffer.Length >= maxEntryBytes)
|
||||
{
|
||||
buffer = null; bufferLines = 0; skipping = true;
|
||||
continue;
|
||||
}
|
||||
else continue;
|
||||
}
|
||||
else if (line.StartsWith("<![LOG[", StringComparison.Ordinal) && !line.Contains("]LOG]!>"))
|
||||
{
|
||||
buffer = new StringBuilder(line);
|
||||
bufferLines = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!CmTraceLogParser.TryParseLine(line, out var entry) || entry == null) continue;
|
||||
yield return new ScannedEntry
|
||||
{
|
||||
Offset = entryStart,
|
||||
HasTimestamp = entry.HasTimestamp && !entry.BiasMinutes.HasValue,
|
||||
LocalTimestamp = entry.LocalTimestamp,
|
||||
Message = entry.Message ?? string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -263,6 +263,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// --- Normal processing (single-line or completed multiline) ---
|
||||
CmTraceLogEntry entry;
|
||||
string messageToMatch;
|
||||
_currentEntryOffset = entryStart;
|
||||
if (CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
messageToMatch = entry.Message;
|
||||
@@ -312,6 +313,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// flagged as such.
|
||||
if (hadPreviousObservation && calibrationAnchor != null)
|
||||
CalibrateFrom(_currentSourceFileName, Path.GetFileName(filePath), calibrationAnchor);
|
||||
_currentEntryOffset = -1;
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException) { }
|
||||
@@ -509,6 +511,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
LastMatchedSourceOffsetMinutes = null;
|
||||
LastMatchedSourceOffsetOrigin = CmTraceOffsetOrigin.None;
|
||||
LastMatchedMeasuredWriterOffsetMinutes = null;
|
||||
LastMatchedEraAnchorKind = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -518,6 +521,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
LastMatchedSourceLocalTimestamp = entry.HasTimestamp ? entry.LocalTimestamp : (DateTime?)null;
|
||||
LastMatchedSourceOffsetMinutes = offsetMinutes;
|
||||
LastMatchedSourceOffsetOrigin = origin;
|
||||
LastMatchedEraAnchorKind = origin == CmTraceOffsetOrigin.EraAnchored ? _lastEraAnchorKind : string.Empty;
|
||||
|
||||
// Observational: what the calibrator measured for this file, distinct from what
|
||||
// was applied above.
|
||||
@@ -555,7 +559,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Placed after the staleness gate so a success replayed from a previous
|
||||
// enrollment's log cannot clear a genuine current outage.
|
||||
if (!isStaleReplayLine && string.Equals(pattern.PatternId, "IME-TOKEN-SUCCESS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ClearPendingTokenFailure();
|
||||
// Raw user-token observation for the Entra user-affinity detector (2026-09-04).
|
||||
// Deliberately NOT routed through espPhaseDetected: that path deduplicates once
|
||||
// AccountSetup is set, so a FullESP session would never report the token that
|
||||
// followed the real-user desktop.
|
||||
try { OnUserTokenAcquired?.Invoke(LastMatchedLogTimestamp); }
|
||||
catch (Exception ex) { _logger?.Warning($"OnUserTokenAcquired handler threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -617,6 +629,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
case "imetokenfailure":
|
||||
HandleImeTokenFailure(match.Groups["errorCode"]?.Value, message);
|
||||
// Raw per-line observation (staleness already enforced above via
|
||||
// AppMutatingActions) — the affinity detector counts failures after
|
||||
// the real-user desktop; the grace-window Warning stays separate.
|
||||
try { OnTokenFailureLine?.Invoke(match.Groups["errorCode"]?.Value, LastMatchedLogTimestamp); }
|
||||
catch (Exception ex) { _logger?.Warning($"OnTokenFailureLine handler threw: {ex.Message}"); }
|
||||
break;
|
||||
|
||||
case "imeimpersonation":
|
||||
@@ -941,6 +958,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
// ERA ANCHOR (2026-09-04, session a7140f98): a backlog line whose writer era was
|
||||
// anchored by the bootstrap execution record resolves through that era's offset.
|
||||
// Whole era or nothing — the anchor never crosses an "EMS Agent Started" boundary,
|
||||
// and in-process sibling files (AppWorkload, AppActionProcessor) join by local
|
||||
// time only while the service eras' local ranges are unambiguous. See
|
||||
// ImeLogEraPreScan for why this is the one anchor a backlog line can trust.
|
||||
_lastEraAnchorKind = string.Empty;
|
||||
if (_currentSourceFileName != null && _currentEntryOffset >= 0)
|
||||
{
|
||||
TimeSpan eraOffset;
|
||||
string anchorKind;
|
||||
var resolved = EraTable.TryResolveByOffset(_currentSourceFileName, _currentEntryOffset, out eraOffset, out anchorKind)
|
||||
|| (ImeLogEraPreScan.IsServiceHostedFile(_currentSourceFileName)
|
||||
&& EraTable.TryResolveByLocalTime(entry.LocalTimestamp, out eraOffset, out anchorKind));
|
||||
if (resolved)
|
||||
{
|
||||
origin = CmTraceOffsetOrigin.EraAnchored;
|
||||
offsetMinutes = (int)eraOffset.TotalMinutes;
|
||||
_lastEraAnchorKind = anchorKind;
|
||||
return DateTime.SpecifyKind(entry.LocalTimestamp - eraOffset, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for everything not provably fresh: assume the reader's zone, UNIFORMLY.
|
||||
// Wrong in absolute terms whenever the writer held a different belief, but
|
||||
// self-consistent — both ends of a duration are wrong by the same amount, so derived
|
||||
|
||||
@@ -400,6 +400,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>How that offset was arrived at — declared, measured, or neither.</summary>
|
||||
public CmTraceOffsetOrigin LastMatchedSourceOffsetOrigin { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// For <see cref="CmTraceOffsetOrigin.EraAnchored"/>: which anchor established the era's
|
||||
/// offset ("bootstrap-policy-result", "bootstrap-stdout", with "/service-era-transfer"
|
||||
/// when the line came from an in-process sibling file). Empty otherwise.
|
||||
/// </summary>
|
||||
public string LastMatchedEraAnchorKind { get; internal set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Source of the install marker's UTC instant for the backlog era pre-scan
|
||||
/// (<see cref="ImeLogEraPreScan"/>). Defaults to the registry marker; tests inject.
|
||||
/// </summary>
|
||||
internal Func<DateTime?> DeployedUtcProvider { get; set; } = Security.DeploymentMarker.TryReadDeployedUtc;
|
||||
|
||||
/// <summary>Era table built by <see cref="PreScanEras"/> at <see cref="Start"/>; empty until then.</summary>
|
||||
internal CmTraceEraTable EraTable { get; private set; } = new CmTraceEraTable();
|
||||
|
||||
// Byte offset of the entry currently being resolved in the read loop (-1 outside it) —
|
||||
// the key for the era lookup. Set next to _currentSourceFileName.
|
||||
private long _currentEntryOffset = -1;
|
||||
private string _lastEraAnchorKind = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The offset the calibrator MEASURED for the source file, which is not necessarily the
|
||||
/// one applied.
|
||||
@@ -425,6 +446,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Args: errorCode (may be empty when IME logged none), raw failure line excerpt.
|
||||
// Fired once per distinct errorCode per tracker lifetime (see HandleImeTokenFailure).
|
||||
public Action<string, string> OnImeTokenFailure { get; set; }
|
||||
/// <summary>
|
||||
/// Fires on EVERY IME-TOKEN-SUCCESS line that passes the historic-replay gate, with the
|
||||
/// line's resolved UTC (null when the line carried no timestamp). Unlike
|
||||
/// <see cref="OnEspPhaseChanged"/> (deduplicated once AccountSetup is set) and unlike the
|
||||
/// grace-window model behind <see cref="OnImeTokenFailure"/>, this is the raw observation:
|
||||
/// "Successfully get the token" is IntuneTokenManager acquiring the signed-in user's token
|
||||
/// through WAM, i.e. the only SYSTEM-visible proof of Entra user affinity (2026-09-04).
|
||||
/// </summary>
|
||||
public Action<DateTime?> OnUserTokenAcquired { get; set; }
|
||||
/// <summary>
|
||||
/// Fires on EVERY IME-TOKEN-FAILURE line that passes the historic-replay gate:
|
||||
/// errorCode (may be empty) and the line's resolved UTC. Raw observation — the
|
||||
/// deduplicated Warning stays on <see cref="OnImeTokenFailure"/>.
|
||||
/// </summary>
|
||||
public Action<string, DateTime?> OnTokenFailureLine { get; set; }
|
||||
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
|
||||
// log line, null when the DO collector observed the completion live via polling. The
|
||||
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
|
||||
@@ -839,6 +875,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the backlog era table from the files the first pass will read, each from the
|
||||
/// position the pass will start at. Never throws — a failure leaves the table empty and
|
||||
/// every backlog line on the reader-zone fallback, exactly as before.
|
||||
/// </summary>
|
||||
internal void PreScanEras()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_logFolder)) return;
|
||||
var inputs = new List<ImeLogEraPreScan.ScanInput>();
|
||||
var files = new List<string>();
|
||||
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
|
||||
{
|
||||
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
|
||||
files.Add(filePath);
|
||||
}
|
||||
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var filePath in files)
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
if (!info.Exists) continue;
|
||||
inputs.Add(new ImeLogEraPreScan.ScanInput(filePath, _positionTracker.GetSafePosition(filePath, info.Length)));
|
||||
}
|
||||
|
||||
DateTime? deployedUtc = null;
|
||||
try { deployedUtc = DeployedUtcProvider?.Invoke(); }
|
||||
catch (Exception ex) { _logger.Debug($"ImeLogTracker: Deployed marker read failed: {ex.Message}"); }
|
||||
|
||||
EraTable = ImeLogEraPreScan.Build(inputs, deployedUtc, MaxEntryBytes, _logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ImeLogTracker: era pre-scan failed — backlog stays on the reader-zone fallback: {ex.Message}");
|
||||
EraTable = new CmTraceEraTable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background polling task
|
||||
/// </summary>
|
||||
@@ -851,6 +925,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// exactly where we left off — no re-parsing, no device-phase bleeding.
|
||||
LoadState();
|
||||
|
||||
// Era anchors for the backlog the first pass is about to read (2026-09-04). Runs
|
||||
// before the poll loop so every replayed line already finds its era; read-only on
|
||||
// the position tracker. Replay tooling anchors nothing — its logs did not come from
|
||||
// this device's bootstrap.
|
||||
if (!SimulationMode) PreScanEras();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
|
||||
+11
@@ -67,6 +67,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public event EventHandler<AadPlaceholderUserDetectedEventArgs> PlaceholderUserDetected;
|
||||
public event EventHandler<AadUserJoinedEventArgs> AadUserJoined;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> while a provisioning placeholder (foouser@/autopilot@) is the JoinInfo
|
||||
/// user and no real user has replaced it. On a Hybrid join this stays true through the
|
||||
/// whole user sign-in — the AD sign-in never touches JoinInfo — so consumers report it
|
||||
/// as a fact, never as sign-in evidence (2026-09-04).
|
||||
/// </summary>
|
||||
internal bool PlaceholderObservedWithoutRealUser
|
||||
{
|
||||
get { lock (_stateLock) return _placeholderFired && !_realUserFired; }
|
||||
}
|
||||
|
||||
public AadJoinWatcher(AgentLogger logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
+2
-2
@@ -234,7 +234,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
try
|
||||
{
|
||||
OnTraceEvent?.Invoke(
|
||||
"desktop_excluded_user",
|
||||
SharedEventTypes.DesktopExcludedUser,
|
||||
$"explorer.exe found but owned by excluded user '{owner}' — not a real user desktop",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", owner } });
|
||||
}
|
||||
@@ -250,7 +250,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
try
|
||||
{
|
||||
OnTraceEvent?.Invoke(
|
||||
"desktop_real_user_detected",
|
||||
SharedEventTypes.DesktopRealUserDetected,
|
||||
$"Real user desktop detected (explorer.exe PID {proc.Id}, user '[redacted]')",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", "[redacted]" } });
|
||||
}
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Single-shot detector for Entra user affinity on Hybrid Azure AD Join devices
|
||||
/// (2026-09-04, session a7140f98). After the real user signs in, IME acquires that user's
|
||||
/// token through WAM and logs "Successfully get the token" (IntuneTokenManager,
|
||||
/// <c>IME-TOKEN-SUCCESS</c>). That line is the only SYSTEM-visible proof that the user
|
||||
/// session carries an Entra identity (PRT); a session whose user never gets one shows only
|
||||
/// "Failed to get AAD token" lines after the desktop.
|
||||
/// <para>
|
||||
/// Contract: <see cref="NotifyRealUserDesktop"/> arms the timer when the device is a Hybrid
|
||||
/// join (probe injected by the host). The first token success stamped after the desktop
|
||||
/// cancels the timer and emits <c>ime_user_token_acquired</c>; the timer emits
|
||||
/// <c>entra_user_affinity_pending</c> with the failure codes observed since the desktop.
|
||||
/// A token that arrives after the timer fired is still posted once — it tells the
|
||||
/// timeline (and the backend rule) that affinity came late rather than never.
|
||||
/// Both emissions happen at most once per agent process — a reboot starts a new process,
|
||||
/// whose repeat is exactly the confidence signal the backend rule counts. Token lines older
|
||||
/// than the desktop (minus a small tolerance for the 30 s desktop poll) belong to the
|
||||
/// device phase and are ignored: they appear in every enrollment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not a decision signal. Never influences completion. The JoinInfo placeholder is reported
|
||||
/// as a fact in the payload because it is NOT a login signal on a Hybrid join — the AD
|
||||
/// sign-in never replaces it; only the later Entra device registration does.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class EntraUserAffinityDetector : IDisposable
|
||||
{
|
||||
internal const int DefaultDelayMinutes = 10;
|
||||
internal const string SourceLabel = "EntraUserAffinityDetector";
|
||||
/// <summary>Token lines this much older than the desktop observation still count as post-desktop.</summary>
|
||||
internal static readonly TimeSpan DesktopPollTolerance = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<bool> _isHybridJoinProbe;
|
||||
private readonly Func<bool>? _placeholderActiveProbe;
|
||||
private readonly Func<DateTime> _utcNow;
|
||||
private readonly TimeSpan _delay;
|
||||
private readonly object _lock = new object();
|
||||
private readonly List<string> _failureCodesSinceDesktop = new List<string>();
|
||||
|
||||
private Timer? _timer;
|
||||
private DateTime? _desktopObservedUtc;
|
||||
private bool _armed;
|
||||
private bool _fired;
|
||||
private bool _tokenAcquiredPosted;
|
||||
private int _failureCountSinceDesktop;
|
||||
private int _disposed;
|
||||
|
||||
internal EntraUserAffinityDetector(
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
Func<bool> isHybridJoinProbe,
|
||||
Func<bool>? placeholderActiveProbe = null,
|
||||
TimeSpan? delay = null,
|
||||
Func<DateTime>? utcNow = null)
|
||||
{
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_isHybridJoinProbe = isHybridJoinProbe ?? throw new ArgumentNullException(nameof(isHybridJoinProbe));
|
||||
_placeholderActiveProbe = placeholderActiveProbe;
|
||||
_delay = delay ?? TimeSpan.FromMinutes(DefaultDelayMinutes);
|
||||
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The DesktopArrivalDetector resolved explorer.exe under a real (non-placeholder,
|
||||
/// non-system) user. Arms the timer on Hybrid devices; idempotent; no-op after a
|
||||
/// token success or after the timer fired.
|
||||
/// </summary>
|
||||
public void NotifyRealUserDesktop()
|
||||
{
|
||||
bool hybrid;
|
||||
try { hybrid = _isHybridJoinProbe(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EntraUserAffinityDetector: hybrid probe threw — not arming: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed != 0 || _armed || _fired || _tokenAcquiredPosted) return;
|
||||
if (!hybrid)
|
||||
{
|
||||
_logger.Debug("EntraUserAffinityDetector: real-user desktop on a non-Hybrid device — not armed");
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopObservedUtc = _utcNow();
|
||||
_armed = true;
|
||||
_logger.Info(
|
||||
$"EntraUserAffinityDetector: armed at real-user desktop — will emit {SharedConstants.EventTypes.EntraUserAffinityPending} in {_delay.TotalMinutes:F0} min unless IME acquires a user token");
|
||||
_timer = new Timer(OnTimer, null, _delay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>IME-TOKEN-SUCCESS line observed; <paramref name="lineUtc"/> is the line's resolved time.</summary>
|
||||
public void NotifyUserTokenAcquired(DateTime? lineUtc)
|
||||
{
|
||||
DateTime desktop;
|
||||
double minutesAfterDesktop;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed != 0 || !_armed || _tokenAcquiredPosted) return;
|
||||
desktop = _desktopObservedUtc!.Value;
|
||||
if (!IsAfterDesktop(lineUtc, desktop)) return;
|
||||
|
||||
_tokenAcquiredPosted = true;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
minutesAfterDesktop = ((lineUtc ?? _utcNow()) - desktop).TotalMinutes;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["minutesAfterDesktop"] = Math.Max(0, minutesAfterDesktop).ToString("F1", System.Globalization.CultureInfo.InvariantCulture),
|
||||
["tokenFailuresBeforeSuccess"] = _failureCountSinceDesktop.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["isHybridJoin"] = "true",
|
||||
};
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedConstants.EventTypes.ImeUserTokenAcquired,
|
||||
source: SourceLabel,
|
||||
message: "Entra user token acquired by IME after the real-user desktop — user affinity established",
|
||||
severity: EventSeverity.Info,
|
||||
immediateUpload: false,
|
||||
data: data,
|
||||
occurredAtUtc: lineUtc);
|
||||
|
||||
_logger.Info($"EntraUserAffinityDetector: user token observed {minutesAfterDesktop:F1} min after desktop — timer cancelled");
|
||||
}
|
||||
|
||||
/// <summary>IME-TOKEN-FAILURE line observed; counted only when stamped after the desktop.</summary>
|
||||
public void NotifyTokenFailureLine(string? errorCode, DateTime? lineUtc)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed != 0 || !_armed || _fired || _tokenAcquiredPosted) return;
|
||||
if (!IsAfterDesktop(lineUtc, _desktopObservedUtc!.Value)) return;
|
||||
|
||||
_failureCountSinceDesktop++;
|
||||
var code = string.IsNullOrWhiteSpace(errorCode) ? "unknown" : errorCode!.Trim();
|
||||
if (!_failureCodesSinceDesktop.Contains(code, StringComparer.OrdinalIgnoreCase))
|
||||
_failureCodesSinceDesktop.Add(code);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
lock (_lock)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Test seams — same lock and state guards as the production paths.
|
||||
internal void TriggerFromTest() => OnTimer(null);
|
||||
internal bool IsArmedForTest { get { lock (_lock) { return _armed; } } }
|
||||
internal bool HasFiredForTest { get { lock (_lock) { return _fired; } } }
|
||||
internal bool TokenAcquiredPostedForTest { get { lock (_lock) { return _tokenAcquiredPosted; } } }
|
||||
|
||||
private static bool IsAfterDesktop(DateTime? lineUtc, DateTime desktopUtc)
|
||||
{
|
||||
// A line without a timestamp cannot be placed — treat it as current (the tracker
|
||||
// only hands us lines it just read), which is after the desktop by construction.
|
||||
if (!lineUtc.HasValue) return true;
|
||||
return lineUtc.Value >= desktopUtc - DesktopPollTolerance;
|
||||
}
|
||||
|
||||
private void OnTimer(object? state)
|
||||
{
|
||||
string codes;
|
||||
int count;
|
||||
double minutesSinceDesktop;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed != 0 || !_armed || _fired || _tokenAcquiredPosted) return;
|
||||
_fired = true;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
codes = string.Join(",", _failureCodesSinceDesktop);
|
||||
count = _failureCountSinceDesktop;
|
||||
minutesSinceDesktop = (_utcNow() - _desktopObservedUtc!.Value).TotalMinutes;
|
||||
}
|
||||
|
||||
var placeholderActive = "unknown";
|
||||
try
|
||||
{
|
||||
if (_placeholderActiveProbe != null)
|
||||
placeholderActive = _placeholderActiveProbe() ? "true" : "false";
|
||||
}
|
||||
catch { /* observational */ }
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["delayMinutes"] = ((int)_delay.TotalMinutes).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["reason"] = "timer_fired",
|
||||
["minutesSinceDesktop"] = Math.Max(0, minutesSinceDesktop).ToString("F1", System.Globalization.CultureInfo.InvariantCulture),
|
||||
["tokenFailureCount"] = count.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["tokenFailureCodes"] = codes,
|
||||
["placeholderActive"] = placeholderActive,
|
||||
["isHybridJoin"] = "true",
|
||||
};
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedConstants.EventTypes.EntraUserAffinityPending,
|
||||
source: SourceLabel,
|
||||
message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after the real-user desktop IME has not acquired an Entra user token — user affinity pending"
|
||||
+ (count > 0 ? $" ({count} token failure(s): {codes})" : string.Empty),
|
||||
severity: EventSeverity.Warning,
|
||||
immediateUpload: true,
|
||||
data: data);
|
||||
|
||||
_logger.Warning($"EntraUserAffinityDetector: emitted {SharedConstants.EventTypes.EntraUserAffinityPending} (failures={count}, codes={codes})");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -36,6 +36,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal const int EventId_ProvisioningWillNotLaunch = 360;
|
||||
internal const int EventId_ProvisioningBlocked = 362;
|
||||
internal const int EventId_PinStatus = 376;
|
||||
// Automatic device registration failed at the join / authentication phase (Hybrid Join
|
||||
// affinity diagnosis, 2026-09-04). Not Hello events — they ride the same UDR watcher
|
||||
// because the channel is already armed; surfaced as device_registration_event.
|
||||
internal const int EventId_AutoRegistrationFailedJoin = 304;
|
||||
internal const int EventId_AutoRegistrationFailedAuth = 305;
|
||||
|
||||
internal const int EventId_HelloForBusiness_ProcessingStarted = 3024;
|
||||
internal const int EventId_HelloForBusiness_ProcessingStopped = 6045;
|
||||
@@ -47,6 +52,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
private static readonly HashSet<int> TrackedUdrEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_AutoRegistrationFailedJoin,
|
||||
EventId_AutoRegistrationFailedAuth,
|
||||
EventId_NgcKeyRegistered,
|
||||
EventId_NgcKeyRegistrationFailed,
|
||||
EventId_ProvisioningWillLaunch,
|
||||
@@ -547,7 +554,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var query = new EventLogQuery(
|
||||
UdrEventLogChannel,
|
||||
PathType.LogName,
|
||||
"*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]");
|
||||
"*[System[(EventID=300 or EventID=301 or EventID=304 or EventID=305 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]");
|
||||
|
||||
_udrWatcher = new EventLogWatcher(query);
|
||||
_udrWatcher.EventRecordWritten += OnUdrEventRecordWritten;
|
||||
@@ -654,6 +661,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
message = "Windows Hello PIN status update";
|
||||
break;
|
||||
|
||||
case EventId_AutoRegistrationFailedJoin: // 304
|
||||
case EventId_AutoRegistrationFailedAuth: // 305
|
||||
// OS-side record that the automatic device registration failed (join or
|
||||
// authentication phase). Companion evidence for entra_user_affinity_pending —
|
||||
// no Hello state involved, so no MarkHelloCompleted().
|
||||
eventType = Constants.EventTypes.DeviceRegistrationEvent;
|
||||
severity = EventSeverity.Warning;
|
||||
message = $"User Device Registration: automatic registration failed (event {eventId}, "
|
||||
+ (eventId == EventId_AutoRegistrationFailedJoin ? "join phase)" : "authentication phase)");
|
||||
_logger.Info($"User Device Registration failure event {eventId} observed");
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -691,6 +710,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// flip state multiple times — keep batched.
|
||||
ImmediateUpload = shouldTriggerHelloCompleted
|
||||
|| eventType == Constants.EventTypes.HelloProvisioningFailed
|
||||
|| eventType == Constants.EventTypes.DeviceRegistrationEvent
|
||||
});
|
||||
|
||||
_logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
|
||||
+52
-17
@@ -10,19 +10,27 @@ using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Single-shot detector for the Hybrid User-Driven completion-gap (2026-05-01 trigger:
|
||||
/// session e58bcfdb-3e68-4f23-a3c2-437429ca9e78). After a Hybrid AAD-Join reboot the
|
||||
/// agent restarts and waits for the user to log in with their real AD/AAD account
|
||||
/// (which replaces the foouser/autopilot placeholder in JoinInfo). When that login is
|
||||
/// overdue the agent today goes silent until the backend 5-h watchdog fires; this
|
||||
/// detector emits a <c>hybrid_login_pending</c> warning event after a short single-shot
|
||||
/// timer so the operator sees an explicit "stuck waiting for AD login" signal in the
|
||||
/// timeline.
|
||||
/// Single-shot detector for the Hybrid User-Driven sign-in gap (2026-05-01 trigger:
|
||||
/// session e58bcfdb-3e68-4f23-a3c2-437429ca9e78). After the Hybrid AAD-Join reboot the
|
||||
/// agent restarts and waits for the user to sign in with their AD account. When nobody
|
||||
/// signs in the agent goes silent until the backend watchdog fires; this detector emits a
|
||||
/// <c>hybrid_login_pending</c> warning after a short single-shot timer so the operator sees
|
||||
/// an explicit "still waiting for the sign-in" signal in the timeline.
|
||||
/// <para>
|
||||
/// Conditions checked at <see cref="Arm"/>: composition root must have already
|
||||
/// confirmed a) post-reboot, b) <c>isHybridJoin == true</c>. The detector itself only
|
||||
/// owns the timer + <see cref="AadJoinWatcher.AadUserJoined"/> cancel-path. No polling.
|
||||
/// No periodicity. Fires at most once per agent process.
|
||||
/// What it measures (rewritten 2026-09-04, session a7140f98): the ABSENCE of a real-user
|
||||
/// desktop. Two cancel paths: the DesktopArrivalDetector resolving explorer.exe under a
|
||||
/// real user (the actual sign-in evidence), and the AadJoinWatcher seeing a real user in
|
||||
/// JoinInfo (the Entra-join flavour, where the OOBE sign-in completes the join). On a
|
||||
/// Hybrid join the AD sign-in never replaces the JoinInfo placeholder — only the later
|
||||
/// Entra device registration does — so the placeholder state is reported as a fact in the
|
||||
/// payload, never as the reason for the warning.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Conditions checked at <see cref="Arm"/> by the composition root: post-reboot,
|
||||
/// <c>isHybridJoin == true</c>, and not a WhiteGlove device (a sealed device legitimately
|
||||
/// waits days for its user; the session status AwaitingUser already describes that).
|
||||
/// The detector owns only the timer and the cancel paths. No polling. Fires at most once
|
||||
/// per agent process.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class HybridLoginPendingDetector : IDisposable
|
||||
@@ -40,6 +48,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private bool _armed;
|
||||
private bool _fired;
|
||||
private bool _cancelledByRealUser;
|
||||
private bool _cancelledByDesktop;
|
||||
private int _disposed;
|
||||
|
||||
internal HybridLoginPendingDetector(
|
||||
@@ -60,8 +69,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// Starts the single-shot timer. Idempotent — repeated calls after the first arm
|
||||
/// are no-ops. Has no effect if the watcher already saw a real AAD user (the
|
||||
/// arming was racy and the cancel won), or if the detector already fired.
|
||||
/// are no-ops. Has no effect if a real-user desktop or a real AAD user was already
|
||||
/// observed (the arming was racy and the cancel won), or if the detector already fired.
|
||||
/// </summary>
|
||||
public void Arm()
|
||||
{
|
||||
@@ -69,6 +78,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
if (_disposed != 0) return;
|
||||
if (_armed || _fired) return;
|
||||
if (_cancelledByDesktop)
|
||||
{
|
||||
_logger.Info("HybridLoginPendingDetector: arm requested but a real-user desktop was already observed — skipped");
|
||||
return;
|
||||
}
|
||||
if (_cancelledByRealUser)
|
||||
{
|
||||
_logger.Info("HybridLoginPendingDetector: arm requested but real AAD user already joined — skipped");
|
||||
@@ -77,12 +91,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
_armed = true;
|
||||
_logger.Info(
|
||||
$"HybridLoginPendingDetector: armed — will emit hybrid_login_pending in {_delay.TotalMinutes:F0} min if real AAD user has not joined by then");
|
||||
$"HybridLoginPendingDetector: armed — will emit hybrid_login_pending in {_delay.TotalMinutes:F0} min if no real-user desktop appears by then");
|
||||
|
||||
_timer = new Timer(OnTimer, null, _delay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The DesktopArrivalDetector resolved explorer.exe under a real user — the sign-in
|
||||
/// happened. Cancels a running timer; remembered so a later <see cref="Arm"/> is a no-op.
|
||||
/// </summary>
|
||||
public void NotifyRealUserDesktop()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_cancelledByDesktop = true;
|
||||
if (!_armed || _fired) return;
|
||||
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
_logger.Info("HybridLoginPendingDetector: cancelled — real-user desktop observed before timeout");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
@@ -106,6 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
internal bool IsArmedForTest { get { lock (_lock) { return _armed; } } }
|
||||
internal bool IsCancelledByRealUserForTest { get { lock (_lock) { return _cancelledByRealUser; } } }
|
||||
internal bool IsCancelledByDesktopForTest { get { lock (_lock) { return _cancelledByDesktop; } } }
|
||||
internal bool HasFiredForTest { get { lock (_lock) { return _fired; } } }
|
||||
|
||||
private void OnTimer(object? state) => EmitInternal(reason: "timer_fired");
|
||||
@@ -130,23 +162,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// _armed guard (Codex review 2026-05-01): production is safe because the
|
||||
// timer is only created in Arm(), but explicit guard makes the contract
|
||||
// unambiguous — emission requires a deliberate Arm(), full stop.
|
||||
if (!_armed || _fired || _cancelledByRealUser || _disposed != 0) return;
|
||||
if (!_armed || _fired || _cancelledByRealUser || _cancelledByDesktop || _disposed != 0) return;
|
||||
_fired = true;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
var placeholderActive = _watcher.PlaceholderObservedWithoutRealUser ? "true" : "false";
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["delayMinutes"] = ((int)_delay.TotalMinutes).ToString(),
|
||||
["reason"] = reason,
|
||||
["isHybridJoin"] = "true", // armed only when composition root verified this
|
||||
["realUserDesktopSeen"] = "false",
|
||||
["placeholderActive"] = placeholderActive,
|
||||
};
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedConstants.EventTypes.HybridLoginPending,
|
||||
source: SourceLabel,
|
||||
message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after reboot still no real AD user — login overdue (placeholder still active in JoinInfo)",
|
||||
message: $"Hybrid AAD Join: {(int)_delay.TotalMinutes} min after reboot still no real-user desktop — sign-in overdue",
|
||||
severity: EventSeverity.Warning,
|
||||
immediateUpload: true,
|
||||
data: data);
|
||||
|
||||
@@ -11,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
@@ -312,6 +313,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// later before the detector can fire). When the real-user desktop arrives, Shift+F10 is no
|
||||
// longer possible, so the console watcher is stopped to avoid post-enrollment false positives.
|
||||
ConsoleBypassHost? consoleBypassHost = null;
|
||||
// Captured likewise (assigned right after this host). The real-user desktop is the
|
||||
// sign-in evidence for the Hybrid detectors: it cancels hybrid_login_pending and arms
|
||||
// the Entra user-affinity timer (2026-09-04, session a7140f98).
|
||||
AadJoinHost? aadJoinHostRef = null;
|
||||
var desktopArrivalHost = new DesktopArrivalHost(
|
||||
logger,
|
||||
ingress,
|
||||
@@ -326,6 +331,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Shift+F10 works — stop the console watcher (no-op if not created / already stopped).
|
||||
consoleBypassHost?.StopForDesktopArrival();
|
||||
|
||||
aadJoinHostRef?.NotifyRealUserDesktop();
|
||||
|
||||
if (realmJoinHost == null) return;
|
||||
if (UserSidResolver.TryResolveSid(owner, out var sid) && !string.IsNullOrEmpty(sid))
|
||||
{
|
||||
@@ -340,8 +347,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
var aadJoinHost = new AadJoinHost(
|
||||
logger, ingress, clock,
|
||||
onRealUserJoined: desktopArrivalHost.RequestResetForRealUserSwitch);
|
||||
onRealUserJoined: desktopArrivalHost.RequestResetForRealUserSwitch,
|
||||
isHybridJoinProbe: EnrollmentRegistryDetector.DetectHybridJoin);
|
||||
hosts.Add(aadJoinHost);
|
||||
aadJoinHostRef = aadJoinHost;
|
||||
|
||||
// RealmJoin support is opt-in per tenant (portal toggle → AnalyzerConfiguration.
|
||||
// EnableRealmJoinWatcher, default off). When disabled, leave realmJoinHost null:
|
||||
@@ -439,6 +448,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
hosts.Add(imeLogHost);
|
||||
imeLogHostRef = imeLogHost;
|
||||
|
||||
// Entra user affinity (2026-09-04): the raw IME token lines feed the AadJoinHost's
|
||||
// affinity detector. New callbacks — nothing chained before us, so direct assignment.
|
||||
imeLogHost.Tracker.OnUserTokenAcquired = lineUtc => aadJoinHost.NotifyUserTokenAcquired(lineUtc);
|
||||
imeLogHost.Tracker.OnTokenFailureLine = (code, lineUtc) => aadJoinHost.NotifyTokenFailureLine(code, lineUtc);
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19): the user-apps-settled AccountSetup synthesis used
|
||||
// to run ONLY on the EspExited edge. On a tenant with a large required-app set the ESP
|
||||
// page exits while apps are still in flight, the single attempt misses, and nothing
|
||||
|
||||
@@ -258,6 +258,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public bool IsWhiteGlovePart2 => _isWhiteGlovePart2;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when this device ever resumed a WhiteGlove Part 2 in this session — the
|
||||
/// state folder holds a <c>.part1-<ts>/</c> archive. Unlike
|
||||
/// <see cref="IsWhiteGlovePart2"/> (this start only) it survives later reboots within
|
||||
/// Part 2; see <see cref="StateArchiver.HasPart1Archive"/>.
|
||||
/// </summary>
|
||||
public bool HasWhiteGlovePart1Archive => StateArchiver.HasPart1Archive(_stateDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> wenn der Start auf einen korrupten State-Segment traf und
|
||||
/// Snapshot + Log-Segmente nach <c>.quarantine/{ts}/</c> bewegt wurden.
|
||||
|
||||
@@ -8,6 +8,13 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Hybrid-identity host: the JoinInfo watcher plus the two single-shot detectors that
|
||||
/// describe the user side of a Hybrid Azure AD Join — sign-in overdue
|
||||
/// (<see cref="HybridLoginPendingDetector"/>) and Entra user affinity after the sign-in
|
||||
/// (<see cref="EntraUserAffinityDetector"/>). The composition root feeds it the
|
||||
/// DesktopArrivalDetector's real-user observation and the ImeLogTracker's raw token lines.
|
||||
/// </summary>
|
||||
internal sealed class AadJoinHost : ICollectorHost
|
||||
{
|
||||
public string Name => "AadJoinWatcher";
|
||||
@@ -15,41 +22,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly AadJoinWatcher _watcher;
|
||||
private readonly AadJoinWatcherAdapter _adapter;
|
||||
private readonly HybridLoginPendingDetector _hybridLoginPendingDetector;
|
||||
private readonly EntraUserAffinityDetector _userAffinityDetector;
|
||||
private int _disposed;
|
||||
|
||||
public AadJoinHost(
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Action? onRealUserJoined = null)
|
||||
Action? onRealUserJoined = null,
|
||||
Func<bool>? isHybridJoinProbe = null)
|
||||
{
|
||||
_watcher = new AadJoinWatcher(logger);
|
||||
_adapter = new AadJoinWatcherAdapter(_watcher, ingress, clock, onRealUserJoined: onRealUserJoined);
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_hybridLoginPendingDetector = new HybridLoginPendingDetector(
|
||||
watcher: _watcher,
|
||||
post: new InformationalEventPost(ingress, clock),
|
||||
post: post,
|
||||
logger: logger);
|
||||
_userAffinityDetector = new EntraUserAffinityDetector(
|
||||
post: post,
|
||||
logger: logger,
|
||||
isHybridJoinProbe: isHybridJoinProbe ?? (() => false),
|
||||
placeholderActiveProbe: () => _watcher.PlaceholderObservedWithoutRealUser,
|
||||
utcNow: () => clock.UtcNow);
|
||||
}
|
||||
|
||||
public void Start() => _watcher.Start();
|
||||
public void Stop() => _watcher.Stop();
|
||||
|
||||
/// <summary>
|
||||
/// Composition-root entry point for the Hybrid User-Driven completion-gap fix
|
||||
/// (2026-05-01). Only call when both prerequisites hold: a) the prior agent process
|
||||
/// was killed by an OS reboot (<c>previousExitType=reboot_kill</c>), b) the
|
||||
/// Autopilot profile is Hybrid AAD Join (<c>isHybridJoin=true</c>). The detector
|
||||
/// itself enforces single-shot semantics + cancel-on-real-user — repeated calls or
|
||||
/// late calls after a real user joined are safe no-ops.
|
||||
/// Composition-root entry point for the Hybrid User-Driven sign-in-overdue warning
|
||||
/// (2026-05-01, semantics 2026-09-04). Only call when all prerequisites hold: a) the
|
||||
/// prior agent process was killed by an OS reboot (<c>previousExitType=reboot_kill</c>),
|
||||
/// b) the Autopilot profile is Hybrid AAD Join (<c>isHybridJoin=true</c>), c) the device
|
||||
/// is not a WhiteGlove device (neither resuming Part 2 nor carrying a Part-1 archive).
|
||||
/// The detector itself enforces single-shot semantics and both cancel paths —
|
||||
/// repeated calls or late calls after a desktop / real user are safe no-ops.
|
||||
/// </summary>
|
||||
public void ArmHybridLoginPendingDetector() => _hybridLoginPendingDetector.Arm();
|
||||
|
||||
// Test seam — exposes the detector for unit tests in the V2.Core.Tests project.
|
||||
/// <summary>
|
||||
/// The DesktopArrivalDetector resolved explorer.exe under a real user: cancels the
|
||||
/// sign-in-overdue timer and arms the user-affinity timer (Hybrid devices only).
|
||||
/// </summary>
|
||||
public void NotifyRealUserDesktop()
|
||||
{
|
||||
try { _hybridLoginPendingDetector.NotifyRealUserDesktop(); }
|
||||
catch (Exception ex) { LogCallbackFailure("HybridLoginPendingDetector.NotifyRealUserDesktop", ex); }
|
||||
try { _userAffinityDetector.NotifyRealUserDesktop(); }
|
||||
catch (Exception ex) { LogCallbackFailure("EntraUserAffinityDetector.NotifyRealUserDesktop", ex); }
|
||||
}
|
||||
|
||||
/// <summary>ImeLogTracker observed an IME-TOKEN-SUCCESS line (raw, every line).</summary>
|
||||
public void NotifyUserTokenAcquired(DateTime? lineUtc)
|
||||
{
|
||||
try { _userAffinityDetector.NotifyUserTokenAcquired(lineUtc); }
|
||||
catch (Exception ex) { LogCallbackFailure("EntraUserAffinityDetector.NotifyUserTokenAcquired", ex); }
|
||||
}
|
||||
|
||||
/// <summary>ImeLogTracker observed an IME-TOKEN-FAILURE line (raw, every line).</summary>
|
||||
public void NotifyTokenFailureLine(string? errorCode, DateTime? lineUtc)
|
||||
{
|
||||
try { _userAffinityDetector.NotifyTokenFailureLine(errorCode, lineUtc); }
|
||||
catch (Exception ex) { LogCallbackFailure("EntraUserAffinityDetector.NotifyTokenFailureLine", ex); }
|
||||
}
|
||||
|
||||
// Test seams — expose the detectors for unit tests in the V2.Core.Tests project.
|
||||
internal HybridLoginPendingDetector HybridLoginPendingDetectorForTest => _hybridLoginPendingDetector;
|
||||
internal EntraUserAffinityDetector UserAffinityDetectorForTest => _userAffinityDetector;
|
||||
|
||||
private static void LogCallbackFailure(string what, Exception ex)
|
||||
{
|
||||
// Host callbacks are best-effort — a detector fault must never break the caller
|
||||
// (DesktopArrivalDetector poll / ImeLogTracker drain). Swallowed on purpose; the
|
||||
// detectors log their own state transitions.
|
||||
_ = what; _ = ex;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
try { _userAffinityDetector.Dispose(); } catch { }
|
||||
try { _hybridLoginPendingDetector.Dispose(); } catch { }
|
||||
try { _adapter.Dispose(); } catch { }
|
||||
try { _watcher.Dispose(); } catch { }
|
||||
|
||||
@@ -37,6 +37,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public static class StateArchiver
|
||||
{
|
||||
internal const string Part1BucketPrefix = ".part1-";
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when <paramref name="stateDirectory"/> holds at least one archived
|
||||
/// <c>.part1-<ts>/</c> bucket — i.e. this device already went through a WhiteGlove
|
||||
/// Part-1/Part-2 resume in this session. Durable across later reboots, unlike the
|
||||
/// <c>whiteglove.complete</c> marker (cleared on resume) and the orchestrator's
|
||||
/// in-memory Part-2 flag. Used to keep sign-in-overdue warnings off pre-provisioned
|
||||
/// devices that legitimately wait days for their user (2026-09-04).
|
||||
/// </summary>
|
||||
public static bool HasPart1Archive(string stateDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(stateDirectory) || !Directory.Exists(stateDirectory)) return false;
|
||||
return Directory.GetDirectories(stateDirectory, Part1BucketPrefix + "*").Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reducer-state segment files moved into the <c>.part1-<ts>/</c> bucket on
|
||||
/// archive. <c>event-sequence.json</c> is intentionally NOT in this list — see the
|
||||
@@ -86,7 +109,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
if (!hasAny) return null;
|
||||
|
||||
var stamp = utcNow().ToString("yyyyMMdd'T'HHmmssfff'Z'");
|
||||
var bucket = Path.Combine(stateDirectory, ".part1-" + stamp);
|
||||
var bucket = Path.Combine(stateDirectory, Part1BucketPrefix + stamp);
|
||||
Directory.CreateDirectory(bucket);
|
||||
|
||||
foreach (var name in KnownSegmentFiles)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The install-time deployment marker: <c>HKLM\SOFTWARE\AutopilotMonitor\Deployed</c>, an
|
||||
/// ISO-8601 UTC instant written by <c>--install</c> right after the runtime was handed off
|
||||
/// (writer: <c>Program.InstallMode</c> in the runtime project). It is the bootstrap script's
|
||||
/// re-entry lock and — since 2026-09-04 — the one instant the agent knows in UTC that also
|
||||
/// appears in the IME logs: the Intune Management Extension logs the bootstrap script's
|
||||
/// result a few hundred milliseconds later, which anchors the timezone of the pre-agent log
|
||||
/// era (<c>ImeLogEraPreScan</c>). Read-only here; never written from Core.
|
||||
/// </summary>
|
||||
public static class DeploymentMarker
|
||||
{
|
||||
public const string RegistryKeyPath = @"SOFTWARE\AutopilotMonitor";
|
||||
public const string RegistryValueName = "Deployed";
|
||||
|
||||
/// <summary>The marker's UTC instant, or <c>null</c> when absent or unparseable.</summary>
|
||||
public static DateTime? TryReadDeployedUtc()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(RegistryKeyPath))
|
||||
{
|
||||
var raw = key?.GetValue(RegistryValueName) as string;
|
||||
return Parse(raw);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pure parse of the marker text (round-trip "O" format); exposed for tests.</summary>
|
||||
public static DateTime? Parse(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed))
|
||||
return null;
|
||||
return parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,6 +464,11 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (offsetMinutes.HasValue)
|
||||
data["sourceOffsetMinutes"] = offsetMinutes.Value.ToString(culture);
|
||||
|
||||
// Which era anchor the offset came from — forensics for the era-anchored origin.
|
||||
var anchorKind = _tracker.LastMatchedEraAnchorKind;
|
||||
if (!string.IsNullOrEmpty(anchorKind))
|
||||
data["sourceOffsetAnchor"] = anchorKind;
|
||||
|
||||
// Observational only — what the calibrator measured, which since the 2026-08-20
|
||||
// revert is deliberately NOT what was applied. Kept separate so the two can never
|
||||
// be read as the same thing.
|
||||
@@ -489,6 +494,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// The line anchored itself: read provably fresh, its own distance to the agent
|
||||
// clock (grid-rounded) was applied as the writer's offset. Era-safe per line.
|
||||
case CmTraceOffsetOrigin.LineAnchored: return "line-anchored";
|
||||
// A backlog line resolved through its writer era, anchored by the bootstrap
|
||||
// execution record against the install marker (ImeLogEraPreScan, 2026-09-04).
|
||||
case CmTraceOffsetOrigin.EraAnchored: return "era-anchored";
|
||||
// The line was not provably fresh (backlog, restart catch-up, replay), so this
|
||||
// process's own zone was assumed — correct only if the writer happens to share it.
|
||||
default: return "reader-zone-fallback";
|
||||
|
||||
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Signals
|
||||
{
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions
|
||||
{
|
||||
|
||||
+9
-5
@@ -395,12 +395,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeBatch(IReadOnlyList<TelemetryItem> items)
|
||||
/// <summary>
|
||||
/// The batch body: a JSON array of <see cref="TelemetryItemDto"/> — the Shared class the
|
||||
/// ingest deserialises — with Newtonsoft defaults (PascalCase, ISO-8601 UTC, Kind by name).
|
||||
/// Internal so <c>TelemetryWireContractTests</c> can freeze the real output as a fixture.
|
||||
/// </summary>
|
||||
internal static string SerializeBatch(IReadOnlyList<TelemetryItem> items)
|
||||
{
|
||||
// Newtonsoft PascalCase default + TelemetryItemKind carries [StringEnumConverter] →
|
||||
// Backend routes by string "Event"/"Signal"/"DecisionTransition". Wire-format
|
||||
// matches Plan §4.x M5 contract (PascalCase, StringEnum, ISO-8601 UTC).
|
||||
return JsonConvert.SerializeObject(items, Formatting.None);
|
||||
var wire = new TelemetryItemDto[items.Count];
|
||||
for (var i = 0; i < items.Count; i++) wire[i] = items[i].ToWire();
|
||||
return JsonConvert.SerializeObject(wire, Formatting.None);
|
||||
}
|
||||
|
||||
private static byte[] CompressWithGzip(string text)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
@@ -106,6 +107,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// <summary>Anzahl der bisher fehlgeschlagenen Upload-Versuche. Wird vom Orchestrator incrementiert.</summary>
|
||||
public int RetryCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The wire shape of this item. <see cref="TelemetryItemDto"/> (Shared) is the ONE class both
|
||||
/// ends serialise; this mapping is the only place the agent's validated model meets it.
|
||||
/// <c>TelemetryWireContractTests</c> proves every DTO property is populated here and
|
||||
/// freezes the resulting body under <c>tests/fixtures/telemetry-wire/</c> for the backend.
|
||||
/// </summary>
|
||||
public TelemetryItemDto ToWire() =>
|
||||
new TelemetryItemDto
|
||||
{
|
||||
Kind = Kind.ToString(),
|
||||
PartitionKey = PartitionKey,
|
||||
RowKey = RowKey,
|
||||
TelemetryItemId = TelemetryItemId,
|
||||
SessionTraceOrdinal = SessionTraceOrdinal,
|
||||
PayloadJson = PayloadJson,
|
||||
RequiresImmediateFlush = RequiresImmediateFlush,
|
||||
EnqueuedAtUtc = EnqueuedAtUtc,
|
||||
RetryCount = RetryCount,
|
||||
};
|
||||
|
||||
/// <summary>Copy-with-incremented <see cref="RetryCount"/>. Immutable-Erhalt (§2.3 L.3).</summary>
|
||||
public TelemetryItem WithRetryIncremented() =>
|
||||
new TelemetryItem(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
#nullable enable
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Typ eines <see cref="TelemetryItem"/>. Plan §2.7a / L.10.
|
||||
/// <para>
|
||||
/// Drei getrennte Backend-Tabellen:
|
||||
/// <see cref="Event"/> → <c>Events</c>,
|
||||
/// <see cref="Signal"/> → <c>Signals</c>,
|
||||
/// <see cref="DecisionTransition"/> → <c>DecisionTransitions</c>.
|
||||
/// Der generische Transport routet auf Backend-Seite nach <c>Kind</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// String-serialisiert via <see cref="StringEnumConverter"/> — Backend-Routing darf
|
||||
/// nicht von Integer-Ordering abhängen, das bei zukünftigen Erweiterungen bricht
|
||||
/// (L.14 forward-compat).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public enum TelemetryItemKind
|
||||
{
|
||||
Event = 0,
|
||||
Signal,
|
||||
DecisionTransition,
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -2,6 +2,7 @@
|
||||
using System;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
@@ -16,10 +17,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// </summary>
|
||||
public static class TelemetryItemSerializer
|
||||
{
|
||||
/// <summary>
|
||||
/// DecisionCore settings plus a <see cref="StringEnumConverter"/>: <see cref="TelemetryItem.Kind"/>
|
||||
/// is the only enum on a spool line and has always been written by NAME (<c>"Event"</c>),
|
||||
/// which is also what the wire carries. The enum lives in Shared (no Newtonsoft there), so
|
||||
/// the converter is registered here instead of as an attribute on the type.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerSettings SpoolSettings = CreateSpoolSettings();
|
||||
|
||||
private static JsonSerializerSettings CreateSpoolSettings()
|
||||
{
|
||||
var settings = DecisionCoreJsonSettings.Create();
|
||||
settings.Converters.Add(new StringEnumConverter());
|
||||
return settings;
|
||||
}
|
||||
|
||||
public static string Serialize(TelemetryItem item)
|
||||
{
|
||||
if (item == null) throw new ArgumentNullException(nameof(item));
|
||||
return JsonConvert.SerializeObject(item, DecisionCoreJsonSettings.Shared);
|
||||
return JsonConvert.SerializeObject(item, SpoolSettings);
|
||||
}
|
||||
|
||||
public static TelemetryItem Deserialize(string line)
|
||||
@@ -28,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
|
||||
try
|
||||
{
|
||||
var result = JsonConvert.DeserializeObject<TelemetryItem>(line, DecisionCoreJsonSettings.Shared);
|
||||
var result = JsonConvert.DeserializeObject<TelemetryItem>(line, SpoolSettings);
|
||||
if (result == null)
|
||||
{
|
||||
throw new JsonSerializationException($"TelemetryItem deserialization produced null: {line}");
|
||||
|
||||
@@ -37,8 +37,10 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// </summary>
|
||||
public static partial class Program
|
||||
{
|
||||
internal const string DeploymentRegistryKey = @"SOFTWARE\AutopilotMonitor";
|
||||
internal const string DeploymentRegistryValue = "Deployed";
|
||||
// Shared with the Core reader (DeploymentMarker) — the marker's UTC instant anchors the
|
||||
// pre-agent IME log era, so writer and reader must agree on key, value and format.
|
||||
internal const string DeploymentRegistryKey = AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.RegistryKeyPath;
|
||||
internal const string DeploymentRegistryValue = AutopilotMonitor.Agent.V2.Core.Security.DeploymentMarker.RegistryValueName;
|
||||
internal const string BootstrapConfigFileName = "bootstrap-config.json";
|
||||
internal const string AwaitEnrollmentConfigFileName = "await-enrollment.json";
|
||||
private const string InstalledAgentExeName = "AutopilotMonitor.Agent.exe";
|
||||
|
||||
@@ -494,14 +494,19 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
LifecycleEmitters.PostSystemRebootObserved(orchestrator.IngressSink, previousExit, logger);
|
||||
|
||||
// Hybrid User-Driven completion-gap fix (2026-05-01): the reboot is the
|
||||
// expected switch from foouser/autopilot OOBE to the real AD account.
|
||||
// If the user never logs in we go silent until the 5-h backend watchdog;
|
||||
// arm a single-shot 10-min detector that emits hybrid_login_pending
|
||||
// once if the AAD user join is overdue. Cancelled when AadJoinWatcher
|
||||
// sees the real user. Only armed for actual Hybrid devices — non-Hybrid
|
||||
// reboots don't have the placeholder→real-user flow that motivates this.
|
||||
if (!isWhiteGloveResume && EnrollmentRegistryDetector.DetectHybridJoin())
|
||||
// Hybrid User-Driven sign-in gap (2026-05-01, semantics 2026-09-04): after
|
||||
// the Hybrid reboot the user has to sign in with the AD account. If nobody
|
||||
// does, the agent goes silent until the backend watchdog; arm a single-shot
|
||||
// 10-min detector that emits hybrid_login_pending once when no real-user
|
||||
// desktop appeared by then. Cancelled by the DesktopArrivalDetector (the
|
||||
// sign-in evidence) or by the AadJoinWatcher seeing a real user (Entra-join
|
||||
// flavour). Never on WhiteGlove devices: a sealed device legitimately waits
|
||||
// days for its user, and AwaitingUser already describes that — the Part-1
|
||||
// archive check covers reboots inside Part 2 after the resume marker is gone.
|
||||
if (!isWhiteGloveResume
|
||||
&& !orchestrator.IsWhiteGlovePart2
|
||||
&& !orchestrator.HasWhiteGlovePart1Archive
|
||||
&& EnrollmentRegistryDetector.DetectHybridJoin())
|
||||
{
|
||||
try { orchestrator.CollectorSurfaces?.AadJoinHost?.ArmHybridLoginPendingDetector(); }
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -11,9 +11,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
{
|
||||
/// <summary>
|
||||
/// Every payload-derived string that lands in <see cref="DecisionState"/> is bounded by
|
||||
/// <see cref="FactStringBounds"/> at the reducer boundary. The server-side semantic replay
|
||||
/// (<c>ReducerVerifier</c>) folds device-uploaded payloads through the same engine, so an
|
||||
/// unbounded string there is a per-request memory/CPU amplifier. These tests pin that the
|
||||
/// <see cref="FactStringBounds"/> at the reducer boundary — an unbounded string there is a
|
||||
/// per-signal memory/CPU amplifier in every snapshot persist. These tests pin that the
|
||||
/// bound is applied BEFORE any dedupe comparison (so a forged multi-hundred-KB key neither
|
||||
/// costs a full-length compare nor lands in state) and that real-sized values pass through
|
||||
/// unchanged.
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Default-value + round-trip tests for <see cref="AdminConfiguration.EnableIndexDualWrite"/>
|
||||
/// (Plan §M5.d). The flag gates the index-table dual-write rollout — defaulting to false
|
||||
/// keeps pre-M5.d behaviour bit-exact and lets operators flip it on deliberately.
|
||||
/// </summary>
|
||||
public class AdminConfigurationEnableIndexDualWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnableIndexDualWrite_defaults_to_false_on_new_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration();
|
||||
Assert.False(cfg.EnableIndexDualWrite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnableIndexDualWrite_defaults_to_false_on_CreateDefault()
|
||||
{
|
||||
// CreateDefault() is called when no admin-config row exists yet — if it somehow
|
||||
// enabled dual-write it would break the "default-off" contract immediately.
|
||||
var cfg = AdminConfiguration.CreateDefault();
|
||||
Assert.False(cfg.EnableIndexDualWrite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnableIndexDualWrite_true_persists_on_the_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration { EnableIndexDualWrite = true };
|
||||
Assert.True(cfg.EnableIndexDualWrite);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// JSON round-trip tests for <see cref="AnalyzeOnEnrollmentEndEnvelope"/>. The producer
|
||||
/// (<c>AzureQueueAnalyzeOnEnrollmentEndProducer</c>) serializes with Newtonsoft and the
|
||||
/// queue worker (<c>AnalyzeOnEnrollmentEndQueueWorker</c>) deserializes the same shape —
|
||||
/// queue worker (<c>AnalyzeOnEnrollmentEndQueueFunction</c>) deserializes the same shape —
|
||||
/// pinning the contract here keeps the queue from silently breaking when fields are added.
|
||||
/// </summary>
|
||||
public class AnalyzeOnEnrollmentEndEnvelopeSerializationTests
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The middleware-side error-envelope writer: status, content type, Retry-After and the exact body
|
||||
/// (production wire options — camelCase, absent-when-null) for the generic envelope and for a
|
||||
/// specialised <see cref="IApiErrorResponse"/>. Whatever the authentication, policy, rate-limit and
|
||||
/// quota middlewares write flows through here, so this is the one place their body shape is pinned.
|
||||
/// </summary>
|
||||
public class ApiErrorWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Generic_envelope_writes_status_content_type_and_the_three_prefix_keys()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-42", HttpStatusCode.Unauthorized,
|
||||
Constants.ApiErrorCodes.AuthenticationRequired, "Authentication required.");
|
||||
|
||||
Assert.Equal(401, ctx.Response.StatusCode);
|
||||
Assert.StartsWith("application/json", ctx.Response.ContentType);
|
||||
Assert.False(ctx.Response.Headers.ContainsKey("Retry-After"));
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Authentication required.\",\"code\":\"AuthenticationRequired\",\"correlationId\":\"cid-42\"}",
|
||||
Body(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generic_envelope_with_retry_window_sets_the_header_and_mirrors_it_in_the_body()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-7", HttpStatusCode.TooManyRequests,
|
||||
Constants.ApiErrorCodes.RateLimited, "Rate limit exceeded: 60 requests per minute",
|
||||
retryAfterSeconds: 23);
|
||||
|
||||
Assert.Equal(429, ctx.Response.StatusCode);
|
||||
Assert.Equal("23", ctx.Response.Headers["Retry-After"]);
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Rate limit exceeded: 60 requests per minute\",\"code\":\"RateLimited\",\"correlationId\":\"cid-7\",\"retryAfterSeconds\":23}",
|
||||
Body(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generic_envelope_carries_hint_and_operation_only_when_given()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-9", HttpStatusCode.InternalServerError,
|
||||
Constants.ApiErrorCodes.InternalError, "QueryTable failed.",
|
||||
hint: "Retry with a narrower filter.", operation: "QueryTable");
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"QueryTable failed.\",\"code\":\"InternalError\",\"correlationId\":\"cid-9\"," +
|
||||
"\"hint\":\"Retry with a narrower filter.\",\"operation\":\"QueryTable\"}",
|
||||
Body(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Specialised_body_is_stamped_with_the_correlation_id_and_serialized_as_its_runtime_type()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
var body = new DelegatedSlotLimitReachedResponse
|
||||
{
|
||||
Error = "Slot limit reached.",
|
||||
HomeTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001",
|
||||
Used = 2,
|
||||
Limit = 2,
|
||||
Required = 1,
|
||||
};
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-slot", HttpStatusCode.Conflict, body);
|
||||
|
||||
Assert.Equal(409, ctx.Response.StatusCode);
|
||||
Assert.Equal("cid-slot", body.CorrelationId);
|
||||
var json = JsonDocument.Parse(Body(ctx)).RootElement;
|
||||
// Envelope prefix first, then the specialised fields — and nothing of the marker interface.
|
||||
var keys = json.EnumerateObject().Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { "error", "code", "correlationId", "homeTenantId", "used", "limit", "required" }, keys);
|
||||
Assert.Equal("DelegatedSlotLimitReached", json.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
private static DefaultHttpContext NewContext()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static string Body(HttpContext ctx)
|
||||
{
|
||||
ctx.Response.Body.Position = 0;
|
||||
using var reader = new StreamReader(ctx.Response.Body);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -29,6 +30,33 @@ public class ApiResponseWireParityTests
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
// ---- Error envelope (2026-09, deliberate wire change) ----------------------------------
|
||||
// Non-2xx bodies used to be ~10 anonymous shapes ({ success = false, message }, { error },
|
||||
// { error, message }, { error, code }, …). They are ONE typed envelope now, so these pins fix
|
||||
// the NEW shape exactly rather than proving parity with any single legacy literal.
|
||||
|
||||
[Fact]
|
||||
public void ApiErrorResponse_pins_the_minimal_envelope_shape()
|
||||
{
|
||||
var body = ApiErrorWriter.Build("cid-1", Constants.ApiErrorCodes.NotFound, "Session not found.");
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Session not found.\",\"code\":\"NotFound\",\"correlationId\":\"cid-1\"}",
|
||||
JsonSerializer.Serialize(body, WireOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiErrorResponse_pins_the_full_envelope_shape_and_key_order()
|
||||
{
|
||||
var body = ApiErrorWriter.Build("cid-2", Constants.ApiErrorCodes.ServiceUnavailable,
|
||||
"Authorization service temporarily unavailable.", hint: "Retry.", retryAfterSeconds: 5, operation: "PolicyEnforcement");
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Authorization service temporarily unavailable.\",\"code\":\"ServiceUnavailable\"," +
|
||||
"\"correlationId\":\"cid-2\",\"hint\":\"Retry.\",\"retryAfterSeconds\":5,\"operation\":\"PolicyEnforcement\"}",
|
||||
JsonSerializer.Serialize(body, WireOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessMessageResponse_matches_the_canonical_anonymous_shape()
|
||||
{
|
||||
|
||||
@@ -38,6 +38,30 @@ public class AppInsightsQueryFunctionTests
|
||||
Assert.Equal(expected, AppInsightsQueryFunction.ClampBudget(requested));
|
||||
}
|
||||
|
||||
// ── Upstream failure → status the caller sees. Query errors are the caller's (400, rendered in full
|
||||
// by the MCP); a missing managed-identity grant (401/403) and store failures (5xx) are 502. ──
|
||||
[Theory]
|
||||
[InlineData(400, System.Net.HttpStatusCode.BadRequest)] // SyntaxError / SemanticError / BadArgumentError
|
||||
[InlineData(404, System.Net.HttpStatusCode.BadRequest)] // unknown app id in the setting: still a config/caller problem, not the store
|
||||
[InlineData(401, System.Net.HttpStatusCode.BadGateway)]
|
||||
[InlineData(403, System.Net.HttpStatusCode.BadGateway)]
|
||||
[InlineData(429, System.Net.HttpStatusCode.BadRequest)]
|
||||
[InlineData(500, System.Net.HttpStatusCode.BadGateway)]
|
||||
[InlineData(503, System.Net.HttpStatusCode.BadGateway)]
|
||||
public void Upstream_failures_map_to_400_for_the_query_and_502_for_grant_or_store(int upstream, System.Net.HttpStatusCode expected)
|
||||
{
|
||||
Assert.Equal(expected, AppInsightsQueryFunction.MapUpstreamFailure((System.Net.HttpStatusCode)upstream));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hints_name_the_query_problem_or_the_missing_grant()
|
||||
{
|
||||
Assert.Contains("syntax error", AppInsightsQueryFunction.UpstreamHint(System.Net.HttpStatusCode.BadRequest, "SyntaxError", "backend")!);
|
||||
Assert.Contains("getschema", AppInsightsQueryFunction.UpstreamHint(System.Net.HttpStatusCode.BadRequest, "SemanticError", "web")!);
|
||||
Assert.Contains("Log Analytics Reader", AppInsightsQueryFunction.UpstreamHint(System.Net.HttpStatusCode.Forbidden, null, "mcp")!);
|
||||
Assert.Null(AppInsightsQueryFunction.UpstreamHint(System.Net.HttpStatusCode.InternalServerError, "InternalError", "backend"));
|
||||
}
|
||||
|
||||
// ── Kusto body → typed tables; cells forwarded as the JSON the store produced ──
|
||||
[Fact]
|
||||
public void Kusto_body_is_parsed_cell_for_cell_and_complete_results_carry_no_partial()
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Application principals — a service principal presenting an app-only token (idtyp=app, typically behind a
|
||||
/// federated credential) — flow through the same role tables as a person under the key
|
||||
/// <c>app:<client-id></c>. These tests pin the four places where that is decided: the classification
|
||||
/// (<see cref="ClaimsPrincipalExtensions.GetUserPrincipalName"/>), the authentication gate
|
||||
/// (<see cref="AuthenticationMiddleware.ValidateApplicationPrincipal"/>: the access_as_application
|
||||
/// permission is mandatory), the roleless-tier deny in the policy middleware (an application is never
|
||||
/// "some authenticated person"), and the caps — Viewer in a tenant, DelegatedReader on managed tenants,
|
||||
/// never a platform role — enforced at resolution rather than only at grant time.
|
||||
/// </summary>
|
||||
public class ApplicationPrincipalTests
|
||||
{
|
||||
private const string TenantA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TenantB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string AppId = "AAAAAAAA-0000-0000-0000-00000000AAAA";
|
||||
private const string AppOid = "bbbbbbbb-0000-0000-0000-0000bbbbbbbb";
|
||||
private static readonly string AppKey = Constants.PrincipalKeys.ForApplication(AppId);
|
||||
|
||||
private static ClaimsPrincipal AppPrincipal(
|
||||
string tenantId = TenantA, string? appId = AppId, string? oid = AppOid, bool withPermission = true,
|
||||
string appIdClaim = "appid", bool idtyp = true)
|
||||
{
|
||||
var claims = new List<Claim> { new("tid", tenantId) };
|
||||
if (idtyp) claims.Add(new Claim("idtyp", "app"));
|
||||
if (appId != null) claims.Add(new Claim(appIdClaim, appId));
|
||||
if (oid != null) claims.Add(new Claim("oid", oid));
|
||||
if (withPermission) claims.Add(new Claim("roles", Constants.ApplicationPermissions.AccessAsApplication));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal UserPrincipal(string upn = "alice@contoso.example", string tenantId = TenantA)
|
||||
=> new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("tid", tenantId), new Claim("upn", upn), new Claim("oid", "cccccccc-0000-0000-0000-0000cccccccc"),
|
||||
new Claim("scp", "access_as_user"),
|
||||
}, "TestAuth"));
|
||||
|
||||
// ── Classification: the principal key ───────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("appid")]
|
||||
[InlineData("azp")]
|
||||
public void AppOnlyToken_YieldsTheApplicationKey_FromV1AndV2Claims(string claim)
|
||||
{
|
||||
var principal = AppPrincipal(appIdClaim: claim);
|
||||
|
||||
Assert.True(principal.IsApplicationPrincipal());
|
||||
Assert.Equal(AppId.ToLowerInvariant(), principal.GetApplicationId());
|
||||
Assert.Equal($"app:{AppId.ToLowerInvariant()}", principal.GetUserPrincipalName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokenWithoutIdtyp_IsNotAnApplication_AndHasNoKey()
|
||||
{
|
||||
// Fail-closed classification: no idtyp ⇒ a person, who then needs a UPN like everyone else.
|
||||
var principal = AppPrincipal(idtyp: false);
|
||||
|
||||
Assert.False(principal.IsApplicationPrincipal());
|
||||
Assert.Null(principal.GetUserPrincipalName());
|
||||
Assert.Null(AdminIdentity.FromPrincipal(principal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserToken_KeepsItsUpn_EvenWhenAnAppIdIsPresent()
|
||||
{
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("tid", TenantA), new Claim("upn", "Alice@Contoso.Example"), new Claim("appid", AppId),
|
||||
}, "TestAuth"));
|
||||
|
||||
Assert.Equal("Alice@Contoso.Example", principal.GetUserPrincipalName());
|
||||
Assert.False(principal.IsApplicationPrincipal());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdminIdentity_OfAnApplication_IsFlagged()
|
||||
{
|
||||
var identity = AdminIdentity.FromPrincipal(AppPrincipal());
|
||||
|
||||
Assert.NotNull(identity);
|
||||
Assert.True(identity!.IsApplication);
|
||||
Assert.Equal(AppKey, identity.Upn);
|
||||
Assert.Equal(AppOid, identity.ObjectId);
|
||||
Assert.False(AdminIdentity.FromPrincipal(UserPrincipal())!.IsApplication);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrincipalKeys_CannotCollideWithAUpn()
|
||||
{
|
||||
Assert.True(Constants.PrincipalKeys.IsApplication(AppKey));
|
||||
Assert.False(Constants.PrincipalKeys.IsApplication("app@contoso.example"));
|
||||
Assert.Equal(AppId.ToLowerInvariant(), Constants.PrincipalKeys.TryGetApplicationId(AppKey));
|
||||
Assert.Null(Constants.PrincipalKeys.TryGetApplicationId("alice@contoso.example"));
|
||||
}
|
||||
|
||||
// ── Authentication gate ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Gate_PassesUserTokens_Untouched()
|
||||
=> Assert.Null(AuthenticationMiddleware.ValidateApplicationPrincipal(UserPrincipal()));
|
||||
|
||||
[Fact]
|
||||
public void Gate_PassesAnAdmissibleAppToken()
|
||||
=> Assert.Null(AuthenticationMiddleware.ValidateApplicationPrincipal(AppPrincipal()));
|
||||
|
||||
[Fact]
|
||||
public void Gate_RejectsAnAppToken_WithoutTheApplicationPermission()
|
||||
{
|
||||
var reason = AuthenticationMiddleware.ValidateApplicationPrincipal(AppPrincipal(withPermission: false));
|
||||
|
||||
Assert.NotNull(reason);
|
||||
Assert.Contains(Constants.ApplicationPermissions.AccessAsApplication, reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_RejectsAnAppToken_WithoutApplicationIdOrObjectId()
|
||||
{
|
||||
Assert.Contains("application id", AuthenticationMiddleware.ValidateApplicationPrincipal(AppPrincipal(appId: null))!);
|
||||
Assert.Contains("object id", AuthenticationMiddleware.ValidateApplicationPrincipal(AppPrincipal(oid: null))!);
|
||||
}
|
||||
|
||||
// ── Policy middleware: roleless tiers and caps ──────────────────────────────
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public required PolicyEnforcementMiddleware Middleware { get; init; }
|
||||
public required Mock<IAdminRepository> Repo { get; init; }
|
||||
|
||||
public void MemberRow(string role, bool enabled = true) =>
|
||||
Repo.Setup(r => r.GetTenantMemberAsync(TenantA, AppKey))
|
||||
.ReturnsAsync(new TenantMember { TenantId = TenantA, Upn = AppKey, Role = role, IsEnabled = enabled });
|
||||
}
|
||||
|
||||
private static Harness BuildHarness()
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
|
||||
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
|
||||
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<DelegatedAdminEntry>());
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
|
||||
repo.Setup(r => r.GetIdentityBindingAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(new AdminIdentityBinding { TenantId = TenantA, ObjectId = AppOid });
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var bindings = new AdminIdentityBindingService(repo.Object, cache, NullLogger<AdminIdentityBindingService>.Instance);
|
||||
var globalAdmin = new GlobalAdminService(repo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance);
|
||||
var delegatedAdmin = new DelegatedAdminService(
|
||||
repo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro), cache, NullLogger<DelegatedAdminService>.Instance);
|
||||
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
|
||||
var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var mw = new PolicyEnforcementMiddleware(
|
||||
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
|
||||
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig, new RecordingDenialReporter());
|
||||
return new Harness { Middleware = mw, Repo = repo };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/feedback/status")] // AuthenticatedUser
|
||||
[InlineData("GET", "/api/auth/me")] // AuthenticatedUser
|
||||
[InlineData("POST", "/api/realtime/groups/join")] // AuthenticatedUserWithRole
|
||||
public async Task RolelessTiers_DenyAnApplication_UnlessTheEntryOptsIn(string method, string path)
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.MemberRow(Constants.TenantRoles.Viewer);
|
||||
|
||||
var r = await h.Middleware.DecideAsync(method, path, null, AppPrincipal());
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(403, r.StatusCode);
|
||||
Assert.Equal("ApplicationPrincipalNotAllowed", r.LogReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheMcpFrontDoor_AdmitsAnApplication()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/auth/mcp", null, AppPrincipal());
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.Equal(AppKey, r.Context!.UserPrincipalName);
|
||||
Assert.Equal(AppOid, r.Context.ObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MemberRead_AdmitsAnApplicationMember_CappedToViewer()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.MemberRow(Constants.TenantRoles.Admin); // a row edited to Admin still reads as Viewer
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AppPrincipal());
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, r.Context!.UserRole);
|
||||
Assert.False(r.Context.IsTenantAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MemberRead_DeniesAnApplication_WithoutARow()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AppPrincipal());
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(403, r.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("POST", "/api/sessions/abc/actions")] // TenantAdminOrOperator
|
||||
[InlineData("PUT", "/api/sessions/abc/annotations/operator")]
|
||||
[InlineData("POST", "/api/config/11111111-1111-1111-1111-111111111111")] // TenantAdminOrGA
|
||||
public async Task WriteTiers_NeverAdmitAnApplication_EvenWithAnAdminRow(string method, string path)
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.MemberRow(Constants.TenantRoles.Admin);
|
||||
|
||||
var r = await h.Middleware.DecideAsync(method, path, null, AppPrincipal());
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(403, r.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformRoleRow_IsInert_ForAnApplication()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.Repo.Setup(r => r.GetGlobalRoleAsync(AppKey)).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, AppPrincipal());
|
||||
|
||||
Assert.False(r.Allowed);
|
||||
Assert.Equal(403, r.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatedScope_OfAnApplication_IsCappedToReader()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.Repo.Setup(r => r.GetDelegatedTenantsAsync(AppKey)).ReturnsAsync(new List<DelegatedAdminEntry>
|
||||
{
|
||||
new()
|
||||
{
|
||||
TenantId = TenantB, Role = Constants.DelegatedRoles.DelegatedAdmin, IsEnabled = true,
|
||||
Status = Constants.DelegatedStatus.Active, Source = Constants.DelegatedSource.OperatorGranted,
|
||||
},
|
||||
});
|
||||
|
||||
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AppPrincipal());
|
||||
|
||||
Assert.True(r.Allowed);
|
||||
Assert.True(r.Context!.IsDelegatedReader);
|
||||
Assert.False(r.Context.IsDelegatedAdmin);
|
||||
}
|
||||
|
||||
// ── Member-role resolver cap ────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Admin)]
|
||||
[InlineData(Constants.TenantRoles.Operator)]
|
||||
[InlineData(Constants.TenantRoles.Viewer)]
|
||||
public async Task Resolver_CapsAnApplicationRow_ToViewer(string rowRole)
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetTenantMemberAsync(TenantA, AppKey))
|
||||
.ReturnsAsync(new TenantMember { TenantId = TenantA, Upn = AppKey, Role = rowRole, IsEnabled = true });
|
||||
var sut = Resolver(repo);
|
||||
|
||||
var role = await sut.ResolveAsync(TenantA, AppKey, new[] { Constants.ApplicationPermissions.AccessAsApplication });
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, role?.Role);
|
||||
Assert.False(role!.CanManageBootstrapTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolver_NeverDerivesAnApplicationRole_FromClaims()
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((TenantMember?)null);
|
||||
var sut = Resolver(repo, appRolesEnabled: true);
|
||||
|
||||
// Even a (hypothetical) Admin app-role claim on an app token grants nothing without a row.
|
||||
var role = await sut.ResolveAsync(TenantA, AppKey, new[] { Constants.TenantRoles.Admin });
|
||||
|
||||
Assert.Null(role);
|
||||
}
|
||||
|
||||
private static TenantMemberRoleResolver Resolver(Mock<IAdminRepository> repo, bool appRolesEnabled = false)
|
||||
{
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantA, EntraAppRolesEnabled = appRolesEnabled });
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
return new TenantMemberRoleResolver(
|
||||
new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance),
|
||||
new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
// ── MCP front door ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task McpAccess_ForAnApplicationMember_IsAllMembers_AndNamesTheKey()
|
||||
{
|
||||
var (sut, members) = McpService();
|
||||
members.Verdict = (tid, key, _) => tid == TenantA && key == AppKey ? new MemberRoleInfo { Role = Constants.TenantRoles.Viewer } : null;
|
||||
|
||||
var result = await sut.IsAllowedAsync(AppKey, TenantA, AppOid, new[] { Constants.ApplicationPermissions.AccessAsApplication });
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal("AllMembers", result.AccessGrant);
|
||||
Assert.Equal(AppKey, result.Upn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpAccess_ForAnApplication_WithoutMembership_SaysSo()
|
||||
{
|
||||
var (sut, _) = McpService();
|
||||
|
||||
var result = await sut.IsAllowedAsync(AppKey, TenantA, AppOid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Contains("Service principal", result.Reason);
|
||||
}
|
||||
|
||||
private static (McpUserService Sut, StubTenantMemberRoleResolver Members) McpService()
|
||||
{
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
var globalAdmin = new Mock<GlobalAdminService>(adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
var delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro), cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
var members = new StubTenantMemberRoleResolver();
|
||||
var sut = new McpUserService(
|
||||
adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members);
|
||||
return (sut, members);
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -61,7 +61,8 @@ public class AzureQueueImeMsiArchiveProducerTests
|
||||
public async Task EnqueueAsync_send_failure_is_swallowed()
|
||||
{
|
||||
var (producer, queue, _) = BuildSut();
|
||||
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
queue.Setup(q => q.SendMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "queue busy"));
|
||||
|
||||
// Fire-and-forget continuation on the ingest path — must never throw.
|
||||
@@ -107,8 +108,9 @@ public class AzureQueueImeMsiArchiveProducerTests
|
||||
queue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, CancellationToken>((body, _) =>
|
||||
queue.Setup(q => q.SendMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, TimeSpan?, TimeSpan?, CancellationToken>((body, _, _, _) =>
|
||||
{
|
||||
sentBodies.Add(body);
|
||||
var receipt = QueuesModelFactory.SendReceipt(
|
||||
|
||||
@@ -476,11 +476,13 @@ public class ConfigWireParityTests
|
||||
trialExpiresUtc,
|
||||
trialConsumed = true,
|
||||
effectiveEdition = "pro",
|
||||
editionSource = "plan",
|
||||
retentionGraceEndsUtc,
|
||||
maxDelegatedTenants = 5,
|
||||
maxDelegatedTenantsOverride = 5,
|
||||
mcpUsagePlan = "msp",
|
||||
mcpUsagePlanOverride = "msp"
|
||||
mcpUsagePlanOverride = "msp",
|
||||
payingCustomer = true
|
||||
},
|
||||
new SetTenantPlanTierResponse
|
||||
{
|
||||
@@ -489,11 +491,13 @@ public class ConfigWireParityTests
|
||||
TrialExpiresUtc = trialExpiresUtc,
|
||||
TrialConsumed = true,
|
||||
EffectiveEdition = "pro",
|
||||
EditionSource = "plan",
|
||||
RetentionGraceEndsUtc = retentionGraceEndsUtc,
|
||||
MaxDelegatedTenants = 5,
|
||||
MaxDelegatedTenantsOverride = 5,
|
||||
McpUsagePlan = "msp",
|
||||
McpUsagePlanOverride = "msp"
|
||||
McpUsagePlanOverride = "msp",
|
||||
PayingCustomer = true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -514,11 +518,13 @@ public class ConfigWireParityTests
|
||||
trialExpiresUtc,
|
||||
trialConsumed = false,
|
||||
effectiveEdition = "community",
|
||||
editionSource = "community",
|
||||
retentionGraceEndsUtc,
|
||||
maxDelegatedTenants = 0,
|
||||
maxDelegatedTenantsOverride,
|
||||
mcpUsagePlan = "community",
|
||||
mcpUsagePlanOverride
|
||||
mcpUsagePlanOverride,
|
||||
payingCustomer = false
|
||||
},
|
||||
new SetTenantPlanTierResponse
|
||||
{
|
||||
@@ -527,11 +533,13 @@ public class ConfigWireParityTests
|
||||
TrialExpiresUtc = null,
|
||||
TrialConsumed = false,
|
||||
EffectiveEdition = "community",
|
||||
EditionSource = "community",
|
||||
RetentionGraceEndsUtc = null,
|
||||
MaxDelegatedTenants = 0,
|
||||
MaxDelegatedTenantsOverride = null,
|
||||
McpUsagePlan = "community",
|
||||
McpUsagePlanOverride = null
|
||||
McpUsagePlanOverride = null,
|
||||
PayingCustomer = false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Concurrent cache misses on the two configuration services share one repository read
|
||||
/// (audit 2026-09-05 F12). The MemoryCache stays: the hit path and the in-place projection
|
||||
/// semantics are untouched, only the miss path is single-flighted.
|
||||
/// </summary>
|
||||
public class ConfigurationServiceSingleFlightTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Admin_config_concurrent_misses_issue_one_repository_read()
|
||||
{
|
||||
var gate = new TaskCompletionSource<AdminConfiguration?>();
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync()).Returns(gate.Task);
|
||||
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync()).ToArray();
|
||||
gate.SetResult(AdminConfiguration.CreateDefault());
|
||||
var results = await Task.WhenAll(calls);
|
||||
|
||||
repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Once);
|
||||
Assert.All(results, r => Assert.Same(results[0], r));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_config_after_completion_the_next_miss_reads_again()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync()).ReturnsAsync(AdminConfiguration.CreateDefault());
|
||||
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await sut.GetConfigurationAsync();
|
||||
sut.InvalidateCache();
|
||||
await sut.GetConfigurationAsync();
|
||||
|
||||
repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_config_concurrent_misses_for_one_tenant_issue_one_repository_read()
|
||||
{
|
||||
const string tenantId = "11111111-1111-1111-1111-111111111111";
|
||||
var gate = new TaskCompletionSource<TenantConfiguration?>();
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(tenantId)).Returns(gate.Task);
|
||||
var sut = new TenantConfigurationService(repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync(tenantId)).ToArray();
|
||||
gate.SetResult(TenantConfiguration.CreateDefault(tenantId));
|
||||
var results = await Task.WhenAll(calls);
|
||||
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(tenantId), Times.Once);
|
||||
Assert.All(results, r => Assert.Same(results[0], r));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_config_different_tenants_do_not_share_a_flight()
|
||||
{
|
||||
const string a = "11111111-1111-1111-1111-111111111111";
|
||||
const string b = "22222222-2222-2222-2222-222222222222";
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.Returns<string>(id => Task.FromResult<TenantConfiguration?>(TenantConfiguration.CreateDefault(id)));
|
||||
var sut = new TenantConfigurationService(repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await Task.WhenAll(sut.GetConfigurationAsync(a), sut.GetConfigurationAsync(b));
|
||||
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(a), Times.Once);
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(b), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DecisionGraphBuilder"/> — pure projection, zero IO. Covers node
|
||||
/// de-duplication, terminal classification, dead-end preservation, visit-count semantics, and
|
||||
/// empty / out-of-order inputs.
|
||||
/// </summary>
|
||||
public class DecisionGraphBuilderTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static DecisionTransitionRecord Transition(
|
||||
int step, string from, string to, bool taken = true, string? deadEnd = null, string trigger = "t",
|
||||
string reducer = "1.0.0")
|
||||
=> new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = step,
|
||||
FromStage = from,
|
||||
ToStage = to,
|
||||
Taken = taken,
|
||||
DeadEndReason = deadEnd,
|
||||
Trigger = trigger,
|
||||
ReducerVersion = reducer,
|
||||
IsTerminal = DecisionGraphBuilder.IsTerminalStage(to),
|
||||
OccurredAtUtc = new DateTime(2026, 4, 21, 10, step, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
// ============================================================ Empty input
|
||||
|
||||
[Fact]
|
||||
public void Build_returns_empty_projection_when_no_transitions()
|
||||
{
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, Array.Empty<DecisionTransitionRecord>());
|
||||
|
||||
Assert.Equal(TenantId, graph.TenantId);
|
||||
Assert.Equal(SessionId, graph.SessionId);
|
||||
Assert.Empty(graph.Nodes);
|
||||
Assert.Empty(graph.Edges);
|
||||
Assert.Equal(string.Empty, graph.ReducerVersion);
|
||||
}
|
||||
|
||||
// ============================================================ Node de-duplication
|
||||
|
||||
[Fact]
|
||||
public void Build_deduplicates_stages_into_unique_nodes()
|
||||
{
|
||||
// Same stage visited twice through different paths — appears once in Nodes.
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "SessionStarted", "EspInProgress"),
|
||||
Transition(1, "EspInProgress", "AccountSetup"),
|
||||
Transition(2, "AccountSetup", "EspInProgress"), // back-edge (hypothetical)
|
||||
Transition(3, "EspInProgress", "Completed"),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
// Unique stages: SessionStarted, EspInProgress, AccountSetup, Completed = 4 nodes.
|
||||
Assert.Equal(4, graph.Nodes.Count);
|
||||
Assert.Contains(graph.Nodes, n => n.Id == "SessionStarted");
|
||||
Assert.Contains(graph.Nodes, n => n.Id == "EspInProgress");
|
||||
Assert.Contains(graph.Nodes, n => n.Id == "AccountSetup");
|
||||
Assert.Contains(graph.Nodes, n => n.Id == "Completed");
|
||||
}
|
||||
|
||||
// ============================================================ Terminal classification
|
||||
|
||||
[Theory]
|
||||
[InlineData("Completed", "Succeeded")]
|
||||
[InlineData("Failed", "Failed")]
|
||||
[InlineData("WhiteGloveSealed", "PausedForPart2")]
|
||||
public void Build_marks_terminal_stages_and_derives_outcome(string terminalStage, string expectedOutcome)
|
||||
{
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "Start", terminalStage),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
var node = graph.Nodes.Single(n => n.Id == terminalStage);
|
||||
|
||||
Assert.True(node.IsTerminal);
|
||||
Assert.Equal(expectedOutcome, node.TerminalOutcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_leaves_non_terminal_nodes_unmarked()
|
||||
{
|
||||
var transitions = new[] { Transition(0, "SessionStarted", "EspInProgress") };
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
Assert.All(graph.Nodes, n =>
|
||||
{
|
||||
Assert.False(n.IsTerminal);
|
||||
Assert.Null(n.TerminalOutcome);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================ Edges
|
||||
|
||||
[Fact]
|
||||
public void Build_preserves_all_edges_including_dead_ends()
|
||||
{
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "EspInProgress", "Completed", taken: true),
|
||||
Transition(1, "EspInProgress", "EspInProgress", taken: false,
|
||||
deadEnd: "hybrid_reboot_gate_blocking", trigger: "EspExiting"),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
Assert.Equal(2, graph.Edges.Count);
|
||||
Assert.Contains(graph.Edges, e => e.Taken && e.ToStage == "Completed");
|
||||
var deadEnd = graph.Edges.Single(e => !e.Taken);
|
||||
Assert.Equal("hybrid_reboot_gate_blocking", deadEnd.DeadEndReason);
|
||||
Assert.Equal("EspExiting", deadEnd.Trigger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_orders_edges_by_StepIndex_even_if_input_is_shuffled()
|
||||
{
|
||||
// Repository sorts, but defend anyway — the builder is used directly in tests and
|
||||
// could be fed arbitrary lists.
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(2, "AccountSetup", "Completed"),
|
||||
Transition(0, "SessionStarted", "EspInProgress"),
|
||||
Transition(1, "EspInProgress", "AccountSetup"),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
Assert.Equal(new[] { 0, 1, 2 }, graph.Edges.Select(e => e.StepIndex));
|
||||
}
|
||||
|
||||
// ============================================================ VisitCount
|
||||
|
||||
[Fact]
|
||||
public void Build_counts_only_taken_transitions_toward_VisitCount()
|
||||
{
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "Start", "EspInProgress", taken: true),
|
||||
Transition(1, "EspInProgress", "EspInProgress", taken: false, deadEnd: "blocked"),
|
||||
Transition(2, "EspInProgress", "Completed", taken: true),
|
||||
Transition(3, "Start", "EspInProgress", taken: true), // hypothetical retry
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
var esp = graph.Nodes.Single(n => n.Id == "EspInProgress");
|
||||
Assert.Equal(2, esp.VisitCount); // two taken → EspInProgress; the dead-end does not count
|
||||
|
||||
var completed = graph.Nodes.Single(n => n.Id == "Completed");
|
||||
Assert.Equal(1, completed.VisitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_exposes_ReducerVersion_from_first_row()
|
||||
{
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "Start", "EspInProgress", reducer: "1.4.2"),
|
||||
Transition(1, "EspInProgress", "Completed", reducer: "1.4.2"),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
Assert.Equal("1.4.2", graph.ReducerVersion);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,32 @@ public class DelegatedAdminEditionGateTests
|
||||
Assert.True(scope.Covers(ManagedTenantB));
|
||||
}
|
||||
|
||||
private const string ConferredProHomeTenant = "66666666-6666-6666-6666-666666666666";
|
||||
|
||||
[Fact]
|
||||
public async Task ConferredProHomeTenant_SuppressesEntireScope_NoTransitiveDelegation()
|
||||
{
|
||||
// The home tenant is Pro only because ANOTHER tenant manages it: the gate is the delegation
|
||||
// ENTITLEMENT (absent from conferred Pro), not the edition.
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<DelegatedAdminEntry> { Row(ManagedTenantA) });
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubAdminIdentityBindingService(bound: true),
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == ConferredProHomeTenant
|
||||
? new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false)
|
||||
: new EditionResolution(TenantEdition.Community, EditionSource.Community, OwnPro: false),
|
||||
planOverrideResolver: null),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
var scope = await svc.GetScopeAsync(Id(ConferredProHomeTenant));
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommunityHomeTenant_SuppressesEntireScope()
|
||||
{
|
||||
|
||||
@@ -42,6 +42,7 @@ public class DelegationSelfServiceTests
|
||||
public required FakeSignalRNotificationService SignalR { get; init; }
|
||||
public required List<DelegationInvitation> Rows { get; init; }
|
||||
public required List<string> GroupTenants { get; init; }
|
||||
public required Mock<ProConferralService> ProConferral { get; init; }
|
||||
}
|
||||
|
||||
private static Harness Build(TenantEdition homeEdition = TenantEdition.Pro, int? slotOverride = null)
|
||||
@@ -111,9 +112,66 @@ public class DelegationSelfServiceTests
|
||||
.ReturnsAsync(true);
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
|
||||
// Conferred Pro is the projection's business — here we only pin that the accept/end paths notify it.
|
||||
var proConferral = new Mock<ProConferralService>(
|
||||
repo.Object,
|
||||
new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache),
|
||||
ManagedTenantProIndex.None,
|
||||
NullLogger<ProConferralService>.Instance) { CallBase = false };
|
||||
|
||||
var svc = new DelegationSelfService(repo.Object, invitations.Object, delegatedAdmins, slots, entitlements, configRepo.Object, audit.Object, signalR,
|
||||
NullLogger<DelegationSelfService>.Instance, time);
|
||||
return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants };
|
||||
proConferral.Object, NullLogger<DelegationSelfService>.Instance, time);
|
||||
return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants, ProConferral = proConferral };
|
||||
}
|
||||
|
||||
// ── Conferred Pro hooks ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Accept_NotifiesConferredProProjection_ForTheCustomer()
|
||||
{
|
||||
var h = Build();
|
||||
h.Rows.Add(Pending());
|
||||
|
||||
var r = await h.Svc.AcceptAsync(Token(), Customer, CustomerAdmin);
|
||||
|
||||
Assert.True(r.Ok);
|
||||
h.ProConferral.Verify(p => p.NotifyDelegationChangedAsync(Customer), Times.Once);
|
||||
h.ProConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveManaged_RecordsConferredProLoss_WithTheAuditReason()
|
||||
{
|
||||
var h = Build();
|
||||
h.GroupTenants.Add(Customer);
|
||||
|
||||
var r = await h.Svc.RemoveManagedAsync(Home, Customer, MspAdmin);
|
||||
|
||||
Assert.True(r.Ok);
|
||||
h.ProConferral.Verify(p => p.RecordLossAsync(Customer, Home, "customer-removed-by-manager"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeManager_RecordsConferredProLoss_WithTheAuditReason()
|
||||
{
|
||||
var h = Build();
|
||||
h.GroupTenants.Add(Customer);
|
||||
|
||||
var r = await h.Svc.RevokeManagerAsync(Customer, Home, CustomerAdmin);
|
||||
|
||||
Assert.True(r.Ok);
|
||||
h.ProConferral.Verify(p => p.RecordLossAsync(Customer, Home, "customer-revoked"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveManaged_NotAMember_RecordsNoLoss()
|
||||
{
|
||||
var h = Build();
|
||||
|
||||
var r = await h.Svc.RemoveManagedAsync(Home, Customer, MspAdmin);
|
||||
|
||||
Assert.False(r.Ok);
|
||||
h.ProConferral.Verify(p => p.RecordLossAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
private static DelegationInvitation Pending(string id = "inv1", DateTime? expires = null) => new()
|
||||
|
||||
@@ -27,6 +27,7 @@ public class DelegationWireParityTests
|
||||
{
|
||||
error = "Delegated tenant slot limit reached for partner.example: 2 of 2 slot(s) in use, 1 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.",
|
||||
code = "DelegatedSlotLimitReached",
|
||||
correlationId = "",
|
||||
homeTenantId = Home,
|
||||
homeTenantDomain = "partner.example",
|
||||
used = 2,
|
||||
@@ -47,6 +48,7 @@ public class DelegationWireParityTests
|
||||
{
|
||||
error = $"Delegated tenant slot limit reached for {Home}: 3 of 3 slot(s) in use, 2 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.",
|
||||
code = "DelegatedSlotLimitReached",
|
||||
correlationId = "",
|
||||
homeTenantId = Home,
|
||||
homeTenantDomain,
|
||||
used = 3,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.Functions.Functions.Sessions;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -59,6 +60,9 @@ public class DeleteSessionFunctionTests
|
||||
}
|
||||
|
||||
// ── BuildV2ResponseBody ───────────────────────────────────────────────────────────────
|
||||
// The success arm is SessionDeletionQueuedResponse; every rejected arm is the typed error body
|
||||
// SessionDeletionRejectedResponse (envelope prefix error/code/correlationId + lock diagnostics).
|
||||
// The correlation id is stamped by the writer, so the builder leaves it empty here.
|
||||
|
||||
[Fact]
|
||||
public void BuildV2ResponseBody_Enqueued_carries_manifestId_and_queued_status()
|
||||
@@ -69,15 +73,15 @@ public class DeleteSessionFunctionTests
|
||||
ManifestId = ManifestId,
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Assert.IsType<SessionDeletionQueuedResponse>(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "success", true);
|
||||
AssertProperty(body, "status", "queued");
|
||||
AssertProperty(body, "manifestId", ManifestId);
|
||||
Assert.True(body.Success);
|
||||
Assert.Equal("queued", body.Status);
|
||||
Assert.Equal(ManifestId, body.ManifestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV2ResponseBody_AlreadyInFlight_carries_state_and_manifestId_with_hint()
|
||||
public void BuildV2ResponseBody_AlreadyInFlight_carries_state_and_manifestId_with_code()
|
||||
{
|
||||
var result = new SessionDeletionEnqueueResult
|
||||
{
|
||||
@@ -86,12 +90,11 @@ public class DeleteSessionFunctionTests
|
||||
ExistingState = "Running",
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "success", false);
|
||||
AssertProperty(body, "manifestId", ManifestId);
|
||||
AssertProperty(body, "deletionState", "Running");
|
||||
AssertProperty(body, "hint", "cascade_already_in_flight");
|
||||
Assert.Equal(Constants.ApiErrorCodes.CascadeAlreadyInFlight, body.Code);
|
||||
Assert.Equal(ManifestId, body.ManifestId);
|
||||
Assert.Equal("Running", body.DeletionState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -106,13 +109,12 @@ public class DeleteSessionFunctionTests
|
||||
ExistingState = "Poisoned",
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "hint", "cascade_poisoned_use_restore");
|
||||
AssertProperty(body, "manifestId", ManifestId);
|
||||
Assert.Equal(Constants.ApiErrorCodes.CascadePoisonedUseRestore, body.Code);
|
||||
Assert.Equal(ManifestId, body.ManifestId);
|
||||
// The message string must mention the restore endpoint so the UI surfaces it verbatim.
|
||||
var message = GetProperty<string>(body, "message");
|
||||
Assert.Contains("/restore", message);
|
||||
Assert.Contains("/restore", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -126,10 +128,27 @@ public class DeleteSessionFunctionTests
|
||||
Outcome = SessionDeletionEnqueueOutcome.KillSwitchActive,
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "hint", "kill_switch_active");
|
||||
Assert.False(HasProperty(body, "manifestId"));
|
||||
Assert.Equal(Constants.ApiErrorCodes.KillSwitchActive, body.Code);
|
||||
Assert.Null(body.ManifestId);
|
||||
Assert.DoesNotContain("manifestId", TestWire.Serialize(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV2ResponseBody_rejected_arms_serialize_with_the_envelope_prefix()
|
||||
{
|
||||
var result = new SessionDeletionEnqueueResult
|
||||
{
|
||||
Outcome = SessionDeletionEnqueueOutcome.SessionNotFound,
|
||||
};
|
||||
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
body.CorrelationId = "cid-1";
|
||||
|
||||
Assert.Equal(
|
||||
$"{{\"error\":\"Session {SessionId} not found\",\"code\":\"NotFound\",\"correlationId\":\"cid-1\"}}",
|
||||
TestWire.Serialize(body));
|
||||
}
|
||||
|
||||
// ── EvaluateAdminDeleteGates (kill-switch short-circuit) ──────────────────────────────
|
||||
@@ -141,10 +160,10 @@ public class DeleteSessionFunctionTests
|
||||
|
||||
Assert.NotNull(gate);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, gate!.Value.Status);
|
||||
AssertProperty(gate.Value.Body, "hint", "kill_switch_active");
|
||||
Assert.Equal(Constants.ApiErrorCodes.KillSwitchActive, gate.Value.Body.Code);
|
||||
// The kill-switch body must NOT carry a manifestId so the UI doesn't render a
|
||||
// "track this cascade" link for a request that was refused.
|
||||
Assert.False(HasProperty(gate.Value.Body, "manifestId"));
|
||||
Assert.Null(gate.Value.Body.ManifestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -157,25 +176,6 @@ public class DeleteSessionFunctionTests
|
||||
Assert.Null(gate);
|
||||
}
|
||||
|
||||
// ── Anonymous-object reflection helpers ───────────────────────────────────────────────
|
||||
|
||||
// IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the
|
||||
// wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here.
|
||||
private static void AssertProperty(object body, string propertyName, object? expected)
|
||||
{
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
Assert.NotNull(prop);
|
||||
Assert.Equal(expected, prop!.GetValue(body));
|
||||
}
|
||||
|
||||
private static T GetProperty<T>(object body, string propertyName)
|
||||
{
|
||||
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
Assert.NotNull(prop);
|
||||
var value = prop!.GetValue(body);
|
||||
return (T)value!;
|
||||
}
|
||||
|
||||
private static bool HasProperty(object body, string propertyName) =>
|
||||
body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance) != null;
|
||||
private static SessionDeletionRejectedResponse Rejected(IApiResponse body)
|
||||
=> Assert.IsType<SessionDeletionRejectedResponse>(body);
|
||||
}
|
||||
|
||||
@@ -46,12 +46,7 @@ public class DeletionManifestBuilderTests
|
||||
.WithSignals(15)
|
||||
.WithDecisionTransitions(4)
|
||||
.WithEventTypeIndex(5)
|
||||
.WithCveIndex(2)
|
||||
.WithSessionsByTerminal(1)
|
||||
.WithSessionsByStage(3)
|
||||
.WithDeadEndsByReason(0)
|
||||
.WithClassifierVerdictsByIdLevel(2)
|
||||
.WithSignalsByKind(11));
|
||||
.WithCveIndex(2));
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
TenantId, SessionId, "admin_delete",
|
||||
@@ -69,15 +64,10 @@ public class DeletionManifestBuilderTests
|
||||
Assert.Equal(4, c["decisionTransitions"]);
|
||||
Assert.Equal(5, c["eventTypeIndex"]);
|
||||
Assert.Equal(2, c["cveIndex"]);
|
||||
Assert.Equal(1, c["sessionsByTerminal"]);
|
||||
Assert.Equal(3, c["sessionsByStage"]);
|
||||
Assert.Equal(0, c["deadEndsByReason"]);
|
||||
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
|
||||
Assert.Equal(11, c["signalsByKind"]);
|
||||
|
||||
// 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);
|
||||
// 15 cascade tables (incl. SessionTimeBreakdowns F1 PR2, 3 SessionAnnotations lanes +
|
||||
// SessionTenantLookup P6.1) + tombstone = 16 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(16, manifest.Steps.Count);
|
||||
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
|
||||
}
|
||||
|
||||
@@ -99,11 +89,6 @@ public class DeletionManifestBuilderTests
|
||||
.WithDecisionTransitions(1, propValue: "transitions_marker")
|
||||
.WithEventTypeIndex(1, propValue: "eti_marker")
|
||||
.WithCveIndex(1, propValue: "cve_marker")
|
||||
.WithSessionsByTerminal(1, propValue: "terminal_marker")
|
||||
.WithSessionsByStage(1, propValue: "stage_marker")
|
||||
.WithDeadEndsByReason(1, propValue: "deadend_marker")
|
||||
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker")
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
|
||||
.WithSessionAnnotations(propValue: "annotation_marker")
|
||||
.WithSessionTenantLookup(propValue: "lookup_marker"));
|
||||
@@ -149,9 +134,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 20 cascade-table steps
|
||||
// (16 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
|
||||
Assert.Equal(20, nonTombstoneSteps.Count);
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 15 cascade-table steps
|
||||
// (11 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
|
||||
Assert.Equal(15, nonTombstoneSteps.Count);
|
||||
foreach (var step in nonTombstoneSteps)
|
||||
{
|
||||
Assert.Equal(0, step.RowCount);
|
||||
@@ -180,8 +165,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
// Expect: 20 cascade + 2 inventory + 1 tombstone = 23 steps.
|
||||
Assert.Equal(23, manifest.Steps.Count);
|
||||
// Expect: 15 cascade + 2 inventory + 1 tombstone = 18 steps.
|
||||
Assert.Equal(18, manifest.Steps.Count);
|
||||
|
||||
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
|
||||
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
|
||||
@@ -310,8 +295,7 @@ public class DeletionManifestBuilderTests
|
||||
.WithSessionsRow()
|
||||
.WithSessionsIndexRow()
|
||||
.WithEvents(3)
|
||||
.WithSignals(5)
|
||||
.WithSessionsByTerminal(1));
|
||||
.WithSignals(5));
|
||||
|
||||
var first = await NewBuilder(reader).BuildAsync(TenantId, SessionId, "admin_delete",
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
@@ -345,7 +329,6 @@ public class DeletionManifestBuilderTests
|
||||
.WithSessionsIndexRow()
|
||||
.WithEvents(2, propValue: "events_check")
|
||||
.WithRuleResults(1, propValue: "rules_check")
|
||||
.WithSessionsByTerminal(1, propValue: "terminal_check")
|
||||
.WithSessionInventoryContributions(MakeContributionsRow(new List<DeletionDecrementKey>
|
||||
{
|
||||
new DeletionDecrementKey { Vendor = "Contoso", Name = "App", Version = "1.0" },
|
||||
@@ -778,12 +761,6 @@ public class DeletionManifestBuilderTests
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithSessionsByTerminal(int n, string propValue = "term") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByTerminal, n, propValue, "Failed");
|
||||
public ReaderSeed WithSessionsByStage(int n, string propValue = "stg") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByStage, n, propValue, "AccountSetup");
|
||||
public ReaderSeed WithDeadEndsByReason(int n, string propValue = "de") => SeedDiscriminatorPkProp(Constants.TableNames.DeadEndsByReason, n, propValue, "Timeout");
|
||||
public ReaderSeed WithClassifierVerdictsByIdLevel(int n, string propValue = "cls") => SeedDiscriminatorPkProp(Constants.TableNames.ClassifierVerdictsByIdLevel, n, propValue, "EspApps_High");
|
||||
public ReaderSeed WithSignalsByKind(int n, string propValue = "sk") => SeedDiscriminatorPkProp(Constants.TableNames.SignalsByKind, n, propValue, "EspPhaseChanged");
|
||||
|
||||
public ReaderSeed WithSessionAnnotations(string propValue = "anno")
|
||||
{
|
||||
// One row per lane — the builder emits one exact-RK step per lane.
|
||||
@@ -887,22 +864,5 @@ public class DeletionManifestBuilderTests
|
||||
return this;
|
||||
}
|
||||
|
||||
private ReaderSeed SeedDiscriminatorPkProp(string tableName, int n, string propValue, string discriminator)
|
||||
{
|
||||
// PK = {tenantId}_{discriminator}, server-side filter on PK prefix + SessionId == sessionId.
|
||||
var rows = new List<TableEntity>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var e = new TableEntity($"{TenantId}_{discriminator}", $"{tableName}_{i:D5}")
|
||||
{
|
||||
["Marker"] = propValue,
|
||||
["SessionId"] = SessionId,
|
||||
};
|
||||
e.ETag = new ETag("0x" + tableName + i);
|
||||
rows.Add(e);
|
||||
}
|
||||
_query[tableName] = rows;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,17 +726,6 @@ public class DeviceJourneyAndFtrTests
|
||||
Assert.Equal(3, totals.ExcludedSessions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30)] // default window
|
||||
[InlineData("garbage", 30)]
|
||||
[InlineData("7", 7)]
|
||||
[InlineData("0", 1)] // clamped low
|
||||
[InlineData("999", 180)] // clamped to aggregate retention
|
||||
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InclusiveWindowStart_YieldsExactlyNCalendarDays()
|
||||
{
|
||||
|
||||
-25
@@ -277,31 +277,6 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inspector v1 endpoints (signals, decision-graph, reducer-verification) MUST stay locked to a
|
||||
/// platform-scope tier while the UI matures (Plan §M6 — primary use case is modelling 2-stage
|
||||
/// WhiteGlove deployments). They are now GlobalReadOrAdmin (GA + read-only GlobalReader): still
|
||||
/// invisible to tenant admins/members, but visible to the read-only platform tier. Defense-in-depth
|
||||
/// against an accidental downgrade to a tenant tier (MemberRead/TenantAdminOrGA) that would expose
|
||||
/// decision internals before the lift.
|
||||
///
|
||||
/// When the v2 adminMode lift happens, signals+decision-graph move to MemberRead with
|
||||
/// TenantScoping.QueryParam. reducer-verification stays platform-scope (never tenant-visible).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/sessions/abc-123/signals", "sessions/{sessionId}/signals")]
|
||||
[InlineData("GET", "/api/sessions/abc-123/decision-graph", "sessions/{sessionId}/decision-graph")]
|
||||
[InlineData("GET", "/api/sessions/abc-123/reducer-verification", "sessions/{sessionId}/reducer-verification")]
|
||||
public void InspectorRoutes_AreGlobalReadOrAdmin(string method, string path, string expectedTemplate)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(expectedTemplate, entry!.RouteTemplate);
|
||||
Assert.Equal(EndpointPolicy.GlobalReadOrAdmin, entry.Policy);
|
||||
Assert.Equal(TenantScoping.None, entry.TenantScoping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SignalR group join/leave MUST be AuthenticatedUserWithRole — NOT MemberRead and NOT plain
|
||||
/// AuthenticatedUser. The Progress Portal admits non-member end users, so MemberRead 403s them
|
||||
|
||||
@@ -27,7 +27,10 @@ public class FeatureEntitlementCatalogTests
|
||||
[InlineData(" enterprise ")]
|
||||
public void ResolveEdition_ProOrLegacyEnterpriseTier_IsPro(string tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
var r = FeatureEntitlementCatalog.Resolve(tier, null, null, Now);
|
||||
Assert.Equal(TenantEdition.Pro, r.Edition);
|
||||
Assert.Equal(EditionSource.Plan, r.Source);
|
||||
Assert.True(r.OwnPro);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -38,14 +41,20 @@ public class FeatureEntitlementCatalogTests
|
||||
[InlineData("premium")] // unknown value → fail-closed
|
||||
public void ResolveEdition_NonProTier_IsCommunity(string? tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
var r = FeatureEntitlementCatalog.Resolve(tier, null, null, Now);
|
||||
Assert.Equal(TenantEdition.Community, r.Edition);
|
||||
Assert.Equal(EditionSource.Community, r.Source);
|
||||
Assert.False(r.OwnPro);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ActiveTrial_IsPro_EvenOnFreeTier()
|
||||
{
|
||||
Assert.Equal(TenantEdition.Pro,
|
||||
FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
|
||||
var r = FeatureEntitlementCatalog.Resolve("free", Now.AddSeconds(1), null, Now);
|
||||
Assert.Equal(TenantEdition.Pro, r.Edition);
|
||||
Assert.Equal(EditionSource.Trial, r.Source);
|
||||
Assert.True(r.IsTrial);
|
||||
Assert.True(r.OwnPro);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -53,14 +62,14 @@ public class FeatureEntitlementCatalogTests
|
||||
{
|
||||
// Strict '>' — a trial ending exactly now is already over.
|
||||
Assert.Equal(TenantEdition.Community,
|
||||
FeatureEntitlementCatalog.ResolveEdition("free", Now, Now));
|
||||
FeatureEntitlementCatalog.Resolve("free", Now, null, Now).Edition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ExpiredTrial_IsCommunity()
|
||||
{
|
||||
Assert.Equal(TenantEdition.Community,
|
||||
FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
|
||||
FeatureEntitlementCatalog.Resolve(null, Now.AddDays(-1), null, Now).Edition);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -68,8 +77,102 @@ public class FeatureEntitlementCatalogTests
|
||||
[InlineData("enterprise")]
|
||||
public void ResolveEdition_ExpiredTrial_ButPermanentProTier_StaysPro(string tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Pro,
|
||||
FeatureEntitlementCatalog.ResolveEdition(tier, Now.AddDays(-1), Now));
|
||||
var r = FeatureEntitlementCatalog.Resolve(tier, Now.AddDays(-1), null, Now);
|
||||
Assert.Equal(TenantEdition.Pro, r.Edition);
|
||||
Assert.Equal(EditionSource.Plan, r.Source); // permanent tier beats the stale trial timestamp
|
||||
}
|
||||
|
||||
// ── Conferred Pro ("Pro (MSP)") ─────────────────────────────────────────────
|
||||
|
||||
private const string ManagingTenant = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
[Theory]
|
||||
[InlineData("free")]
|
||||
[InlineData("community")]
|
||||
[InlineData(null)]
|
||||
public void Resolve_ManagedByProTenant_IsPro_SourceMsp_NotOwnPro(string? tier)
|
||||
{
|
||||
var r = FeatureEntitlementCatalog.Resolve(tier, null, ManagingTenant, Now);
|
||||
Assert.Equal(TenantEdition.Pro, r.Edition);
|
||||
Assert.Equal(EditionSource.Msp, r.Source);
|
||||
Assert.True(r.IsViaMsp);
|
||||
Assert.False(r.IsTrial);
|
||||
Assert.False(r.OwnPro);
|
||||
Assert.Equal("msp", r.SourceName);
|
||||
Assert.Equal("pro", r.EditionName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pro", null)]
|
||||
[InlineData("enterprise", null)]
|
||||
[InlineData("community", 1)] // active trial
|
||||
public void Resolve_ManagedTenant_ThatIsProItself_ShowsMsp_KeepsOwnPro(string tier, int? trialDaysLeft)
|
||||
{
|
||||
// Display precedence: MSP wins even over the tenant's own Pro (badge "Pro (MSP)"); the own
|
||||
// standing survives in OwnPro so the entitlement union keeps the delegation right.
|
||||
var r = FeatureEntitlementCatalog.Resolve(tier, trialDaysLeft is int d ? Now.AddDays(d) : null, ManagingTenant, Now);
|
||||
Assert.Equal(TenantEdition.Pro, r.Edition);
|
||||
Assert.Equal(EditionSource.Msp, r.Source);
|
||||
Assert.True(r.OwnPro);
|
||||
Assert.False(r.IsTrial);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Resolve_BlankManagedBy_IsNotManaged(string managedBy)
|
||||
{
|
||||
var r = FeatureEntitlementCatalog.Resolve("community", null, managedBy, Now);
|
||||
Assert.Equal(TenantEdition.Community, r.Edition);
|
||||
Assert.Equal(EditionSource.Community, r.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FromConfig_ReadsTheProjection()
|
||||
{
|
||||
var config = new AutopilotMonitor.Shared.Models.TenantConfiguration
|
||||
{
|
||||
TenantId = "t", PlanTier = "community", ManagedByProTenantId = ManagingTenant,
|
||||
};
|
||||
Assert.Equal(EditionSource.Msp, FeatureEntitlementCatalog.Resolve(config, Now).Source);
|
||||
Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ConferredPro_IsProWithoutTheDelegationRight()
|
||||
{
|
||||
var conferred = FeatureEntitlementCatalog.Get(new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false));
|
||||
var pro = FeatureEntitlementCatalog.Get(TenantEdition.Pro);
|
||||
|
||||
Assert.Equal(TenantEdition.Pro, conferred.Edition);
|
||||
Assert.False(conferred.DelegatedAdminAllowed);
|
||||
Assert.Equal(0, conferred.MaxDelegatedTenants);
|
||||
// Everything else is the Pro value.
|
||||
Assert.Equal(pro.RetentionCapDays, conferred.RetentionCapDays);
|
||||
Assert.Equal(pro.UserRateLimitPerMinute, conferred.UserRateLimitPerMinute);
|
||||
Assert.Equal(pro.DeviceRateLimitPerMinute, conferred.DeviceRateLimitPerMinute);
|
||||
Assert.Equal(pro.BootstrapIncluded, conferred.BootstrapIncluded);
|
||||
Assert.Equal(pro.UnrestrictedModeAvailable, conferred.UnrestrictedModeAvailable);
|
||||
Assert.Equal(pro.McpUsagePlanName, conferred.McpUsagePlanName);
|
||||
Assert.Equal(pro.McpDailyRequestLimit, conferred.McpDailyRequestLimit);
|
||||
Assert.Equal(pro.McpMonthlyRequestLimit, conferred.McpMonthlyRequestLimit);
|
||||
Assert.Equal(pro.McpTenantDailyRequestLimit, conferred.McpTenantDailyRequestLimit);
|
||||
Assert.Equal(pro.McpTenantMonthlyRequestLimit, conferred.McpTenantMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_ManagedTenantThatIsProItself_KeepsTheFullProSet()
|
||||
{
|
||||
var e = FeatureEntitlementCatalog.Get(new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: true));
|
||||
Assert.True(e.DelegatedAdminAllowed);
|
||||
Assert.Equal(2, e.MaxDelegatedTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_CommunityResolution_IsCommunity_RegardlessOfOwnProFlag()
|
||||
{
|
||||
var e = FeatureEntitlementCatalog.Get(new EditionResolution(TenantEdition.Community, EditionSource.Community, OwnPro: true));
|
||||
Assert.Equal(TenantEdition.Community, e.Edition);
|
||||
}
|
||||
|
||||
// ── IsPermanentProTier ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Functions.Sessions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the pure helpers on <see cref="GetSessionSignalsFunction"/>. The HTTP-trigger
|
||||
/// end needs a live runtime harness; this file covers the deterministic query-string
|
||||
/// normalisation that decides how many rows we load.
|
||||
/// </summary>
|
||||
public class GetSessionSignalsFunctionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseMaxResults_defaults_when_query_param_missing()
|
||||
{
|
||||
var result = GetSessionSignalsFunction.ParseMaxResults("");
|
||||
|
||||
Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMaxResults_defaults_when_query_param_is_garbage()
|
||||
{
|
||||
var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=banana");
|
||||
|
||||
Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMaxResults_honours_valid_value_inside_range()
|
||||
{
|
||||
var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=250");
|
||||
|
||||
Assert.Equal(250, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMaxResults_caps_at_MaxResultsCap()
|
||||
{
|
||||
var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=999999");
|
||||
|
||||
Assert.Equal(GetSessionSignalsFunction.MaxResultsCap, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("?maxResults=0")]
|
||||
[InlineData("?maxResults=-5")]
|
||||
public void ParseMaxResults_floors_at_one_for_non_positive_values(string query)
|
||||
{
|
||||
var result = GetSessionSignalsFunction.ParseMaxResults(query);
|
||||
|
||||
Assert.Equal(1, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMaxResults_handles_null_query_string()
|
||||
{
|
||||
var result = GetSessionSignalsFunction.ParseMaxResults(null!);
|
||||
|
||||
Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
"contactEmailSet",
|
||||
"diagnosticsUploadConfigured",
|
||||
"edition",
|
||||
// Non-sensitive by review: one of "community" | "plan" | "trial" | "msp" — says WHY the
|
||||
// edition applies (the "Pro (MSP)" badge), never who the managing tenant is.
|
||||
"editionSource",
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
"enableSoftwareInventoryAnalyzer",
|
||||
"entitlements",
|
||||
@@ -266,11 +269,59 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
|
||||
|
||||
Assert.Equal("pro", element.GetProperty("edition").GetString());
|
||||
Assert.Equal("trial", element.GetProperty("editionSource").GetString());
|
||||
Assert.True(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_EditionSource_Plan_And_Community()
|
||||
{
|
||||
Assert.Equal("plan", Serialize(new TenantConfiguration { PlanTier = "pro" }).GetProperty("editionSource").GetString());
|
||||
Assert.Equal("community", Serialize(new TenantConfiguration()).GetProperty("editionSource").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ManagedByProTenant_IsProViaMsp_WithoutDelegationRight_NoTrialCta()
|
||||
{
|
||||
// The projection (set by the configuration loader) turns a Community tenant into "Pro (MSP)":
|
||||
// Pro entitlements minus delegation, no trial CTA, never "isTrial", no expiry key — and the
|
||||
// managing tenant's id itself is NOT part of this member-readable payload.
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
PlanTier = "community",
|
||||
ManagedByProTenantId = "11111111-1111-1111-1111-111111111111",
|
||||
});
|
||||
|
||||
Assert.Equal("pro", element.GetProperty("edition").GetString());
|
||||
Assert.Equal("msp", element.GetProperty("editionSource").GetString());
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.False(element.TryGetProperty("trialExpiresUtc", out _));
|
||||
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
var entitlements = element.GetProperty("entitlements");
|
||||
Assert.Equal(365, entitlements.GetProperty("retentionCapDays").GetInt32());
|
||||
Assert.False(entitlements.GetProperty("delegatedAdminAllowed").GetBoolean());
|
||||
Assert.Equal(0, entitlements.GetProperty("maxDelegatedTenants").GetInt32());
|
||||
Assert.Equal("pro", entitlements.GetProperty("mcpUsagePlan").GetString());
|
||||
Assert.DoesNotContain("11111111-1111-1111-1111-111111111111", element.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ManagedTenant_ThatIsProItself_ShowsMsp_KeepsDelegationRight()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
PlanTier = "pro",
|
||||
ManagedByProTenantId = "11111111-1111-1111-1111-111111111111",
|
||||
});
|
||||
|
||||
Assert.Equal("msp", element.GetProperty("editionSource").GetString());
|
||||
Assert.True(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean());
|
||||
Assert.Equal(2, element.GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ExpiredTrial_DegradesToCommunity_NoTrialAvailable()
|
||||
{
|
||||
|
||||
@@ -75,7 +75,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 3;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
@@ -94,7 +94,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 15;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 2;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 2;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
@@ -117,7 +117,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
}
|
||||
};
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
@@ -134,7 +134,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 4;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
// warn=5 → 4 messages now classifies as healthy (would be warning under defaults)
|
||||
var svc = BuildService(probe, new Dictionary<string, string?>
|
||||
@@ -153,7 +153,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 1;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ANALYZE-ID-004 v2.0.0 — pins the built-in that consumes the agent's user-affinity signal:
|
||||
/// the real user reached the desktop (<c>desktop_arrived</c>), IME acquired no Entra user token
|
||||
/// within the wait window (<c>entra_user_affinity_pending</c>, at most once per agent process),
|
||||
/// and no <c>ime_user_token_acquired</c> disproves it. Repetition across reboots is the
|
||||
/// confidence lever (base 60, +20 at two, threshold 75), the terminal precondition on
|
||||
/// <c>enrollment_complete</c> stays the suppression at session end, and the v1.x evidence
|
||||
/// (<c>hybrid_login_pending</c>, the JoinInfo placeholder) must no longer fire the rule.
|
||||
/// </summary>
|
||||
public class HybridUserAffinityRuleTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "77777777-8888-9999-aaaa-bbbbbbbbbbbb";
|
||||
|
||||
private static AnalyzeRule Rule() => BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-ID-004");
|
||||
|
||||
[Fact]
|
||||
public void ANALYZE_ID_004_v2_shape_is_pinned()
|
||||
{
|
||||
var rule = Rule();
|
||||
Assert.Equal("2.0.0", rule.Version);
|
||||
Assert.True(rule.Enabled);
|
||||
Assert.Equal("high", rule.Severity);
|
||||
Assert.False(rule.MarkSessionAsFailedDefault);
|
||||
Assert.Equal(75, rule.ConfidenceThreshold);
|
||||
Assert.Contains("on_event:entra_user_affinity_pending", rule.EvaluateOn!);
|
||||
Assert.DoesNotContain("on_event:hybrid_login_pending", rule.EvaluateOn!);
|
||||
|
||||
// The old evidence is gone from every rule surface — trigger, conditions and factors.
|
||||
Assert.DoesNotContain(rule.Conditions, c => c.EventType is "hybrid_login_pending" or "aad_placeholder_user_detected");
|
||||
Assert.DoesNotContain(rule.ConfidenceFactors, f => f.Signal is "hybrid_login_pending" or "aad_placeholder_user_detected");
|
||||
|
||||
var noToken = Assert.Single(rule.Conditions, c => c.Signal == "no_user_token");
|
||||
Assert.Equal("event_type", noToken.Source);
|
||||
Assert.Equal("ime_user_token_acquired", noToken.EventType);
|
||||
Assert.Equal("not_exists", noToken.Operator);
|
||||
Assert.True(noToken.Required);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fires_when_affinity_is_pending_twice_and_no_token_was_acquired()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
DesktopArrived(sequence: 10),
|
||||
AffinityPending(sequence: 20),
|
||||
AffinityPending(sequence: 30), // second agent process after a reboot
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(Rule(), events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-ID-004", result.RuleId);
|
||||
Assert.True(result.ConfidenceScore >= 75, $"confidence {result.ConfidenceScore} below threshold");
|
||||
Assert.Equal(80, result.ConfidenceScore); // base 60 + count >= 2
|
||||
|
||||
// The absence condition carries its own evidence so the matched-condition map is
|
||||
// complete for the UI, not just the two presence signals.
|
||||
Assert.True(result.MatchedConditions.ContainsKey("user_affinity_pending"));
|
||||
Assert.True(result.MatchedConditions.ContainsKey("user_desktop_reached"));
|
||||
Assert.True(result.MatchedConditions.ContainsKey("no_user_token"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Three_pending_warnings_reach_confidence_90()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
DesktopArrived(sequence: 10),
|
||||
AffinityPending(sequence: 20),
|
||||
AffinityPending(sequence: 30),
|
||||
AffinityPending(sequence: 40),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(Rule(), events);
|
||||
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal(90, result.ConfidenceScore); // base 60 + 20 + 10
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_not_fire_on_a_single_pending_warning()
|
||||
{
|
||||
// One agent process without a token is hybrid latency until it repeats: 60 < 75.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
DesktopArrived(sequence: 10),
|
||||
AffinityPending(sequence: 20),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(Rule(), events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_not_fire_when_a_user_token_was_acquired()
|
||||
{
|
||||
// A token success after the desktop disproves the diagnosis even when the warning
|
||||
// repeated (e.g. the first two processes stalled, the third one got the PRT).
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
DesktopArrived(sequence: 10),
|
||||
AffinityPending(sequence: 20),
|
||||
AffinityPending(sequence: 30),
|
||||
Event("ime_user_token_acquired", sequence: 40, new Dictionary<string, object>
|
||||
{
|
||||
["minutesAfterDesktop"] = 3,
|
||||
["tokenFailuresBeforeSuccess"] = 4,
|
||||
["isHybridJoin"] = true,
|
||||
}),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(Rule(), events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Is_skipped_by_the_precondition_when_the_enrollment_completed()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
DesktopArrived(sequence: 10),
|
||||
AffinityPending(sequence: 20),
|
||||
AffinityPending(sequence: 30),
|
||||
Event("enrollment_complete", sequence: 40, new Dictionary<string, object>()),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(Rule(), events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_not_fire_on_the_v1_evidence_alone()
|
||||
{
|
||||
// hybrid_login_pending now means "no real-user desktop yet"; together with the placeholder
|
||||
// it was the v1.x evidence and must not satisfy v2 — even at the old confidence-raising count.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
DesktopArrived(sequence: 10),
|
||||
Event("hybrid_login_pending", sequence: 20, HybridLoginPendingData()),
|
||||
Event("hybrid_login_pending", sequence: 30, HybridLoginPendingData()),
|
||||
Event("hybrid_login_pending", sequence: 40, HybridLoginPendingData()),
|
||||
Event("aad_placeholder_user_detected", sequence: 50, new Dictionary<string, object>()),
|
||||
Event("aad_placeholder_user_detected", sequence: 60, new Dictionary<string, object>()),
|
||||
Event("aad_placeholder_user_detected", sequence: 70, new Dictionary<string, object>()),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(Rule(), events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_not_fire_without_a_real_user_desktop()
|
||||
{
|
||||
// The agent only arms the detector after the desktop, but the rule must not rely on that:
|
||||
// two pending warnings without desktop_arrived are not this diagnosis.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AffinityPending(sequence: 20),
|
||||
AffinityPending(sequence: 30),
|
||||
};
|
||||
|
||||
var outcome = await RunAsync(Rule(), events);
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
// ===== Event builders — mirror the agent emit shapes =====
|
||||
|
||||
private static EnrollmentEvent DesktopArrived(int sequence) =>
|
||||
Event("desktop_arrived", sequence, new Dictionary<string, object>());
|
||||
|
||||
private static EnrollmentEvent AffinityPending(int sequence) =>
|
||||
Event("entra_user_affinity_pending", sequence, new Dictionary<string, object>
|
||||
{
|
||||
["delayMinutes"] = 10,
|
||||
["reason"] = "no_user_token_after_desktop",
|
||||
["minutesSinceDesktop"] = 10,
|
||||
["tokenFailureCount"] = 4,
|
||||
["tokenFailureCodes"] = "0xCAA2000C",
|
||||
["placeholderActive"] = true,
|
||||
["isHybridJoin"] = true,
|
||||
});
|
||||
|
||||
private static Dictionary<string, object> HybridLoginPendingData() => new()
|
||||
{
|
||||
["delayMinutes"] = 10,
|
||||
["reason"] = "no_real_user_desktop",
|
||||
["isHybridJoin"] = true,
|
||||
["realUserDesktopSeen"] = false,
|
||||
["placeholderActive"] = true,
|
||||
};
|
||||
|
||||
private static EnrollmentEvent Event(string eventType, int sequence, Dictionary<string, object> data) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = eventType,
|
||||
Timestamp = DateTime.UtcNow.AddMinutes(sequence),
|
||||
Sequence = sequence,
|
||||
Data = data,
|
||||
};
|
||||
|
||||
private static async Task<AnalysisOutcome> RunAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(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<HybridUserAffinityRuleTests>.Instance);
|
||||
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Queue;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Ime;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contract of the ime-msi-archive queue function (successor of the self-polling worker):
|
||||
/// merge the archiver's outcome onto the ImeVersionHistory row on EVERY attempt (also
|
||||
/// failures, so operators see a stuck download), return for terminal outcomes so the host
|
||||
/// deletes the message, throw for retryable ones so the host's retry → poison ladder applies,
|
||||
/// re-enqueue with a delay — not drop — while ImeMsiArchivingEnabled is off, and drop
|
||||
/// version-less envelopes before the archiver runs.
|
||||
/// </summary>
|
||||
public class ImeMsiArchiveQueueFunctionTests
|
||||
{
|
||||
private const string Version = "1.104.102.0";
|
||||
|
||||
[Fact]
|
||||
public async Task Success_merges_row_and_completes()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Archiver.Result = new ImeMsiArchiveResult(
|
||||
true, ImeMsiArchiver.Statuses.Archived, Retryable: false,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
|
||||
await h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None);
|
||||
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.Archived,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
h.Producer.Verify(p => p.EnqueueAsync(
|
||||
It.IsAny<ImeMsiArchiveEnvelope>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retryable_failure_merges_row_and_throws_for_host_retry()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedDownload, Retryable: true,
|
||||
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None));
|
||||
|
||||
// Row always tells the truth — the failure status IS merged before the throw.
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedDownload,
|
||||
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Permanent_failure_merges_row_and_completes()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedTooLarge, Retryable: false,
|
||||
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
|
||||
await h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None);
|
||||
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedTooLarge,
|
||||
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paused_requeues_with_delay_and_skips_archiver()
|
||||
{
|
||||
var h = new Harness(archivingEnabled: false);
|
||||
|
||||
await h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, h.Archiver.Calls);
|
||||
h.Producer.Verify(p => p.EnqueueAsync(
|
||||
It.Is<ImeMsiArchiveEnvelope>(e => e.Version == Version),
|
||||
ImeMsiArchiveQueueFunction.PauseRequeueDelay,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
|
||||
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Envelope_without_version_is_dropped_before_archiver()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.Sut.ProcessAsync(Message(new ImeMsiArchiveEnvelope
|
||||
{
|
||||
Version = string.Empty,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
}), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, h.Archiver.Calls);
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
|
||||
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
|
||||
Times.Never);
|
||||
h.Producer.Verify(p => p.EnqueueAsync(
|
||||
It.IsAny<ImeMsiArchiveEnvelope>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static ImeMsiArchiveEnvelope Envelope() => new()
|
||||
{
|
||||
Version = Version,
|
||||
MsiDownloadUrl = ImeMsiArchiver.FallbackMsiUrls[1],
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static QueueMessage Message(ImeMsiArchiveEnvelope envelope) => QueuesModelFactory.QueueMessage(
|
||||
messageId: "msg-" + Guid.NewGuid().ToString("N"),
|
||||
popReceipt: "pop-" + Guid.NewGuid().ToString("N"),
|
||||
body: new BinaryData(JsonConvert.SerializeObject(envelope)),
|
||||
dequeueCount: 1);
|
||||
|
||||
/// <summary>Scripted archiver — the function only consumes the classified result.</summary>
|
||||
private sealed class StubArchiver : ImeMsiArchiver
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public ImeMsiArchiveResult Result { get; set; } = new(
|
||||
true, Statuses.Archived, Retryable: false, null, null, null, null);
|
||||
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public override Task<ImeMsiArchiveResult> ArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public StubArchiver Archiver { get; } = new();
|
||||
public Mock<ISessionRepository> Repo { get; } = new();
|
||||
public Mock<IImeMsiArchiveProducer> Producer { get; } = new();
|
||||
public ImeMsiArchiveQueueFunction Sut { get; }
|
||||
|
||||
public Harness(bool archivingEnabled = true)
|
||||
{
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { ImeMsiArchivingEnabled = archivingEnabled });
|
||||
|
||||
Sut = new ImeMsiArchiveQueueFunction(
|
||||
Archiver, Repo.Object, adminConfig.Object, Producer.Object,
|
||||
NullLogger<ImeMsiArchiveQueueFunction>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Queues;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Ime;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Worker-level tests for the ime-msi-archive queue consumer (daily review 2026-08-18:
|
||||
/// previously untested). The worker's contract: merge the archiver's outcome onto the
|
||||
/// ImeVersionHistory row on EVERY attempt (also failures, so operators see a stuck
|
||||
/// download), delete the message for terminal outcomes, rethrow for retryable ones
|
||||
/// (visibility-timeout ladder), pause — not drop — while ImeMsiArchivingEnabled is off,
|
||||
/// and drop version-less envelopes without invoking the archiver.
|
||||
/// </summary>
|
||||
public class ImeMsiArchiveQueueWorkerTests
|
||||
{
|
||||
private const string Version = "1.104.102.0";
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_success_merges_row_and_deletes_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
true, ImeMsiArchiver.Statuses.Archived, Retryable: false,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.Archived,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_retryable_failure_merges_row_but_leaves_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedDownload, Retryable: true,
|
||||
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.Archiver.Calls > 0);
|
||||
|
||||
// Row always tells the truth — the failure status IS merged...
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedDownload,
|
||||
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.AtLeastOnce);
|
||||
// ...but the message stays for the visibility-timeout retry → poison ladder.
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_permanent_failure_merges_row_and_completes_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedTooLarge, Retryable: false,
|
||||
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedTooLarge,
|
||||
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_pauses_while_archiving_disabled()
|
||||
{
|
||||
var harness = new Harness(archivingEnabled: false);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Pause, not drop: no receive, no archive attempt — the message parks in the queue.
|
||||
harness.MainQueue.Verify(q => q.ReceiveMessagesAsync(
|
||||
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
Assert.Equal(0, harness.Archiver.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_drops_envelope_without_version_before_archiver()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(new ImeMsiArchiveEnvelope
|
||||
{
|
||||
Version = string.Empty,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
}));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
Assert.Equal(0, harness.Archiver.Calls);
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
|
||||
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static ImeMsiArchiveEnvelope Envelope() => new()
|
||||
{
|
||||
Version = Version,
|
||||
MsiDownloadUrl = ImeMsiArchiver.FallbackMsiUrls[1],
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>Scripted archiver — the worker only consumes the classified result.</summary>
|
||||
private sealed class StubArchiver : ImeMsiArchiver
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public ImeMsiArchiveResult Result { get; set; } = new(
|
||||
true, Statuses.Archived, Retryable: false, null, null, null, null);
|
||||
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public override Task<ImeMsiArchiveResult> ArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mock-queue poll-loop harness, same shape as SessionDeletionWorkerTests.</summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<QueueClient> MainQueue { get; }
|
||||
public Mock<QueueClient> PoisonQueue { get; }
|
||||
public StubArchiver Archiver { get; } = new();
|
||||
public Mock<ISessionRepository> Repo { get; } = new();
|
||||
public ImeMsiArchiveQueueWorker Sut { get; }
|
||||
|
||||
private readonly Queue<QueueMessage> _pendingMessages = new();
|
||||
|
||||
public Harness(bool archivingEnabled = true)
|
||||
{
|
||||
MainQueue = new Mock<QueueClient>();
|
||||
PoisonQueue = new Mock<QueueClient>();
|
||||
|
||||
MainQueue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
PoisonQueue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
|
||||
MainQueue.Setup(q => q.ReceiveMessagesAsync(
|
||||
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<int, TimeSpan?, CancellationToken>((maxMessages, _, _) =>
|
||||
{
|
||||
var batch = new List<QueueMessage>();
|
||||
lock (_pendingMessages)
|
||||
{
|
||||
while (batch.Count < maxMessages && _pendingMessages.Count > 0)
|
||||
batch.Add(_pendingMessages.Dequeue());
|
||||
}
|
||||
return Task.FromResult(Response.FromValue(batch.ToArray(), new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
MainQueue.Setup(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Mock<Response>().Object);
|
||||
|
||||
MainQueue.Setup(q => q.UpdateMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, string, TimeSpan, CancellationToken>((_, _, _, vis, _) =>
|
||||
{
|
||||
var updated = QueuesModelFactory.UpdateReceipt(
|
||||
"pop-extended-" + Guid.NewGuid().ToString("N"),
|
||||
DateTimeOffset.UtcNow.Add(vis));
|
||||
return Task.FromResult(Response.FromValue(updated, new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { ImeMsiArchivingEnabled = archivingEnabled });
|
||||
|
||||
Sut = new ImeMsiArchiveQueueWorker(
|
||||
MainQueue.Object, PoisonQueue.Object,
|
||||
Archiver, Repo.Object, adminConfig.Object,
|
||||
NullLogger<ImeMsiArchiveQueueWorker>.Instance,
|
||||
pollInterval: TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
public void EnqueueMessage(string body)
|
||||
{
|
||||
var msg = QueuesModelFactory.QueueMessage(
|
||||
messageId: "msg-" + Guid.NewGuid().ToString("N"),
|
||||
popReceipt: "pop-" + Guid.NewGuid().ToString("N"),
|
||||
body: new BinaryData(body),
|
||||
dequeueCount: 1);
|
||||
lock (_pendingMessages) _pendingMessages.Enqueue(msg);
|
||||
}
|
||||
|
||||
public async Task RunForAsync(TimeSpan duration)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(duration);
|
||||
try { await Sut.StartAsync(cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
try { await Task.Delay(duration, cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
try { await Sut.StopAsync(CancellationToken.None); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public async Task RunUntilAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
|
||||
using var cts = new CancellationTokenSource();
|
||||
try { await Sut.StartAsync(cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(25);
|
||||
cts.Cancel();
|
||||
try { await Sut.StopAsync(CancellationToken.None); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public bool MainQueueDeleted() =>
|
||||
MainQueue.Invocations.Any(i => i.Method.Name == nameof(QueueClient.DeleteMessageAsync));
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services.Indexing;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <b>M5 release-gate</b> (Plan §M5-Pflicht line 1395): per-index-table PK/RK consistency
|
||||
/// between primary rows and their derived index rows. For each index table, walk a
|
||||
/// canonical primary row through the Factory + Handler pipeline and verify that the
|
||||
/// captured index record carries enough back-ref information to reconstruct the
|
||||
/// primary's <c>(PartitionKey, RowKey)</c> via <see cref="TableSignalRepository"/> /
|
||||
/// <see cref="TableDecisionTransitionRepository"/> key builders.
|
||||
/// <para>
|
||||
/// Also asserts pipeline idempotency: running the same primary row through twice
|
||||
/// produces identical upsert keys — the consumer's <c>UpsertReplace</c> batches stay
|
||||
/// deduplicating even under timer-triggered resubmits.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class IndexDualWriteConsistencyTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
private static (IndexReconcileHandler handler, FakeIndexTableRepository repo) Sut()
|
||||
{
|
||||
var repo = new FakeIndexTableRepository();
|
||||
var handler = new IndexReconcileHandler(repo, NullLogger<IndexReconcileHandler>.Instance);
|
||||
return (handler, repo);
|
||||
}
|
||||
|
||||
// ============================================================ SignalsByKind ↔ Signals
|
||||
|
||||
[Fact]
|
||||
public async Task SignalsByKind_row_carries_back_ref_to_primary_Signals_row()
|
||||
{
|
||||
var primary = new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = 42,
|
||||
Kind = "EspTerminalFailure",
|
||||
OccurredAtUtc = Occurred,
|
||||
SourceOrigin = "EspAndHelloTrackerAdapter",
|
||||
};
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(primary));
|
||||
|
||||
var index = Assert.Single(repo.SignalsByKind);
|
||||
|
||||
// Back-ref fields on the index row must reconstruct the primary PK/RK.
|
||||
var reconstructedPk = TableSignalRepository.BuildPartitionKey(index.TenantId, index.SessionId);
|
||||
var reconstructedRk = TableSignalRepository.BuildRowKey(index.SessionSignalOrdinal);
|
||||
|
||||
Assert.Equal(
|
||||
TableSignalRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
reconstructedPk);
|
||||
Assert.Equal(
|
||||
TableSignalRepository.BuildRowKey(primary.SessionSignalOrdinal),
|
||||
reconstructedRk);
|
||||
}
|
||||
|
||||
// ============================================================ SessionsByTerminal ↔ DecisionTransitions
|
||||
|
||||
[Fact]
|
||||
public async Task SessionsByTerminal_row_carries_back_ref_to_primary_DecisionTransitions_row()
|
||||
{
|
||||
var primary = TerminalTransition();
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
|
||||
|
||||
var index = Assert.Single(repo.SessionsByTerminal);
|
||||
|
||||
var reconstructedPk = TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId);
|
||||
var reconstructedRk = TableDecisionTransitionRepository.BuildRowKey(index.StepIndex);
|
||||
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
reconstructedPk);
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
|
||||
reconstructedRk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionsByStage_row_carries_back_ref_to_primary_DecisionTransitions_row()
|
||||
{
|
||||
var primary = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 3,
|
||||
FromStage = "AwaitingEsp",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = true,
|
||||
IsTerminal = false,
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
|
||||
|
||||
var index = Assert.Single(repo.SessionsByStage);
|
||||
|
||||
var reconstructedPk = TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId);
|
||||
var reconstructedRk = TableDecisionTransitionRepository.BuildRowKey(index.StepIndex);
|
||||
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
reconstructedPk);
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
|
||||
reconstructedRk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadEndsByReason_row_carries_back_ref_to_primary_DecisionTransitions_row()
|
||||
{
|
||||
var primary = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 5,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = false,
|
||||
IsTerminal = false,
|
||||
DeadEndReason = "hybrid_reboot_gate_blocking",
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
|
||||
|
||||
var index = Assert.Single(repo.DeadEndsByReason);
|
||||
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId));
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
|
||||
TableDecisionTransitionRepository.BuildRowKey(index.StepIndex));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClassifierVerdictsByIdLevel_row_carries_back_ref_to_primary_DecisionTransitions_row()
|
||||
{
|
||||
var primary = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 7,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
|
||||
|
||||
var index = Assert.Single(repo.ClassifierVerdictsByIdLevel);
|
||||
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId));
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
|
||||
TableDecisionTransitionRepository.BuildRowKey(index.StepIndex));
|
||||
}
|
||||
|
||||
// ============================================================ pipeline idempotency
|
||||
|
||||
[Fact]
|
||||
public async Task Resubmitting_same_primary_rows_produces_same_index_keys()
|
||||
{
|
||||
// Simulates the M5.d.4 timer re-enqueueing primary rows it already handled —
|
||||
// the handler's index-row keys must be key-stable so the real TableIndexRepository's
|
||||
// UpsertReplace collapses duplicates rather than appending rows.
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
var signal = new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = 1,
|
||||
Kind = "DesktopArrived",
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
var transition = TerminalTransition();
|
||||
|
||||
// First pass: initial ingest fan-out.
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(signal));
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(transition));
|
||||
|
||||
// Second pass: 2h-timer reconcile resubmits the same primaries.
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(signal));
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(transition));
|
||||
|
||||
// The fake repo captures one row per call, but all duplicated rows must map to the
|
||||
// same (PK, RK) — the real Azure Tables Upsert would collapse them.
|
||||
Assert.Equal(2, repo.SignalsByKind.Count);
|
||||
var signalKeys = repo.SignalsByKind
|
||||
.Select(r => (
|
||||
Pk: IndexRowKeys.BuildSignalsByKindPk(r.TenantId, r.SignalKind),
|
||||
Rk: IndexRowKeys.BuildSignalsByKindRk(r.OccurredAtUtc, r.SessionId, r.SessionSignalOrdinal)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Assert.Single(signalKeys);
|
||||
|
||||
Assert.Equal(2, repo.SessionsByTerminal.Count);
|
||||
var terminalKeys = repo.SessionsByTerminal
|
||||
.Select(r => (
|
||||
Pk: IndexRowKeys.BuildSessionsByTerminalPk(r.TenantId, r.TerminalStage),
|
||||
Rk: IndexRowKeys.BuildSessionsByTerminalRk(r.OccurredAtUtc, r.SessionId)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Assert.Single(terminalKeys);
|
||||
|
||||
Assert.Equal(2, repo.SessionsByStage.Count);
|
||||
var stageKeys = repo.SessionsByStage
|
||||
.Select(r => (
|
||||
Pk: IndexRowKeys.BuildSessionsByStagePk(r.TenantId, r.Stage),
|
||||
Rk: IndexRowKeys.BuildSessionsByStageRk(r.LastUpdatedUtc, r.SessionId)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Assert.Single(stageKeys);
|
||||
|
||||
Assert.Equal(2, repo.ClassifierVerdictsByIdLevel.Count);
|
||||
var verdictKeys = repo.ClassifierVerdictsByIdLevel
|
||||
.Select(r => (
|
||||
Pk: IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(r.TenantId, r.ClassifierId, r.HypothesisLevel),
|
||||
Rk: IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(r.OccurredAtUtc, r.SessionId, r.StepIndex)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Assert.Single(verdictKeys);
|
||||
}
|
||||
|
||||
// ============================================================ helpers
|
||||
|
||||
private static DecisionTransitionRecord TerminalTransition()
|
||||
=> new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 7,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
private sealed class FakeIndexTableRepository : IIndexTableRepository
|
||||
{
|
||||
public List<SessionsByTerminalRecord> SessionsByTerminal { get; } = new();
|
||||
public List<SessionsByStageRecord> SessionsByStage { get; } = new();
|
||||
public List<DeadEndsByReasonRecord> DeadEndsByReason { get; } = new();
|
||||
public List<ClassifierVerdictsByIdLevelRecord> ClassifierVerdictsByIdLevel { get; } = new();
|
||||
public List<SignalsByKindRecord> SignalsByKind { get; } = new();
|
||||
|
||||
public Task<int> StoreSessionsByTerminalAsync(IReadOnlyList<SessionsByTerminalRecord> records, CancellationToken ct = default)
|
||||
{ SessionsByTerminal.AddRange(records); return Task.FromResult(records.Count); }
|
||||
public Task<int> StoreSessionsByStageAsync(IReadOnlyList<SessionsByStageRecord> records, CancellationToken ct = default)
|
||||
{ SessionsByStage.AddRange(records); return Task.FromResult(records.Count); }
|
||||
public Task<int> StoreDeadEndsByReasonAsync(IReadOnlyList<DeadEndsByReasonRecord> records, CancellationToken ct = default)
|
||||
{ DeadEndsByReason.AddRange(records); return Task.FromResult(records.Count); }
|
||||
public Task<int> StoreClassifierVerdictsByIdLevelAsync(IReadOnlyList<ClassifierVerdictsByIdLevelRecord> records, CancellationToken ct = default)
|
||||
{ ClassifierVerdictsByIdLevel.AddRange(records); return Task.FromResult(records.Count); }
|
||||
public Task<int> StoreSignalsByKindAsync(IReadOnlyList<SignalsByKindRecord> records, CancellationToken ct = default)
|
||||
{ SignalsByKind.AddRange(records); return Task.FromResult(records.Count); }
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Services.Indexing;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure tests for <see cref="IndexReconcileEnvelopeFactory"/> (Plan §2.8, §M5.d).
|
||||
/// Verifies the shape-only projection from primary record → queue envelope — the
|
||||
/// consumer (M5.d.3) relies on these fields being copied verbatim.
|
||||
/// </summary>
|
||||
public class IndexReconcileEnvelopeFactoryTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
// ============================================================ FromSignal
|
||||
|
||||
[Fact]
|
||||
public void FromSignal_copies_discriminators_and_back_ref()
|
||||
{
|
||||
var record = new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = 42,
|
||||
SessionTraceOrdinal = 117,
|
||||
Kind = "EspTerminalFailure",
|
||||
KindSchemaVersion = 2,
|
||||
OccurredAtUtc = Occurred,
|
||||
SourceOrigin = "EspAndHelloTrackerAdapter",
|
||||
PayloadJson = "{}",
|
||||
};
|
||||
|
||||
var envelope = IndexReconcileEnvelopeFactory.FromSignal(record);
|
||||
|
||||
Assert.Equal("1", envelope.EnvelopeVersion);
|
||||
Assert.Equal("Signal", envelope.SourceKind);
|
||||
Assert.Equal(TenantId, envelope.TenantId);
|
||||
Assert.Equal(SessionId, envelope.SessionId);
|
||||
Assert.Equal(Occurred, envelope.OccurredAtUtc);
|
||||
Assert.Equal(42L, envelope.SessionSignalOrdinal);
|
||||
Assert.Equal("EspTerminalFailure", envelope.SignalKind);
|
||||
Assert.Equal("EspAndHelloTrackerAdapter", envelope.SourceOrigin);
|
||||
|
||||
// DecisionTransition-only fields stay null on Signal envelopes.
|
||||
Assert.Null(envelope.StepIndex);
|
||||
Assert.Null(envelope.FromStage);
|
||||
Assert.Null(envelope.ToStage);
|
||||
Assert.Null(envelope.Taken);
|
||||
Assert.Null(envelope.IsTerminal);
|
||||
Assert.Null(envelope.DeadEndReason);
|
||||
Assert.Null(envelope.ClassifierVerdictId);
|
||||
Assert.Null(envelope.ClassifierHypothesisLevel);
|
||||
}
|
||||
|
||||
// ============================================================ FromDecisionTransition
|
||||
|
||||
[Fact]
|
||||
public void FromDecisionTransition_taken_terminal_with_classifier_copies_all_discriminators()
|
||||
{
|
||||
var record = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 7,
|
||||
SessionTraceOrdinal = 118,
|
||||
SignalOrdinalRef = 42,
|
||||
OccurredAtUtc = Occurred,
|
||||
Trigger = "EspExiting",
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
DeadEndReason = null,
|
||||
ReducerVersion = "1.0.0",
|
||||
IsTerminal = true,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
PayloadJson = "{}",
|
||||
};
|
||||
|
||||
var envelope = IndexReconcileEnvelopeFactory.FromDecisionTransition(record);
|
||||
|
||||
Assert.Equal("1", envelope.EnvelopeVersion);
|
||||
Assert.Equal("DecisionTransition", envelope.SourceKind);
|
||||
Assert.Equal(TenantId, envelope.TenantId);
|
||||
Assert.Equal(SessionId, envelope.SessionId);
|
||||
Assert.Equal(Occurred, envelope.OccurredAtUtc);
|
||||
Assert.Equal(7, envelope.StepIndex);
|
||||
Assert.Equal("EspInProgress", envelope.FromStage);
|
||||
Assert.Equal("WhiteGloveSealed", envelope.ToStage);
|
||||
Assert.Equal(true, envelope.Taken);
|
||||
Assert.Equal(true, envelope.IsTerminal);
|
||||
Assert.Null(envelope.DeadEndReason);
|
||||
Assert.Equal("whiteglove-sealing", envelope.ClassifierVerdictId);
|
||||
Assert.Equal("Strong", envelope.ClassifierHypothesisLevel);
|
||||
|
||||
// Signal-only fields stay null on DecisionTransition envelopes.
|
||||
Assert.Null(envelope.SessionSignalOrdinal);
|
||||
Assert.Null(envelope.SignalKind);
|
||||
Assert.Null(envelope.SourceOrigin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDecisionTransition_dead_end_carries_reason_without_classifier_fields()
|
||||
{
|
||||
var record = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 3,
|
||||
OccurredAtUtc = Occurred,
|
||||
Trigger = "EspExiting",
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = false,
|
||||
DeadEndReason = "hybrid_reboot_gate_blocking",
|
||||
ReducerVersion = "1.0.0",
|
||||
IsTerminal = false,
|
||||
PayloadJson = "{}",
|
||||
};
|
||||
|
||||
var envelope = IndexReconcileEnvelopeFactory.FromDecisionTransition(record);
|
||||
|
||||
Assert.Equal(false, envelope.Taken);
|
||||
Assert.Equal(false, envelope.IsTerminal);
|
||||
Assert.Equal("hybrid_reboot_gate_blocking", envelope.DeadEndReason);
|
||||
Assert.Null(envelope.ClassifierVerdictId);
|
||||
Assert.Null(envelope.ClassifierHypothesisLevel);
|
||||
}
|
||||
|
||||
// ============================================================ BuildBatch
|
||||
|
||||
[Fact]
|
||||
public void BuildBatch_preserves_input_order_and_kind_tags()
|
||||
{
|
||||
var signals = new List<SignalRecord>
|
||||
{
|
||||
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 1, Kind = "EspPhaseChanged", OccurredAtUtc = Occurred },
|
||||
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 2, Kind = "DesktopArrived", OccurredAtUtc = Occurred },
|
||||
};
|
||||
var transitions = new List<DecisionTransitionRecord>
|
||||
{
|
||||
new() { TenantId = TenantId, SessionId = SessionId, StepIndex = 1, FromStage = "A", ToStage = "B", Taken = true, OccurredAtUtc = Occurred },
|
||||
};
|
||||
|
||||
var envelopes = IndexReconcileEnvelopeFactory.BuildBatch(signals, transitions);
|
||||
|
||||
Assert.Equal(3, envelopes.Count);
|
||||
Assert.Equal("Signal", envelopes[0].SourceKind);
|
||||
Assert.Equal(1L, envelopes[0].SessionSignalOrdinal);
|
||||
Assert.Equal("Signal", envelopes[1].SourceKind);
|
||||
Assert.Equal(2L, envelopes[1].SessionSignalOrdinal);
|
||||
Assert.Equal("DecisionTransition", envelopes[2].SourceKind);
|
||||
Assert.Equal(1, envelopes[2].StepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBatch_returns_empty_for_null_or_empty_inputs()
|
||||
{
|
||||
Assert.Empty(IndexReconcileEnvelopeFactory.BuildBatch(
|
||||
new List<SignalRecord>(), new List<DecisionTransitionRecord>()));
|
||||
Assert.Empty(IndexReconcileEnvelopeFactory.BuildBatch(null!, null!));
|
||||
}
|
||||
}
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JSON round-trip tests for <see cref="IndexReconcileEnvelope"/> (Plan §2.8, §M5.d).
|
||||
/// The producer (M5.d.2) serializes with Newtonsoft; the queue-triggered consumer
|
||||
/// (M5.d.3) must deserialize the same shape back — verified here so the contract
|
||||
/// doesn't drift silently as fields are added.
|
||||
/// </summary>
|
||||
public class IndexReconcileEnvelopeSerializationTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Signal_envelope_round_trips_through_newtonsoft()
|
||||
{
|
||||
var original = new IndexReconcileEnvelope
|
||||
{
|
||||
EnvelopeVersion = "1",
|
||||
SourceKind = "Signal",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
SessionSignalOrdinal = 42,
|
||||
SignalKind = "EspTerminalFailure",
|
||||
SourceOrigin = "EspAndHelloTrackerAdapter",
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(original);
|
||||
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal("1", restored!.EnvelopeVersion);
|
||||
Assert.Equal("Signal", restored.SourceKind);
|
||||
Assert.Equal(TenantId, restored.TenantId);
|
||||
Assert.Equal(SessionId, restored.SessionId);
|
||||
Assert.Equal(Occurred, restored.OccurredAtUtc.ToUniversalTime());
|
||||
Assert.Equal(42L, restored.SessionSignalOrdinal);
|
||||
Assert.Equal("EspTerminalFailure", restored.SignalKind);
|
||||
Assert.Equal("EspAndHelloTrackerAdapter", restored.SourceOrigin);
|
||||
Assert.Null(restored.StepIndex);
|
||||
Assert.Null(restored.ClassifierVerdictId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecisionTransition_envelope_round_trips_all_discriminators()
|
||||
{
|
||||
var original = new IndexReconcileEnvelope
|
||||
{
|
||||
EnvelopeVersion = "1",
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 7,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
DeadEndReason = null,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(original);
|
||||
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal("DecisionTransition", restored!.SourceKind);
|
||||
Assert.Equal(7, restored.StepIndex);
|
||||
Assert.Equal("EspInProgress", restored.FromStage);
|
||||
Assert.Equal("WhiteGloveSealed", restored.ToStage);
|
||||
Assert.Equal(true, restored.Taken);
|
||||
Assert.Equal(true, restored.IsTerminal);
|
||||
Assert.Null(restored.DeadEndReason);
|
||||
Assert.Equal("whiteglove-sealing", restored.ClassifierVerdictId);
|
||||
Assert.Equal("Strong", restored.ClassifierHypothesisLevel);
|
||||
Assert.Null(restored.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialization_tolerates_missing_optional_fields()
|
||||
{
|
||||
// A minimal envelope from a consumer-compatible-but-older writer should still parse.
|
||||
const string minimal =
|
||||
"{\"envelopeVersion\":\"1\",\"sourceKind\":\"Signal\",\"tenantId\":\"t\",\"sessionId\":\"s\",\"occurredAtUtc\":\"2026-04-21T14:07:33Z\"}";
|
||||
|
||||
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(minimal);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal("Signal", restored!.SourceKind);
|
||||
Assert.Null(restored.SessionSignalOrdinal);
|
||||
Assert.Null(restored.SignalKind);
|
||||
Assert.Null(restored.StepIndex);
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Services.Indexing;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Routing tests for <see cref="IndexReconcileHandler"/> (Plan §2.8, §M5.d.3). Verifies
|
||||
/// the 0–3 fan-out decisions per envelope shape using a capture-only fake repo —
|
||||
/// the real <c>TableIndexRepository</c> mapping is covered by <see cref="TableIndexRepositoryMappingTests"/>.
|
||||
/// </summary>
|
||||
public class IndexReconcileHandlerTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
private static (IndexReconcileHandler handler, FakeIndexTableRepository repo) Sut()
|
||||
{
|
||||
var repo = new FakeIndexTableRepository();
|
||||
var handler = new IndexReconcileHandler(repo, NullLogger<IndexReconcileHandler>.Instance);
|
||||
return (handler, repo);
|
||||
}
|
||||
|
||||
// ============================================================ Signal routing
|
||||
|
||||
[Fact]
|
||||
public async Task Signal_envelope_writes_exactly_one_SignalsByKind_row()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "Signal",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
SessionSignalOrdinal = 42,
|
||||
SignalKind = "EspTerminalFailure",
|
||||
SourceOrigin = "EspAndHelloTrackerAdapter",
|
||||
});
|
||||
|
||||
Assert.Single(repo.SignalsByKind);
|
||||
var row = repo.SignalsByKind[0];
|
||||
Assert.Equal(TenantId, row.TenantId);
|
||||
Assert.Equal(SessionId, row.SessionId);
|
||||
Assert.Equal("EspTerminalFailure", row.SignalKind);
|
||||
Assert.Equal(42L, row.SessionSignalOrdinal);
|
||||
Assert.Equal("EspAndHelloTrackerAdapter", row.SourceOrigin);
|
||||
Assert.Equal(Occurred, row.OccurredAtUtc);
|
||||
|
||||
// Other tables untouched.
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
Assert.Empty(repo.SessionsByStage);
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signal_envelope_missing_ordinal_is_dropped()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "Signal",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
SessionSignalOrdinal = null,
|
||||
SignalKind = "EspPhaseChanged",
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signal_envelope_missing_kind_is_dropped()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "Signal",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
SessionSignalOrdinal = 1,
|
||||
SignalKind = string.Empty,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
}
|
||||
|
||||
// ============================================================ DecisionTransition routing — all 4 paths
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_terminal_taken_with_classifier_writes_three_index_rows()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
// WhiteGlove-sealing: Taken=true, IsTerminal=true, classifier verdict attached.
|
||||
// Expected: SessionsByTerminal + SessionsByStage + ClassifierVerdictsByIdLevel (3 rows).
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 7,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
});
|
||||
|
||||
Assert.Single(repo.SessionsByTerminal);
|
||||
Assert.Equal("WhiteGloveSealed", repo.SessionsByTerminal[0].TerminalStage);
|
||||
Assert.Equal(7, repo.SessionsByTerminal[0].StepIndex);
|
||||
|
||||
Assert.Single(repo.SessionsByStage);
|
||||
Assert.Equal("WhiteGloveSealed", repo.SessionsByStage[0].Stage);
|
||||
Assert.Equal(Occurred, repo.SessionsByStage[0].LastUpdatedUtc);
|
||||
|
||||
Assert.Single(repo.ClassifierVerdictsByIdLevel);
|
||||
Assert.Equal("whiteglove-sealing", repo.ClassifierVerdictsByIdLevel[0].ClassifierId);
|
||||
Assert.Equal("Strong", repo.ClassifierVerdictsByIdLevel[0].HypothesisLevel);
|
||||
|
||||
// Dead-end path not triggered for Taken=true.
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
// Signal path never touched on a DecisionTransition envelope.
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_taken_non_terminal_no_classifier_writes_only_SessionsByStage()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 3,
|
||||
FromStage = "AwaitingEsp",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = true,
|
||||
IsTerminal = false,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
Assert.Single(repo.SessionsByStage);
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_dead_end_with_classifier_writes_DeadEndsByReason_and_verdict()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 5,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = false,
|
||||
IsTerminal = false,
|
||||
DeadEndReason = "hybrid_reboot_gate_blocking",
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Weak",
|
||||
});
|
||||
|
||||
Assert.Single(repo.DeadEndsByReason);
|
||||
Assert.Equal("hybrid_reboot_gate_blocking", repo.DeadEndsByReason[0].DeadEndReason);
|
||||
Assert.Equal("EspInProgress", repo.DeadEndsByReason[0].FromStage);
|
||||
Assert.Equal("EspInProgress", repo.DeadEndsByReason[0].AttemptedToStage);
|
||||
|
||||
Assert.Single(repo.ClassifierVerdictsByIdLevel);
|
||||
Assert.Equal("Weak", repo.ClassifierVerdictsByIdLevel[0].HypothesisLevel);
|
||||
|
||||
// Taken=false → SessionsByStage is NOT updated (no stage entry).
|
||||
Assert.Empty(repo.SessionsByStage);
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_dead_end_without_reason_writes_nothing_to_DeadEnds()
|
||||
{
|
||||
// Defense in depth: an envelope with Taken=false but no reason should not create a
|
||||
// DeadEnd row (empty reason would collide into a single PK across unrelated blocks).
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 1,
|
||||
FromStage = "A",
|
||||
ToStage = "A",
|
||||
Taken = false,
|
||||
DeadEndReason = null,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_missing_stepIndex_is_dropped_entirely()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = null,
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
Assert.Empty(repo.SessionsByStage);
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
|
||||
}
|
||||
|
||||
// ============================================================ Misc
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_SourceKind_is_logged_and_dropped()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "FutureThingWeDontKnowAbout",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
Assert.Empty(repo.SessionsByStage);
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_envelope_is_logged_and_dropped()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(null!);
|
||||
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
}
|
||||
|
||||
// ============================================================ FakeIndexTableRepository
|
||||
|
||||
private sealed class FakeIndexTableRepository : IIndexTableRepository
|
||||
{
|
||||
public List<SessionsByTerminalRecord> SessionsByTerminal { get; } = new();
|
||||
public List<SessionsByStageRecord> SessionsByStage { get; } = new();
|
||||
public List<DeadEndsByReasonRecord> DeadEndsByReason { get; } = new();
|
||||
public List<ClassifierVerdictsByIdLevelRecord> ClassifierVerdictsByIdLevel { get; } = new();
|
||||
public List<SignalsByKindRecord> SignalsByKind { get; } = new();
|
||||
|
||||
public Task<int> StoreSessionsByTerminalAsync(
|
||||
IReadOnlyList<SessionsByTerminalRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SessionsByTerminal.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
|
||||
public Task<int> StoreSessionsByStageAsync(
|
||||
IReadOnlyList<SessionsByStageRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SessionsByStage.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
|
||||
public Task<int> StoreDeadEndsByReasonAsync(
|
||||
IReadOnlyList<DeadEndsByReasonRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
DeadEndsByReason.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
|
||||
public Task<int> StoreClassifierVerdictsByIdLevelAsync(
|
||||
IReadOnlyList<ClassifierVerdictsByIdLevelRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ClassifierVerdictsByIdLevel.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
|
||||
public Task<int> StoreSignalsByKindAsync(
|
||||
IReadOnlyList<SignalsByKindRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SignalsByKind.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the 2h safety-net <see cref="IndexReconcileTimer"/> (Plan §2.8, §M5.d.4).
|
||||
/// Covers the flag-gate short-circuit, the happy path (enqueue every primary row
|
||||
/// in the window), and the empty-window case.
|
||||
/// </summary>
|
||||
public class IndexReconcileTimerTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Cutoff = new(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ============================================================ flag-off short-circuit
|
||||
|
||||
[Fact]
|
||||
public async Task Feature_flag_off_skips_queries_and_enqueue()
|
||||
{
|
||||
var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: false);
|
||||
var signalRepo = new Mock<ISignalRepository>(MockBehavior.Strict);
|
||||
var transitionRepo = new Mock<IDecisionTransitionRepository>(MockBehavior.Strict);
|
||||
var producer = new Mock<IIndexReconcileProducer>(MockBehavior.Strict);
|
||||
|
||||
var timer = new IndexReconcileTimer(
|
||||
adminConfigSvc,
|
||||
signalRepo.Object,
|
||||
transitionRepo.Object,
|
||||
producer.Object,
|
||||
NullLogger<IndexReconcileTimer>.Instance);
|
||||
|
||||
await timer.RunReconcileAsync(Cutoff, default);
|
||||
|
||||
// Strict mocks throw if any unconfigured call happens — so zero setups + no throw
|
||||
// proves the timer short-circuited before any query / enqueue.
|
||||
signalRepo.VerifyNoOtherCalls();
|
||||
transitionRepo.VerifyNoOtherCalls();
|
||||
producer.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
// ============================================================ happy path
|
||||
|
||||
[Fact]
|
||||
public async Task Flag_on_with_primary_rows_enqueues_all_envelopes()
|
||||
{
|
||||
var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: true);
|
||||
|
||||
var signals = new List<SignalRecord>
|
||||
{
|
||||
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 1, Kind = "EspPhaseChanged", OccurredAtUtc = Cutoff.AddMinutes(5) },
|
||||
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 2, Kind = "DesktopArrived", OccurredAtUtc = Cutoff.AddMinutes(10) },
|
||||
};
|
||||
var transitions = new List<DecisionTransitionRecord>
|
||||
{
|
||||
new() { TenantId = TenantId, SessionId = SessionId, StepIndex = 1, Taken = true, FromStage = "A", ToStage = "B", OccurredAtUtc = Cutoff.AddMinutes(7) },
|
||||
};
|
||||
|
||||
var signalRepo = new Mock<ISignalRepository>();
|
||||
signalRepo
|
||||
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(signals);
|
||||
var transitionRepo = new Mock<IDecisionTransitionRepository>();
|
||||
transitionRepo
|
||||
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(transitions);
|
||||
|
||||
List<IndexReconcileEnvelope>? captured = null;
|
||||
var producer = new Mock<IIndexReconcileProducer>();
|
||||
producer
|
||||
.Setup(p => p.EnqueueBatchAsync(It.IsAny<IReadOnlyList<IndexReconcileEnvelope>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IReadOnlyList<IndexReconcileEnvelope>, CancellationToken>((e, _) => captured = e.ToList())
|
||||
.ReturnsAsync(3);
|
||||
|
||||
var timer = new IndexReconcileTimer(
|
||||
adminConfigSvc,
|
||||
signalRepo.Object,
|
||||
transitionRepo.Object,
|
||||
producer.Object,
|
||||
NullLogger<IndexReconcileTimer>.Instance);
|
||||
|
||||
await timer.RunReconcileAsync(Cutoff, default);
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(3, captured!.Count);
|
||||
Assert.Equal("Signal", captured[0].SourceKind);
|
||||
Assert.Equal(1L, captured[0].SessionSignalOrdinal);
|
||||
Assert.Equal("Signal", captured[1].SourceKind);
|
||||
Assert.Equal(2L, captured[1].SessionSignalOrdinal);
|
||||
Assert.Equal("DecisionTransition", captured[2].SourceKind);
|
||||
Assert.Equal(1, captured[2].StepIndex);
|
||||
}
|
||||
|
||||
// ============================================================ empty-window: no enqueue
|
||||
|
||||
[Fact]
|
||||
public async Task Flag_on_empty_window_skips_enqueue()
|
||||
{
|
||||
var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: true);
|
||||
|
||||
var signalRepo = new Mock<ISignalRepository>();
|
||||
signalRepo
|
||||
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SignalRecord>());
|
||||
var transitionRepo = new Mock<IDecisionTransitionRepository>();
|
||||
transitionRepo
|
||||
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DecisionTransitionRecord>());
|
||||
|
||||
var producer = new Mock<IIndexReconcileProducer>(MockBehavior.Strict);
|
||||
|
||||
var timer = new IndexReconcileTimer(
|
||||
adminConfigSvc,
|
||||
signalRepo.Object,
|
||||
transitionRepo.Object,
|
||||
producer.Object,
|
||||
NullLogger<IndexReconcileTimer>.Instance);
|
||||
|
||||
await timer.RunReconcileAsync(Cutoff, default);
|
||||
|
||||
// Empty batch → no producer call (strict mock throws otherwise).
|
||||
producer.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
// ============================================================ cutoff window invariant
|
||||
|
||||
[Fact]
|
||||
public void ReconcileWindow_is_4_hours_and_larger_than_timer_cadence()
|
||||
{
|
||||
// The 2h cron fires every 2h but scans a 4h window — so every primary row is
|
||||
// re-checked at least twice before falling out of range. Protect the invariant.
|
||||
Assert.Equal(TimeSpan.FromHours(4), IndexReconcileTimer.ReconcileWindow);
|
||||
}
|
||||
|
||||
// ============================================================ helpers
|
||||
|
||||
private static AdminConfigurationService BuildAdminConfigService(bool enableIndexDualWrite)
|
||||
{
|
||||
var mock = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
mock.Setup(s => s.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { EnableIndexDualWrite = enableIndexDualWrite });
|
||||
|
||||
return mock.Object;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure PK/RK format tests for <see cref="IndexRowKeys"/> (Plan §2.8 query matrix, §M5.d).
|
||||
/// These verify the string shape contract the queue-driven index writer + the reconciliation
|
||||
/// timer both depend on — any drift here silently breaks cross-session queries.
|
||||
/// </summary>
|
||||
public class IndexRowKeysTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
// ============================================================ SessionsByTerminal
|
||||
|
||||
[Fact]
|
||||
public void SessionsByTerminal_pk_is_tenant_underscore_terminalStage()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_WhiteGloveSealed",
|
||||
IndexRowKeys.BuildSessionsByTerminalPk(TenantId, "WhiteGloveSealed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsByTerminal_rk_is_yyyyMMddHHmmss_underscore_sessionId()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"20260421140733_{SessionId}",
|
||||
IndexRowKeys.BuildSessionsByTerminalRk(Occurred, SessionId));
|
||||
}
|
||||
|
||||
// ============================================================ SessionsByStage
|
||||
|
||||
[Fact]
|
||||
public void SessionsByStage_pk_is_tenant_underscore_stage()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_EspInProgress",
|
||||
IndexRowKeys.BuildSessionsByStagePk(TenantId, "EspInProgress"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsByStage_rk_pads_ticks_to_19_digits_for_lex_ordering()
|
||||
{
|
||||
var rk = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId);
|
||||
|
||||
// 19-digit zero-padding is the contract — string comparison then matches numeric time order.
|
||||
var parts = rk.Split('_');
|
||||
Assert.Equal(19, parts[0].Length);
|
||||
Assert.Equal(Occurred.Ticks.ToString("D19"), parts[0]);
|
||||
Assert.Equal(SessionId, parts[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsByStage_rk_orders_lexicographically_matching_chronology()
|
||||
{
|
||||
var earlier = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId);
|
||||
var later = IndexRowKeys.BuildSessionsByStageRk(Occurred.AddSeconds(1), SessionId);
|
||||
|
||||
Assert.True(string.CompareOrdinal(earlier, later) < 0);
|
||||
}
|
||||
|
||||
// ============================================================ DeadEndsByReason
|
||||
|
||||
[Fact]
|
||||
public void DeadEndsByReason_pk_is_tenant_underscore_reason()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_hybrid_reboot_gate_blocking",
|
||||
IndexRowKeys.BuildDeadEndsByReasonPk(TenantId, "hybrid_reboot_gate_blocking"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadEndsByReason_rk_combines_timestamp_session_stepIndex_padded_to_6()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"20260421140733_{SessionId}_000042",
|
||||
IndexRowKeys.BuildDeadEndsByReasonRk(Occurred, SessionId, 42));
|
||||
}
|
||||
|
||||
// ============================================================ ClassifierVerdictsByIdLevel
|
||||
|
||||
[Fact]
|
||||
public void ClassifierVerdictsByIdLevel_pk_is_tenant_underscore_classifierId_underscore_level()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_whiteglove-sealing_Weak",
|
||||
IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(TenantId, "whiteglove-sealing", "Weak"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifierVerdictsByIdLevel_rk_combines_timestamp_session_stepIndex_padded_to_6()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"20260421140733_{SessionId}_000007",
|
||||
IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(Occurred, SessionId, 7));
|
||||
}
|
||||
|
||||
// ============================================================ SignalsByKind
|
||||
|
||||
[Fact]
|
||||
public void SignalsByKind_pk_is_tenant_underscore_signalKind()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_EspTerminalFailure",
|
||||
IndexRowKeys.BuildSignalsByKindPk(TenantId, "EspTerminalFailure"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalsByKind_rk_combines_timestamp_session_ordinal_padded_to_10()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"20260421140733_{SessionId}_0000000123",
|
||||
IndexRowKeys.BuildSignalsByKindRk(Occurred, SessionId, 123));
|
||||
}
|
||||
|
||||
// ============================================================ UTC coercion + sanitizing
|
||||
|
||||
[Fact]
|
||||
public void Timestamp_always_normalizes_to_UTC()
|
||||
{
|
||||
// If a caller passes Local-kind DateTime, conversion must still produce the UTC wall clock.
|
||||
var localKind = DateTime.SpecifyKind(Occurred, DateTimeKind.Local);
|
||||
var rk = IndexRowKeys.BuildSessionsByTerminalRk(localKind, SessionId);
|
||||
|
||||
Assert.Equal($"{localKind.ToUniversalTime():yyyyMMddHHmmss}_{SessionId}", rk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo/bar", "foo_bar")]
|
||||
[InlineData("foo\\bar", "foo_bar")]
|
||||
[InlineData("foo#bar", "foo_bar")]
|
||||
[InlineData("foo?bar", "foo_bar")]
|
||||
[InlineData("clean_name", "clean_name")]
|
||||
[InlineData("", "")]
|
||||
public void Sanitize_replaces_forbidden_key_chars_with_underscore(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, IndexRowKeys.Sanitize(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_preserves_enum_style_identifiers_unchanged()
|
||||
{
|
||||
// Discriminators are overwhelmingly enum names — sanitize must not mangle them.
|
||||
Assert.Equal("WhiteGloveSealed", IndexRowKeys.Sanitize("WhiteGloveSealed"));
|
||||
Assert.Equal("EspTerminalFailure", IndexRowKeys.Sanitize("EspTerminalFailure"));
|
||||
Assert.Equal("hybrid_reboot_gate_blocking", IndexRowKeys.Sanitize("hybrid_reboot_gate_blocking"));
|
||||
}
|
||||
}
|
||||
@@ -641,6 +641,10 @@ public class InfrastructureWireParityTests
|
||||
}
|
||||
|
||||
// ---- McpQuotaEnforcementMiddleware: 429 quota body -----------------------------------
|
||||
// DELIBERATE wire change (error-envelope pass): the body now leads with the envelope prefix
|
||||
// error/code/correlationId (error carries the former `message`); quotaExceeded stays as the
|
||||
// MCP's discriminator. These literals pin the NEW shape (correlationId is stamped by the writer,
|
||||
// so the builder leaves it empty).
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_matches_the_daily_blocked_shape()
|
||||
@@ -654,14 +658,16 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "community",
|
||||
scope = "daily",
|
||||
level = "user",
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-08-31T00:00:00Z."
|
||||
resetUtc = "2026-08-31T00:00:00Z"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
|
||||
}
|
||||
@@ -679,14 +685,16 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "power",
|
||||
scope = "monthly",
|
||||
level = "tenant",
|
||||
limit = 60000,
|
||||
used = 60000L,
|
||||
resetUtc = "2026-09-01T00:00:00Z",
|
||||
message = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z."
|
||||
resetUtc = "2026-09-01T00:00:00Z"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
|
||||
}
|
||||
@@ -701,17 +709,20 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "community",
|
||||
scope,
|
||||
level = "user",
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
resetUtc = "2026-08-31T00:00:00Z"
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
Error = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z.",
|
||||
QuotaExceeded = true,
|
||||
Plan = "community",
|
||||
Scope = null,
|
||||
@@ -719,7 +730,6 @@ public class InfrastructureWireParityTests
|
||||
Limit = 200,
|
||||
Used = 200,
|
||||
ResetUtc = "2026-08-31T00:00:00Z",
|
||||
Message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -737,6 +747,9 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "pro",
|
||||
scope = "daily",
|
||||
@@ -744,7 +757,6 @@ public class InfrastructureWireParityTests
|
||||
limit = 300,
|
||||
used = 300L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.",
|
||||
targetTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, "customer.example"));
|
||||
@@ -763,14 +775,16 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP request quota exceeded for all 2 managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "pro",
|
||||
scope = "daily",
|
||||
level = "tenant",
|
||||
limit = 300,
|
||||
used = 300L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP request quota exceeded for all 2 managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at 2026-08-31T00:00:00Z."
|
||||
resetUtc = "2026-08-31T00:00:00Z"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, exhaustedTenantCount: 2));
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using System.Net;
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent's uploader halves its batch only on 413 and treats every 5xx as "replay later";
|
||||
/// <see cref="IngestTelemetryFunction.ClassifyStorageFailure"/> is what keeps an oversize
|
||||
/// transaction from surfacing as a 500 the device would retry forever.
|
||||
/// </summary>
|
||||
public class IngestStorageFailureClassificationTests
|
||||
{
|
||||
private static RequestFailedException Failure(int status, string? code = null)
|
||||
=> new RequestFailedException(status, "storage", code, null);
|
||||
|
||||
[Fact]
|
||||
public void Oversize_transaction_becomes_413_without_retry_after()
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(413, "RequestBodyTooLarge"));
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, status);
|
||||
Assert.Null(retryAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oversize_reported_as_400_with_size_code_still_becomes_413()
|
||||
{
|
||||
var (status, _, _) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(400, "EntityTooLarge"));
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(503)]
|
||||
[InlineData(429)]
|
||||
[InlineData(500)]
|
||||
public void Transient_storage_failure_becomes_503_with_retry_after(int storageStatus)
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(storageStatus));
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, status);
|
||||
Assert.Equal(IngestTelemetryFunction.StorageRetryAfterSeconds, retryAfter);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(404)]
|
||||
[InlineData(409)]
|
||||
[InlineData(400)]
|
||||
public void Anything_else_stays_500(int storageStatus)
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(storageStatus, "InvalidInput"));
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, status);
|
||||
Assert.Null(retryAfter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the reverse index behind conferred Pro (<see cref="ManagedTenantProIndex"/>): only OWNED groups
|
||||
/// of a PERMANENT-Pro owner confer; a trial owner and operator-created groups never do; the owner's tier is
|
||||
/// read raw (two tenants managing each other resolve without recursion); storage failure yields "nobody is
|
||||
/// managed" (fail-closed) without throwing; Invalidate forces a rebuild.
|
||||
/// </summary>
|
||||
public sealed class ManagedTenantProIndexTests
|
||||
{
|
||||
private const string Msp = "11111111-1111-1111-1111-111111111111";
|
||||
private const string TrialMsp = "22222222-2222-2222-2222-222222222222";
|
||||
private const string CustomerA = "33333333-3333-3333-3333-333333333333";
|
||||
private const string CustomerB = "44444444-4444-4444-4444-444444444444";
|
||||
private const string OperatorManaged = "55555555-5555-5555-5555-555555555555";
|
||||
|
||||
private static TenantGroup Owned(string owner, params string[] tenants) => new()
|
||||
{
|
||||
GroupId = AutopilotMonitor.Shared.Constants.TenantGroupIds.ForHomeTenant(owner),
|
||||
Name = "Customers",
|
||||
OwnerTenantId = owner,
|
||||
TenantIds = tenants.ToList(),
|
||||
};
|
||||
|
||||
private static TenantGroup Operator(params string[] tenants) => new()
|
||||
{
|
||||
GroupId = "operator-group",
|
||||
Name = "Operator bundle",
|
||||
OwnerTenantId = null,
|
||||
TenantIds = tenants.ToList(),
|
||||
};
|
||||
|
||||
private static (ManagedTenantProIndex Index, Mock<IAdminRepository> Admin, Mock<IConfigRepository> Config) Build(
|
||||
IEnumerable<TenantGroup> groups, IDictionary<string, TenantConfiguration> configs)
|
||||
{
|
||||
var admin = new Mock<IAdminRepository>();
|
||||
admin.Setup(a => a.GetAllTenantGroupsAsync()).ReturnsAsync(groups.ToList());
|
||||
var config = new Mock<IConfigRepository>();
|
||||
config.Setup(c => c.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string id) => configs.TryGetValue(id, out var cfg) ? cfg : null);
|
||||
var index = new ManagedTenantProIndex(admin.Object, config.Object, new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<ManagedTenantProIndex>.Instance);
|
||||
return (index, admin, config);
|
||||
}
|
||||
|
||||
private static TenantConfiguration Tenant(string id, string tier, DateTime? trial = null) =>
|
||||
new() { TenantId = id, PlanTier = tier, TrialExpiresUtc = trial };
|
||||
|
||||
[Fact]
|
||||
public async Task OwnedGroup_OfPermanentProOwner_ConfersOnMembers()
|
||||
{
|
||||
var (index, _, _) = Build(
|
||||
new[] { Owned(Msp, CustomerA, CustomerB) },
|
||||
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "pro") });
|
||||
|
||||
Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerA));
|
||||
Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerB));
|
||||
Assert.Null(await index.GetConferringOwnerAsync(Msp)); // the owner never manages itself
|
||||
Assert.Null(await index.GetConferringOwnerAsync(OperatorManaged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyEnterpriseTier_CountsAsPermanentPro()
|
||||
{
|
||||
var (index, _, _) = Build(
|
||||
new[] { Owned(Msp, CustomerA) },
|
||||
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "enterprise") });
|
||||
|
||||
Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrialOwner_ConfersNothing()
|
||||
{
|
||||
var (index, _, _) = Build(
|
||||
new[] { Owned(TrialMsp, CustomerA) },
|
||||
new Dictionary<string, TenantConfiguration>
|
||||
{
|
||||
[TrialMsp] = Tenant(TrialMsp, "community", trial: DateTime.UtcNow.AddDays(10)),
|
||||
});
|
||||
|
||||
Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OwnerWithoutConfigRow_ConfersNothing()
|
||||
{
|
||||
var (index, _, _) = Build(new[] { Owned(Msp, CustomerA) }, new Dictionary<string, TenantConfiguration>());
|
||||
|
||||
Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OperatorGroup_NeverConfers_EvenWhenAssigneesAreProHomed()
|
||||
{
|
||||
var (index, _, _) = Build(
|
||||
new[] { Operator(OperatorManaged) },
|
||||
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "pro") });
|
||||
|
||||
Assert.Null(await index.GetConferringOwnerAsync(OperatorManaged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MutualManagement_ResolvesBothWays_WithoutRecursion()
|
||||
{
|
||||
// A manages B and B manages A, both permanent Pro: each owner's tier is one RAW read — the
|
||||
// index never asks "is the owner itself managed", so there is no cycle to fall into.
|
||||
var (index, _, config) = Build(
|
||||
new[] { Owned(Msp, CustomerA), Owned(CustomerA, Msp) },
|
||||
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "pro"), [CustomerA] = Tenant(CustomerA, "pro") });
|
||||
|
||||
Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerA));
|
||||
Assert.Equal(CustomerA, await index.GetConferringOwnerAsync(Msp));
|
||||
config.Verify(c => c.GetTenantConfigurationAsync(Msp), Times.Once);
|
||||
config.Verify(c => c.GetTenantConfigurationAsync(CustomerA), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoProOwners_ResolveDeterministically_ByGroupId()
|
||||
{
|
||||
// GroupIds are "msp-{owner}", so ordinal order of the owner ids decides.
|
||||
var first = "0aaaaaaa-0000-0000-0000-000000000000";
|
||||
var second = "0bbbbbbb-0000-0000-0000-000000000000";
|
||||
var (index, _, _) = Build(
|
||||
new[] { Owned(second, CustomerA), Owned(first, CustomerA) },
|
||||
new Dictionary<string, TenantConfiguration> { [first] = Tenant(first, "pro"), [second] = Tenant(second, "pro") });
|
||||
|
||||
Assert.Equal(first, await index.GetConferringOwnerAsync(CustomerA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsCachedAcrossLookups_AndInvalidateRebuilds()
|
||||
{
|
||||
var (index, admin, _) = Build(
|
||||
new[] { Owned(Msp, CustomerA) },
|
||||
new Dictionary<string, TenantConfiguration> { [Msp] = Tenant(Msp, "pro") });
|
||||
|
||||
await index.GetConferringOwnerAsync(CustomerA);
|
||||
await index.GetConferringOwnerAsync(CustomerB);
|
||||
admin.Verify(a => a.GetAllTenantGroupsAsync(), Times.Once);
|
||||
|
||||
index.Invalidate();
|
||||
await index.GetConferringOwnerAsync(CustomerA);
|
||||
admin.Verify(a => a.GetAllTenantGroupsAsync(), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StorageFailure_YieldsNobodyManaged_NeverThrows()
|
||||
{
|
||||
var admin = new Mock<IAdminRepository>();
|
||||
admin.Setup(a => a.GetAllTenantGroupsAsync()).ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var index = new ManagedTenantProIndex(admin.Object, Mock.Of<IConfigRepository>(),
|
||||
new MemoryCache(new MemoryCacheOptions()), NullLogger<ManagedTenantProIndex>.Instance);
|
||||
|
||||
Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BlankTenantId_IsNull_WithoutTouchingStorage()
|
||||
{
|
||||
var (index, admin, _) = Build(Array.Empty<TenantGroup>(), new Dictionary<string, TenantConfiguration>());
|
||||
|
||||
Assert.Null(await index.GetConferringOwnerAsync(null));
|
||||
Assert.Null(await index.GetConferringOwnerAsync(" "));
|
||||
admin.Verify(a => a.GetAllTenantGroupsAsync(), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task None_ProjectsNobody()
|
||||
{
|
||||
Assert.Null(await ManagedTenantProIndex.None.GetConferringOwnerAsync(CustomerA));
|
||||
ManagedTenantProIndex.None.Invalidate(); // no-op, must not throw
|
||||
}
|
||||
}
|
||||
+23
-23
@@ -198,17 +198,17 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
|
||||
Assert.Equal("tenant", body.Level);
|
||||
Assert.Equal(TenantB, body.TargetTenantId);
|
||||
Assert.Contains("managed tenant 'customer.example'", body.Message);
|
||||
Assert.Contains("tenant plan 'community'", body.Message);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Message);
|
||||
Assert.DoesNotContain("your organization", body.Message);
|
||||
Assert.Contains("managed tenant 'customer.example'", body.Error);
|
||||
Assert.Contains("tenant plan 'community'", body.Error);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Error);
|
||||
Assert.DoesNotContain("your organization", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManagedTarget_WithoutLabel_FallsBackToTheId()
|
||||
{
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB));
|
||||
Assert.Contains($"managed tenant '{TenantB}'", body.Message);
|
||||
Assert.Contains($"managed tenant '{TenantB}'", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -216,8 +216,8 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
{
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", TenantB, 3000, 3000), "customer.example");
|
||||
Assert.Equal(TenantB, body.TargetTenantId);
|
||||
Assert.DoesNotContain("Upgrading", body.Message);
|
||||
Assert.Contains("shared by all its members and delegated admins", body.Message);
|
||||
Assert.DoesNotContain("Upgrading", body.Error);
|
||||
Assert.Contains("shared by all its members and delegated admins", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -225,8 +225,8 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
{
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null));
|
||||
Assert.Null(body.TargetTenantId);
|
||||
Assert.Contains("of your organization", body.Message);
|
||||
Assert.DoesNotContain("managed tenant", body.Message);
|
||||
Assert.Contains("of your organization", body.Error);
|
||||
Assert.DoesNotContain("managed tenant", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -235,13 +235,13 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
// The quota is the upgrade lever: a member blocked by their own Community organization window is told
|
||||
// that Community is sized for occasional use and that Pro lifts it — Pro members never see the hint.
|
||||
var community = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null));
|
||||
Assert.Contains("sized for occasional use", community.Message);
|
||||
Assert.Contains("upgrading your organization to Pro", community.Message);
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", community.Message);
|
||||
Assert.Contains("sized for occasional use", community.Error);
|
||||
Assert.Contains("upgrading your organization to Pro", community.Error);
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", community.Error);
|
||||
|
||||
var pro = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", target: null, 3000, 3000));
|
||||
Assert.DoesNotContain("upgrading", pro.Message);
|
||||
Assert.DoesNotContain("occasional use", pro.Message);
|
||||
Assert.DoesNotContain("upgrading", pro.Error);
|
||||
Assert.DoesNotContain("occasional use", pro.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -249,9 +249,9 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
{
|
||||
// The delegated admin must learn that the CUSTOMER's plan (not their own Pro/MSP plan) governs the window.
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), "customer.example");
|
||||
Assert.Contains("on the Community plan", body.Message);
|
||||
Assert.Contains("its own plan governs this window, not yours", body.Message);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Message);
|
||||
Assert.Contains("on the Community plan", body.Error);
|
||||
Assert.Contains("its own plan governs this window, not yours", body.Error);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -266,9 +266,9 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
dailyUsed: 100, monthlyUsed: 500, tenantDailyUsed: 1, tenantMonthlyUsed: 1, Now);
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(decision);
|
||||
Assert.Equal("user", body.Level);
|
||||
Assert.StartsWith($"MCP daily request quota exceeded for plan '{userPlan}'.", body.Message);
|
||||
Assert.Equal(expectHint, body.Message.Contains("Pro raises your daily and monthly windows"));
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Message);
|
||||
Assert.StartsWith($"MCP daily request quota exceeded for plan '{userPlan}'.", body.Error);
|
||||
Assert.Equal(expectHint, body.Error.Contains("Pro raises your daily and monthly windows"));
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -277,8 +277,8 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), exhaustedTenantCount: 3);
|
||||
Assert.Null(body.TargetTenantId);
|
||||
Assert.Equal("tenant", body.Level);
|
||||
Assert.Contains("all 3 managed tenants", body.Message);
|
||||
Assert.Contains("Earliest reset at 2026-09-03T00:00:00Z", body.Message);
|
||||
Assert.Contains("all 3 managed tenants", body.Error);
|
||||
Assert.Contains("Earliest reset at 2026-09-03T00:00:00Z", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -293,6 +293,6 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
Assert.Null(body.TargetTenantId);
|
||||
Assert.Equal(
|
||||
"MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-09-03T00:00:00Z.",
|
||||
body.Message);
|
||||
body.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
@@ -11,31 +12,34 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// </summary>
|
||||
public class MetricsFilterInjectionTests
|
||||
{
|
||||
// ---- TableStorageService.BuildRuleStatsFilter (metrics/rule-stats, MemberRead) ----
|
||||
// ---- TableStorageService.BuildRuleStatsFilter (metrics/rule-stats, MemberRead; layout D-199) ----
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsFilter_NormalInput_ProducesScopedFilter()
|
||||
public void BuildRuleStatsFilter_NormalInput_IsOnePartitionRangePerScope()
|
||||
{
|
||||
var filter = TableStorageService.BuildRuleStatsFilter("tenant1", "2024-01-01", "2024-02-01", "analyze");
|
||||
|
||||
Assert.Equal(
|
||||
"PartitionKey ge '2024-01-01' and PartitionKey le '2024-02-01' and " +
|
||||
"RowKey ge 'tenant1_' and RowKey lt 'tenant1_~' and RuleType eq 'analyze'",
|
||||
"PartitionKey ge 'tenant1_2024-01-01' and PartitionKey le 'tenant1_2024-02-01' and RuleType eq 'analyze'",
|
||||
filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsFilter_OpenWindow_FallsBackToTheScopePrefixBounds()
|
||||
{
|
||||
Assert.Equal(
|
||||
"PartitionKey ge 'tenant1_' and PartitionKey lt 'tenant1_~'",
|
||||
TableStorageService.BuildRuleStatsFilter("tenant1", null, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsFilter_RuleTypeInjection_StaysInsideLiteral()
|
||||
{
|
||||
// The reported HIGH-severity payload.
|
||||
var filter = TableStorageService.BuildRuleStatsFilter("tenant1", null, null, "x' or RowKey ge '");
|
||||
|
||||
// The tenant scope must survive...
|
||||
Assert.Contains("RowKey ge 'tenant1_'", filter);
|
||||
Assert.Contains("RowKey lt 'tenant1_~'", filter);
|
||||
// ...and the injected quote must be doubled, so the OR can't escape the string literal.
|
||||
Assert.Equal(
|
||||
"RowKey ge 'tenant1_' and RowKey lt 'tenant1_~' and RuleType eq 'x'' or RowKey ge '''",
|
||||
"PartitionKey ge 'tenant1_' and PartitionKey lt 'tenant1_~' and RuleType eq 'x'' or RowKey ge '''",
|
||||
filter);
|
||||
}
|
||||
|
||||
@@ -45,14 +49,42 @@ public class MetricsFilterInjectionTests
|
||||
{
|
||||
var filter = TableStorageService.BuildRuleStatsFilter("tenant1", payload, null, null);
|
||||
|
||||
Assert.Contains("PartitionKey ge ''' or RowKey ge '''", filter);
|
||||
Assert.Contains("RowKey ge 'tenant1_'", filter);
|
||||
Assert.Equal("PartitionKey ge 'tenant1_'' or RowKey ge ''' and PartitionKey lt 'tenant1_~'", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsFilter_AllNull_ReturnsNull()
|
||||
public void BuildRuleStatsFilter_ScopeInjection_CannotWidenToAnotherTenant()
|
||||
{
|
||||
Assert.Null(TableStorageService.BuildRuleStatsFilter(null, null, null, null));
|
||||
var filter = TableStorageService.BuildRuleStatsFilter("t' or PartitionKey ge '", "2024-01-01", null, null);
|
||||
|
||||
Assert.Equal(
|
||||
"PartitionKey ge 't'' or PartitionKey ge ''_2024-01-01' and PartitionKey lt 't'' or PartitionKey ge ''_~'",
|
||||
filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLegacyRuleStatsFilter_KeepsTheOldShapeWhileLegacyRowsCanExist()
|
||||
{
|
||||
var filter = TableStorageService.BuildLegacyRuleStatsFilter("tenant1", "2024-01-01", "2024-02-01", "analyze", new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(
|
||||
"PartitionKey ge '2024-01-01' and PartitionKey le '2024-02-01' and " +
|
||||
"RowKey ge 'tenant1_' and RowKey lt 'tenant1_~' and RuleType eq 'analyze'",
|
||||
filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLegacyRuleStatsFilter_IsGoneAfterTheRetentionHorizon()
|
||||
{
|
||||
Assert.Null(TableStorageService.BuildLegacyRuleStatsFilter("tenant1", null, null, null, new DateTime(2026, 12, 5, 0, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsCleanupFilter_IsAScopedRangeBelowTheCutoff()
|
||||
{
|
||||
Assert.Equal(
|
||||
"PartitionKey ge 'tenant1_' and PartitionKey lt 'tenant1_2026-06-07'",
|
||||
TableStorageService.BuildRuleStatsCleanupFilter("tenant1", "2026-06-07"));
|
||||
}
|
||||
|
||||
// ---- TableUserUsageRepository.BuildUserUsageFilter (metrics/mcp-usage/user/{userId}) ----
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The notification-dispatch consumer resolves channel ids against the tenant's CURRENT
|
||||
/// configuration — no URL or secret ever rides in the queue — and sends only to channels that
|
||||
/// are still enabled. Plus the wire contract: the envelope round-trips through Newtonsoft with
|
||||
/// the alert's facts, sections and severity intact.
|
||||
/// </summary>
|
||||
public class NotificationDispatchHandlerTests
|
||||
{
|
||||
private const string Tenant = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
[Fact]
|
||||
public async Task Sends_to_enabled_channels_matching_the_envelope_ids_only()
|
||||
{
|
||||
var h = new Harness(new[]
|
||||
{
|
||||
Channel("teams", enabled: true),
|
||||
Channel("slack", enabled: true),
|
||||
Channel("old", enabled: false),
|
||||
});
|
||||
|
||||
await h.Sut.HandleAsync(Envelope("teams", "old"), CancellationToken.None);
|
||||
|
||||
var sent = Assert.Single(h.Sent);
|
||||
Assert.Equal(new[] { "teams" }, sent.Channels.Select(c => c.Id));
|
||||
Assert.Equal("enrollment_succeeded", sent.Alert.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_channel_ids_are_skipped_without_sending()
|
||||
{
|
||||
var h = new Harness(new[] { Channel("teams", enabled: true) });
|
||||
|
||||
await h.Sut.HandleAsync(Envelope("deleted-meanwhile"), CancellationToken.None);
|
||||
|
||||
Assert.Empty(h.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_tenant_configuration_drops_the_alert()
|
||||
{
|
||||
var h = new Harness(channels: null);
|
||||
|
||||
await h.Sut.HandleAsync(Envelope("teams"), CancellationToken.None);
|
||||
|
||||
Assert.Empty(h.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_round_trips_through_newtonsoft()
|
||||
{
|
||||
var envelope = Envelope("teams");
|
||||
envelope.Alert.Facts.Add(new NotificationFact { Name = "Device", Value = "PC-0001" });
|
||||
envelope.Alert.Severity = NotificationSeverity.Error;
|
||||
|
||||
var restored = JsonConvert.DeserializeObject<NotificationDispatchEnvelope>(
|
||||
JsonConvert.SerializeObject(envelope))!;
|
||||
|
||||
Assert.Equal(Tenant, restored.TenantId);
|
||||
Assert.Equal(new[] { "teams" }, restored.ChannelIds);
|
||||
Assert.Equal("enrollment_succeeded", restored.Alert.EventType);
|
||||
Assert.Equal(NotificationSeverity.Error, restored.Alert.Severity);
|
||||
var fact = Assert.Single(restored.Alert.Facts);
|
||||
Assert.Equal("PC-0001", fact.Value);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static NotificationChannel Channel(string id, bool enabled) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = id,
|
||||
ProviderType = (int)WebhookProviderType.Slack,
|
||||
Url = $"https://example.invalid/{id}",
|
||||
Enabled = enabled,
|
||||
NotifyOnSuccess = true,
|
||||
};
|
||||
|
||||
private static NotificationDispatchEnvelope Envelope(params string[] channelIds) => new()
|
||||
{
|
||||
TenantId = Tenant,
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
ChannelIds = channelIds.ToList(),
|
||||
Alert = new NotificationAlert
|
||||
{
|
||||
EventType = "enrollment_succeeded",
|
||||
Title = "Enrollment Succeeded",
|
||||
Summary = "PC-0001 enrolled",
|
||||
Severity = NotificationSeverity.Info,
|
||||
ThemeColor = "00AA00",
|
||||
},
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public List<(IReadOnlyList<NotificationChannel> Channels, NotificationAlert Alert)> Sent { get; } = new();
|
||||
public NotificationDispatchHandler Sut { get; }
|
||||
|
||||
public Harness(IEnumerable<NotificationChannel>? channels)
|
||||
{
|
||||
var configService = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
|
||||
if (channels is null)
|
||||
{
|
||||
configService.Setup(c => c.TryGetConfigurationAsync(Tenant))
|
||||
.ReturnsAsync((new TenantConfiguration { TenantId = Tenant }, false));
|
||||
}
|
||||
else
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = Tenant,
|
||||
NotificationChannelsJson = NotificationChannel.SerializeList(channels),
|
||||
};
|
||||
configService.Setup(c => c.TryGetConfigurationAsync(Tenant))
|
||||
.ReturnsAsync((config, true));
|
||||
}
|
||||
|
||||
var dispatcher = new Mock<NotificationChannelDispatcher>(null!, null!);
|
||||
dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>()))
|
||||
.Callback<IEnumerable<NotificationChannel>, NotificationAlert>((c, a) => Sent.Add((c.ToList(), a)))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
Sut = new NotificationDispatchHandler(
|
||||
configService.Object, dispatcher.Object, NullLogger<NotificationDispatchHandler>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -44,6 +44,7 @@ public sealed class TenantCustomsArchiveContractTests
|
||||
customsArchive: archive,
|
||||
configRepo: null!,
|
||||
farewellEmail: new FakeOffboardFarewellEmailSender(),
|
||||
proConferral: null!,
|
||||
logger: NullLogger<TenantOffboardingHandler>.Instance);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -670,7 +670,8 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
IOffboardFarewellEmailSender farewellEmail,
|
||||
ILogger<TenantOffboardingHandler> logger)
|
||||
: base(auditRepo, enumerator, cascadeEnqueuer, expectations, drainProbe, safeWipe,
|
||||
storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail, logger)
|
||||
storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail,
|
||||
TestProConferral.Inert(), logger)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -486,7 +486,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
|
||||
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
|
||||
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
|
||||
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
|
||||
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, TestProConferral.Inert(), log) { }
|
||||
|
||||
internal override Task ArchiveAndWipeRulesTableAsync(
|
||||
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
|
||||
|
||||
+1
-1
@@ -258,7 +258,7 @@ public class TenantOffboardingWorkerPoisonTests
|
||||
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
|
||||
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
|
||||
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
|
||||
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
|
||||
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, TestProConferral.Inert(), log) { }
|
||||
|
||||
internal override Task ArchiveAndWipeRulesTableAsync(
|
||||
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user