mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d86ae18a5e | |||
| 1c5f69a9fe | |||
| 17affb6dfd | |||
| acf4d0eeaa | |||
| 4c1889d011 | |||
| da4de79a9d | |||
| 6f3f35e1cd | |||
| ca22be5cd0 | |||
| c421b81751 | |||
| 7636a00e24 | |||
| 90294455e0 | |||
| 4def73f193 | |||
| 6bab2d3db9 | |||
| 78b8dd0f1b | |||
| 14acfd5c59 | |||
| 22d4c6c677 | |||
| 9fce499f4c | |||
| 8c7a6410d2 | |||
| c158768343 | |||
| 75197a4184 | |||
| 179a441e64 | |||
| acc95471a6 | |||
| 29ced81d6e | |||
| 4966d23a46 | |||
| 59ba8301a8 | |||
| b142fa0994 | |||
| afa8750cbb | |||
| 3c1229a301 | |||
| b4e409783e | |||
| eb94c612e7 | |||
| ed5fcf3dc3 | |||
| 9ba67162aa | |||
| efc96258d6 | |||
| 9d8b0fc254 | |||
| f3028041af | |||
| 3628c7bc79 | |||
| 395fdd49df | |||
| 5edb64f3c9 | |||
| 46965af290 | |||
| 1bfdb68ff6 | |||
| 5ee5114d5c | |||
| a7bb7ebf15 | |||
| a7a1cc43c1 | |||
| 2a5ad65dd7 | |||
| 960b3d8ed9 | |||
| 86bb255b11 | |||
| d3a10dfb96 | |||
| 80d0b30cba | |||
| aee723e497 | |||
| 427c923530 | |||
| b46a8f1571 | |||
| 6e294c010e | |||
| dc54c9555c | |||
| 784fd99240 | |||
| 607d6615b0 | |||
| 8d4f7ea0f9 | |||
| 0e2d620ad4 | |||
| 69d61c6c26 | |||
| cfb28d83b6 | |||
| f795fbcb4e | |||
| d9d22bc443 | |||
| 6bd5728652 | |||
| d6affae8fc | |||
| a70c5de667 | |||
| e62bac9fea | |||
| 1e94a7aa91 | |||
| c330e5c91a | |||
| 6015548172 | |||
| 3f661d097d |
+1
-1
Submodule .claude/commands updated: 53953bd30b...0766ebfef6
@@ -82,7 +82,7 @@ jobs:
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 141
|
||||
run: npm run lint -- --max-warnings 99
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v4.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v4.37.3
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -39,6 +39,10 @@ jobs:
|
||||
uses: Azure/static-web-apps-deploy@v1
|
||||
env:
|
||||
NEXT_PUBLIC_ENTRA_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_CLIENT_ID }}
|
||||
# Dual app-reg window: previous app registration (unset = single-app behaviour)
|
||||
# and the default app for browsers without a stored selection (legacy|primary).
|
||||
NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID }}
|
||||
NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }}
|
||||
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Pester (Bootstrap scripts)
|
||||
|
||||
# Runs the Pester suite for the Intune bootstrap scripts under Windows PowerShell 5.1
|
||||
# (the runtime IME uses to execute platform scripts) so guard/relax logic changes are
|
||||
# verified without resetting a device. Like ci.yml it reports rather than blocks.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pester-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pester:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$c = New-PesterConfiguration
|
||||
$c.Run.Path = 'scripts/Bootstrap'
|
||||
$c.Run.Exit = $true
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars."
|
||||
$failed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
@@ -1,14 +1,14 @@
|
||||
# <img src="docs/images/logo.svg" height="28" alt="" /> AutopilotMonitor
|
||||
|
||||
[](https://www.autopilotmonitor.com)
|
||||
[](https://www.autopilotmonitor.com)
|
||||
[](LICENSE)
|
||||
[](https://www.autopilotmonitor.com)
|
||||
|
||||
Advanced monitoring and troubleshooting solution for Windows Autopilot deployments. Gain full visibility into every enrollment session with a detailed event timeline, fleet health dashboards, and session reporting. Define custom analysis rules to automatically detect issues and gather rules to collect targeted evidence. Retrieve diagnostics packages on demand, configure agent settings like auto-reboot behavior and automatic timezone adjustment — all managed centrally from the web dashboard.
|
||||
|
||||
## Private Preview
|
||||
## Availability
|
||||
|
||||
Autopilot Monitor is currently running as a **Private Preview**. Visit **[autopilotmonitor.com](https://www.autopilotmonitor.com)** to request access and learn more.
|
||||
Autopilot Monitor is **publicly available** — the Community plan is free. Sign in with your work account at **[autopilotmonitor.com](https://www.autopilotmonitor.com)**; new organizations complete a short activation step after first sign-in.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/SessionList.png" width="45%" />
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
type: Concept
|
||||
title: W365 Cloud PC Device Validation — Cert-CN-Bound Fallback Validator
|
||||
description: Why Windows 365 Cloud PCs can never pass the Autopilot serial lookup, and how the CloudPc validator admits exactly the tenant's service-provisioned Cloud PCs by binding the MDM certificate's Subject CN (= Intune device id) to the Graph virtualEndpoint/cloudPCs inventory.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs
|
||||
tags:
|
||||
- backend
|
||||
- security
|
||||
- device-validation
|
||||
- windows365
|
||||
- cloudpc
|
||||
- graph
|
||||
timestamp: 2026-08-06T00:00:00+02:00
|
||||
---
|
||||
|
||||
# W365 Cloud PC Device Validation
|
||||
|
||||
Windows 365 Cloud PCs are provisioned headless by the Windows 365 service and are
|
||||
**structurally never Autopilot-registered** — the serial lookup against
|
||||
`windowsAutopilotDeviceIdentities` (and the Corporate Identifier fallback, whose
|
||||
serials nobody can pre-import for VMs minted at provisioning time) always misses.
|
||||
Before this validator, a Cloud PC agent died in the 403 "Device not registered"
|
||||
even though its mTLS cert was perfectly valid, so Cloud PC first-connect
|
||||
enrollment (Account Setup, RealmJoin, Hello) could not be monitored.
|
||||
|
||||
# Schema — the validator chain
|
||||
|
||||
`SecurityValidator.ValidateRequestAsync` stage 4 runs the same chain for every
|
||||
device; there is **no device-type routing** and the local W365 markers
|
||||
(Windows365 registry key, CloudManagedDesktopExtension service) are never
|
||||
consulted for auth — they gain an attacker nothing:
|
||||
|
||||
1. **Autopilot serial lookup** (`ValidateAutopilotDevice`) — unchanged primary.
|
||||
2. **Corporate Identifier** (`ValidateCorporateIdentifier`) — unchanged fallback.
|
||||
3. **CloudPc** (`ValidateCloudPcDevice`, default off) — only reached when 1+2
|
||||
missed. Extracts the Intune device id from the **chain-validated** client
|
||||
certificate's Subject CN (`TryGetIntuneDeviceIdFromCertSubject`: CN present
|
||||
AND canonical GUID, else definitive reject) and requires a `cloudPC` object
|
||||
with `managedDeviceId eq <CN>` in the claimed tenant
|
||||
(`GET /v1.0/deviceManagement/virtualEndpoint/cloudPCs?$filter=…`).
|
||||
`ValidatedBy = ValidatorType.CloudPc (5)`.
|
||||
|
||||
Only machines actually provisioned by the Windows 365 service have a `cloudPC`
|
||||
object — a regular Intune-enrolled non-Autopilot device still ends in the 403,
|
||||
so the "Autopilot devices only" contract of stages 1–2 is not widened. The
|
||||
tenant hard gate accepts `ValidateCloudPcDevice` as a third satisfying flag.
|
||||
|
||||
## Why the cert CN, not headers or local markers
|
||||
|
||||
Field-verified 2026-08-06 on a real W365 Enterprise Cloud PC (gktatooine.net):
|
||||
|
||||
- MDM client cert Subject `CN=07623d56-…` == Intune `managedDevice.id` ==
|
||||
`cloudPC.managedDeviceId`. The CN cannot be forged without the Intune MDM
|
||||
Device CA (chain pinned to embedded roots, `CustomRootTrust`).
|
||||
- `$filter=managedDeviceId eq '…'` is supported server-side on **v1.0** — no
|
||||
paging fallback needed.
|
||||
- `managedDevice.serialNumber` matches local `Win32_BIOS.SerialNumber` exactly;
|
||||
the "Cloud PC Enterprise …" model string exists **only** in Graph, never in
|
||||
local WMI (local WMI reports Manufacturer='Microsoft Corporation',
|
||||
Model='Virtual Machine').
|
||||
|
||||
Threat model: a valid Intune cert from a *different* tenant fails (its CN does
|
||||
not exist in the claimed tenant's Cloud PC inventory — this is a *tenant
|
||||
binding* the serial-based validators do not have); spoofed serial headers and
|
||||
faked local W365 markers are irrelevant (never consulted); a rogue enrolled VM
|
||||
with doctored SMBIOS cannot mint a `cloudPC` object (service-side state, not
|
||||
device-reported inventory — the reason the model-string gate was rejected).
|
||||
Retired/deprovisioned Cloud PCs disappear from the inventory and their
|
||||
validation dies with them (compensates the absent cert revocation check).
|
||||
Residual: a compromised *legitimate* Cloud PC of the tenant — same trust level
|
||||
as any tenant device today, rate-limited by cert thumbprint.
|
||||
|
||||
## Graph permission plumbing
|
||||
|
||||
- Optional add-on permission `CloudPC.Read.All` — NOT part of the default
|
||||
consent set. Feature `W365CloudPcValidation` in `GraphFeatureCatalog` →
|
||||
surfaces automatically in the "Optional Graph capabilities" UI (the feature
|
||||
table and grant command are backend-driven) and in
|
||||
`Grant-AutopilotMonitorAddOn.ps1` (`-Features W365CloudPcValidation`).
|
||||
`GraphFeatureCatalogSyncTests` pins C# catalog ↔ grant script (previously
|
||||
comment-only lock-step).
|
||||
- A Graph **403** (permission not granted) is a definitive negative (cached
|
||||
5 min), NOT transient — a 503 Retry-After loop cannot fix a missing grant.
|
||||
The error message names the add-on feature.
|
||||
- Same resilience contract as `AutopilotDeviceValidator`: 30/5 min
|
||||
positive/negative cache, 2 attempts, transient → agent-facing 503.
|
||||
|
||||
## Bootstrap script interplay (v2.3-dev.3)
|
||||
|
||||
The Cloud-PC relax in `Install-AutopilotMonitor-dev.ps1` additionally exempts
|
||||
**guard 4** (12 h uptime window): a Cloud PC runs headless for days before the
|
||||
user's first connect, and the monitorable phase (Account Setup) only starts at
|
||||
that first connect — the single-fresh-profile window (< 15 min) is the time
|
||||
anchor there, not boot uptime. The OOBE-restore trigger keeps the uptime guard.
|
||||
|
||||
## Agent-side IsCloudPc flag + decision-engine expectation
|
||||
|
||||
Same change set, agent side. `CloudPcDetector` (V2.Core) re-uses the bootstrap's
|
||||
field-verified marker AND — `HKLM\SOFTWARE\Microsoft\Windows365` key **plus**
|
||||
installed `CloudManagedDesktopExtension` service (probed via its
|
||||
`HKLM\SYSTEM\CurrentControlSet\Services` key; the agent deliberately has no
|
||||
`System.ServiceProcess` dependency). SKIP-safe: any error resolves `false`.
|
||||
|
||||
Three independent transport rails, never an auth input:
|
||||
|
||||
- **Session metadata** — `SessionRegistration.IsCloudPc` →
|
||||
Sessions table (sticky-true OR across re-registrations, like
|
||||
`IsSelfDeployingProfile`) → SessionsIndex full mirror → `SessionSummary` →
|
||||
search filter `isCloudPc` (portal, `/api/search/sessions`, `/api/raw/sessions`,
|
||||
MCP `search_sessions`/`query_raw_sessions`). Agent-reported and unverified —
|
||||
can legitimately disagree with the server-derived `ValidatedBy = CloudPc`
|
||||
(e.g. Cloud PC admitted via a stage that ran earlier).
|
||||
- **Distress channel** — `DistressReport.IsCloudPc` (captured once at
|
||||
`DistressReporter` construction, `BackendClientFactory`) rides every pre-auth
|
||||
distress report → `DistressReportEntry.IsCloudPc` → the "Devices Not
|
||||
Registered" report aggregates it **sticky-true** per serial
|
||||
(`GetDeviceNotRegisteredFunction`) and the portal insight renders a Cloud PC
|
||||
badge plus a hint pointing at the Cloud PC validation toggle + the
|
||||
`W365CloudPcValidation` add-on. Same UNVERIFIED contract as all distress
|
||||
fields; rows from older agents read false. Fits the existing 1536-byte
|
||||
payload cap without a bump.
|
||||
- **Decision engine** — the marker rides the `EnrollmentFactsObserved` payload
|
||||
(`isCloudPc`), is recorded set-once for BOTH values in
|
||||
`EnrollmentScenarioObservations.CloudPc`, and annotates the scenario profile
|
||||
reason `cloud_pc_first_connect:no_device_esp_expected`. That is the
|
||||
engine-recorded expectation "no Device ESP phase will appear": Device-ESP ran
|
||||
headless at provisioning, the session starts at Account Setup. Deliberately
|
||||
reason+observation only — no completion arm depends on DeviceSetup, `Mode`
|
||||
is still classified by AccountSetup/IME signals (Classic), and `EspConfig`
|
||||
keeps the *configured* FirstSync semantics. The positive marker surfaces as
|
||||
`cloud_pc_marker` in the terminal audit-trail signal census.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — stage 4 chain, `TryGetIntuneDeviceIdFromCertSubject`
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs` — `W365CloudPcValidation`
|
||||
- `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` — customer-side grant
|
||||
- `scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1` — Cloud-PC relax + guard-4 exemption
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs`, `SecurityValidatorTests.cs`, `GraphFeatureCatalogSyncTests.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs` — agent-side marker AND
|
||||
- `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs` — `CloudPc` fact; reason annotation in `EnrollmentScenarioProfileUpdater`
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
type: concept
|
||||
title: Tenant Activation & the Once-Only Welcome Mail
|
||||
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
|
||||
resource: backend
|
||||
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
|
||||
timestamp: 2026-08-02
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
## Activation
|
||||
|
||||
A tenant is **activated** when the row `PartitionKey={tenantId}, RowKey="approved"` exists
|
||||
in the `PreviewWhitelist` table (legacy name kept on purpose). Everything else in the
|
||||
portal gates on that row via `PreviewWhitelistService.IsApprovedAsync` (5 min positive /
|
||||
30 s negative cache).
|
||||
|
||||
Two activation routes, one shared implementation
|
||||
(`TenantApprovalService.ApproveWithSideEffectsAsync`):
|
||||
|
||||
* **Manual**: Global Admin approves in the admin panel (`POST /api/preview/whitelist/{tenantId}`).
|
||||
* **Auto**: signup unconditionally enqueues a `tenant-auto-approve` envelope; the queue
|
||||
worker (`TenantAutoApproveHandler`) re-checks the `AutoApproveNewTenants` flag
|
||||
(uncached, fail-closed), the suspension gate (fresh config read), then approves.
|
||||
Typical latency: ~1 minute after signup.
|
||||
|
||||
The whitelist add is a **conditional INSERT** (`AddEntityAsync`, 409 = lost) — storage
|
||||
arbitrates concurrent activations; exactly one caller runs the side effects
|
||||
(TenantAdmin auto-promote of the signup UPN, welcome mail, `TenantAutoApproved` ops event).
|
||||
|
||||
## The welcome-mail race
|
||||
|
||||
The welcome mail goes to the **notification email** (`RowKey="notification-email"` in the
|
||||
same table) — the only reliable address: it is typed by the user on the activation-pending
|
||||
page (`PUT /api/preview/notification-email`). The signup admin UPN may have no mailbox and
|
||||
`ContactEmail` is merely seeded from the notification email.
|
||||
|
||||
That makes activation and address entry concurrent writers. With auto-approve, activation
|
||||
usually wins (observed in production: approve at T+64 s, address saved at T+69 s → the
|
||||
approval path found no address and the mail was silently skipped).
|
||||
|
||||
**Arbitration — write-then-read on both sides plus a send-once marker:**
|
||||
|
||||
* The approval path writes the `approved` row, then reads the address.
|
||||
* The save path (`SavePreviewNotificationEmail`) writes the address row, then checks
|
||||
approval **fresh** (`IsApprovedFreshAsync` — the 30 s negative cache would hide the
|
||||
activation for exactly this window).
|
||||
* Since both do write-then-read, at least one sees both halves and calls
|
||||
`TenantApprovalService.TrySendWelcomeEmailAsync`.
|
||||
* There, a conditional insert of `RowKey="welcome-email-sent"` decides who actually
|
||||
sends: at most one wins. The marker is consumed strictly **after** the address check —
|
||||
consuming it without an address would permanently suppress the mail.
|
||||
|
||||
Marker lifecycle: revoke (`RevokeAsync`) deletes it (re-approve = fresh activation =
|
||||
fresh mail); the offboarding cascade wipes the whole `PreviewWhitelist` partition anyway.
|
||||
The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantId}`)
|
||||
always sends (explicit intent) and consumes the marker best-effort so the automatic
|
||||
paths never duplicate afterwards.
|
||||
|
||||
# Examples
|
||||
|
||||
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
|
||||
notification-email save path"; user saves address at T+69 s → save path sees approved
|
||||
(fresh read), wins the marker, sends. Response carries `welcomeEmailSent: true`.
|
||||
* Address saved before manual approve (classic shape): approval path finds the address,
|
||||
wins the marker, sends; a later re-save of the same address does not re-send.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TenantApprovalService.cs` — shared activation + `TrySendWelcomeEmailAsync`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/Activation/TenantAutoApproveHandler.cs` — queue-side gates
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -29,6 +29,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
|
||||
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
|
||||
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
|
||||
|
||||
# Backend
|
||||
|
||||
@@ -37,11 +38,14 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Device Journeys & First-Time-Right](backend/device-journeys.md) - How a device's terminal enrollment attempts are grouped into journeys (trim+casefold device key with junk-serial exclusion, first-success end, 30-day gap, WhiteGlove = one attempt), the DeviceHistories chain with its tombstone-driven deleted-session pruning, the additive daily FTR aggregates, and the banner/fleet/MCP surfaces.
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
|
||||
# MCP Server
|
||||
|
||||
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
|
||||
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
|
||||
|
||||
# Web Portal
|
||||
|
||||
|
||||
+29
@@ -1,5 +1,34 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-07 (later)
|
||||
|
||||
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
|
||||
|
||||
## 2026-08-06
|
||||
|
||||
* **Creation**: Added `backend/cloudpc-device-validation.md` — the CloudPc fallback validator (`ValidatorType.CloudPc = 5`, tenant flag `ValidateCloudPcDevice`): W365 Cloud PCs are structurally never Autopilot-registered, so stage 4 gains a third stage that binds the chain-validated MDM cert's Subject CN (= Intune device id, field-verified on a real Cloud PC) to the tenant's `virtualEndpoint/cloudPCs` inventory — admits exactly the service-provisioned Cloud PCs, no device-type routing, local W365 markers never consulted for auth. New optional Graph add-on `W365CloudPcValidation` → `CloudPC.Read.All` (catalog + grant script now pinned by `GraphFeatureCatalogSyncTests`; Graph 403 = definitive "grant missing", not transient). Bootstrap dev script v2.3-dev.3 exempts guard 4 (uptime) while the Cloud-PC relax is active — the fresh-profile window is the time anchor at first connect, not boot uptime. Follow-up deliberately out of scope: agent-side `IsCloudPc` classification + decision-engine "no Device ESP" expectation.
|
||||
|
||||
## 2026-08-05 (later)
|
||||
|
||||
* **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls.
|
||||
|
||||
## 2026-08-05
|
||||
|
||||
* **Creation**: Added `mcp/tool-telemetry.md` — the three MCP tool-telemetry layers (App Insights `McpToolName`, per-user usage table, `MCP_TOOL_LOGGING` stderr lines) and the new quality signals: soft-error capture (`toolError` returns instead of throwing, so backend telemetry counted those calls as successes), `resultChars`/`overCap` vs. the inline-size hint, bounded args summaries, caller scope, `search_zero_hit` on the three search tools (unmet-demand signal), and `tool_call_rejected` for JSON-RPC-level Zod/unknown-tool rejections sniffed at the `/mcp` route (previously invisible everywhere). Bicep default for `toolLoggingEnabled` flipped to `true` (documented desired state; live flag set via `az containerapp update`).
|
||||
* **Creation**: Added `rules/rule-authoring-surface.md` — the AI-assisted rule-authoring stack: three deterministic MCP resources (`rule_authoring_guide` hand-written, `rule_schemas` + `rule_guardrails` generated into `rule-authoring.generated.ts` by a new combine.js emitter, drift-guarded twice), the MCP-local `validate_rule` (ajv 2020-12 against the single-rule $defs, guardrail pre-flight mirroring GatherRuleGuards matching semantics incl. hard blocks, semantic lint for schema-valid-but-dead rules), and the side-effect-free `POST /api/rules/analyze/dryrun` (TenantAdminOrGlobalReader; `RuleEngine.DryRun.cs` trace twin sharing the production evaluators, all-conditions trace with per-condition evidence + matchingEventCount, parity and side-effect-freedom pinned by RuleEngineDryRunTests). MCP `test_analyze_rule` wraps the endpoint with `{{token}}`-interpolated previews; `author-rule` prompt drives the draft → validate → dry-run loop. Deliberately absent: gather-rule remote execution, rule writes via MCP.
|
||||
|
||||
## 2026-08-04
|
||||
|
||||
* **Update**: `rules/gather-rule-phase-scoping.md` — new section "Which phase feeds the triggers": `GatherRuleExecutorHost` now drives `phase_change`/`phase_exit`/`on_event` gather triggers (and the phase-scope gates) from the post-reduce `TimelineEventStream` (every emitted event's `(eventType, phase)` published by `EventTimelineEmitter`) instead of the pre-reduce `SignalIngress.SignalPosted` payloads. Trigger: session `32312a32` (RealmJoin tenant rsneuffen.de) — a Phase-Start rule on FinalizingSetup fired at the raw ESP-exit `EspPhaseChanged(FinalizingSetup)` signal, 7 minutes before the engine's RealmJoin-gated `phase_transition(FinalizingSetup)`, reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` before the RealmJoin package created it. Phase triggers now fire exactly when the timeline shows the phase; `on_event` additionally sees engine-emitted event types (`enrollment_complete`, `realmjoin_resolved`, …) for the first time in V2, restoring V1 semantics and the documented end-of-enrollment pattern. Other raw-signal hosts (DeviceInfo, keep-awake, PPKG) deliberately unchanged.
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
* **Creation**: Added `backend/tenant-activation-welcome-mail.md` — triggered by the first production auto-approve (activation at signup+64 s, notification address saved at +69 s → welcome mail silently skipped, since the approval path is the only sender and reads the address once). Fix documented in the doc: the notification-email save path now also triggers the send (`PUT /api/preview/notification-email` → fresh approval check `IsApprovedFreshAsync`, bypassing the 30 s negative cache), and a conditional-insert `welcome-email-sent` marker in `PreviewWhitelist` makes the send once-only across both racing paths (marker consumed strictly after the address check; revoke clears it, offboarding wipes the partition, the GA resend endpoint sends unconditionally and consumes it best-effort).
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
* **Update**: `agent/gather-rule-debug-log.md` — three trace enrichments: (1) new `phase` stage writing an enrollment-phase transition marker (`phase change: Old -> New`, ruleId `-`) so every rule-level line reads against the current phase; (2) logparser now writes one line per regex match — line number, matched text, capture groups — capped at 10 per file per run (the per-file summary keeps the full count); (3) the invalid-regex `error` line includes the offending pattern string.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
type: concept
|
||||
title: MCP Tool Telemetry
|
||||
description: The three telemetry layers for judging MCP tool quality — backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines that carry the signals the backend cannot see (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
|
||||
resource: src/McpServer/autopilot-monitor-mcp/src/telemetry.ts
|
||||
tags: [mcp, telemetry, observability, tool-quality]
|
||||
timestamp: 2026-08-05
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Three layers answer "are the MCP tools doing their job, and are tools missing?":
|
||||
|
||||
1. **Backend App Insights** — every tool handler runs inside `withToolTelemetry`
|
||||
(`src/telemetry.ts`), which propagates the tool name via AsyncLocalStorage so
|
||||
`apiFetch` stamps `X-MCP-Tool-Name` on each backend request.
|
||||
`RequestTelemetryMiddleware` copies it into the request telemetry as
|
||||
`McpToolName`: per-tool call volume, latency percentiles and HTTP failure
|
||||
rates by KQL. Blind spot: tools that never call the backend, and handler-level
|
||||
soft errors (see layer 3).
|
||||
2. **Usage table** — `McpQuotaEnforcementMiddleware.TrackUsage` counts per
|
||||
user/day under the key `toolname:endpoint`; surfaced by the `get_api_usage`
|
||||
tool. Counts only, every call counts as success.
|
||||
3. **stderr JSON lines** (`MCP_TOOL_LOGGING=true`, queryable via Container App
|
||||
Logs) — the layer that carries what the other two structurally cannot:
|
||||
- `tool_call`: duration, `isError` **including soft errors** (handlers never
|
||||
throw — `toolError` *returns* `isError: true`, so backend-only telemetry
|
||||
records those calls as successes), `resultChars` + `overCap` (result
|
||||
exceeds the tool's `anthropic/maxResultSizeChars` hint → the host truncates
|
||||
it; a frequently-overCap tool needs tighter defaults or projections), a
|
||||
size-bounded args summary, and the caller scope (ga/delegated/tenant).
|
||||
This is the ONLY telemetry for backend-free tools: `search_docs`,
|
||||
`validate_rule`, `get_resource`, the local ranking of `search_knowledge`.
|
||||
- `search_zero_hit`: query text (capped) whenever `search_events` /
|
||||
`search_knowledge` / `search_docs` return zero results — the most direct
|
||||
"missing docs / missing knowledge / missing tool?" demand signal.
|
||||
- `tool_call_rejected`: rejections the SDK answers *before* any handler runs
|
||||
(Zod argument validation, unknown/disabled tool). Two non-obvious facts,
|
||||
both found by live verification: SDK 1.30 wraps these McpErrors as a
|
||||
*successful* JSON-RPC response whose result is `{ isError: true, text:
|
||||
"MCP error <code>: …" }` — a JSON-RPC error envelope never exists for
|
||||
them — and the Hono bridge (`getRequestListener`) may stream the body as
|
||||
`res.write` chunks with a bare `res.end()`. The sniffer in the `/mcp`
|
||||
route therefore wraps both `write` and `end`, reassembles up to 10 KB and
|
||||
matches both shapes (`attachToolCallRejectionSniffer` in `telemetry.ts`).
|
||||
Handler soft errors never carry the `MCP error` prefix, so they are not
|
||||
double-logged. This is the strongest "the schema or description confuses
|
||||
the model" signal and was previously invisible everywhere.
|
||||
|
||||
The env flag defaults to `true` in `infra/mcp-server.bicep` (documented desired
|
||||
state — the template is not routinely deployed; the live flag is set with
|
||||
`az containerapp update --set-env-vars MCP_TOOL_LOGGING=true`).
|
||||
|
||||
# Examples
|
||||
|
||||
Count zero-hit queries to find unmet demand (Log Analytics):
|
||||
|
||||
```kusto
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where Log_s has '"type":"search_zero_hit"'
|
||||
| extend d = parse_json(Log_s)
|
||||
| summarize count() by tostring(d.tool), tostring(d.query)
|
||||
```
|
||||
|
||||
Per-tool soft-error and truncation rates:
|
||||
|
||||
```kusto
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where Log_s has '"type":"tool_call"'
|
||||
| extend d = parse_json(Log_s)
|
||||
| summarize calls = count(), errors = countif(d.isError == true),
|
||||
overCap = countif(d.overCap == true), p95ms = percentile(tolong(d.durationMs), 95)
|
||||
by tool = tostring(d.tool)
|
||||
```
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/telemetry.ts` — wrapper + log emitters
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/index.ts` — `/mcp` rejection sniffer
|
||||
- `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `McpToolName` stamping
|
||||
- `src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs` — usage-table counting
|
||||
@@ -8,7 +8,7 @@ tags:
|
||||
- backend
|
||||
- gather-rules
|
||||
- config
|
||||
timestamp: 2026-07-20T00:00:00+02:00
|
||||
timestamp: 2026-08-04T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Problem
|
||||
@@ -64,6 +64,35 @@ enter-rule and an exit-rule on the same phase never cannibalise each other's slo
|
||||
* Old agents do not know the trigger value and simply never fire the rule (silent no-op) —
|
||||
which is why `phase_exit` needs no ConfigVersion bump: no new field is transported.
|
||||
|
||||
# Which "phase" feeds the triggers — the emitted timeline, not raw signals
|
||||
|
||||
The phase the executor sees is fed by `GatherRuleExecutorHost` from the **post-reduce
|
||||
`TimelineEventStream`**: `EventTimelineEmitter` publishes every emitted event's
|
||||
`(eventType, phase)` after the decision engine reduced the originating signal. Phase triggers
|
||||
therefore fire exactly when the UI timeline shows the phase — by construction.
|
||||
|
||||
This replaced the original V2 wiring (`SignalIngress.SignalPosted`), which fires at enqueue
|
||||
time with the **raw** collector payload. The raw phase can run minutes ahead of the
|
||||
engine-declared one: in session `32312a32` (RealmJoin tenant) the Shell-Core ESP-exit
|
||||
synthesized `EspPhaseChanged(FinalizingSetup)` at 12:18, while the engine — holding the
|
||||
transition behind the RealmJoin completion gate until the first deployment resolved — declared
|
||||
`phase_transition(FinalizingSetup)` at 12:25. A `phase_change` rule on `FinalizingSetup`
|
||||
reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` fired 7 minutes early and read the key before
|
||||
the RealmJoin package wrote it (`exists=false`).
|
||||
|
||||
Consequences of the emitted-event feed:
|
||||
|
||||
* `phase_change` / `phase_exit` / phase **scope** gates all follow the engine-reduced phase
|
||||
(single feed, consistent with the portal's "collects once when the enrollment reaches …").
|
||||
* `on_event` now sees **every emitted event type**, including engine-emitted ones
|
||||
(`enrollment_complete`, `phase_transition`, `realmjoin_resolved`, …) that never existed on
|
||||
the raw signal stream — the documented "On Event with `enrollment_complete`" end-of-enrollment
|
||||
pattern only works because of this. This restores V1 semantics (V1 fired triggers from every
|
||||
emitted `EnrollmentEvent`).
|
||||
* Other hosts (`DeviceInfoHost`, `UserEspKeepAwakeHost`, `ProvisioningPackageHost`) still key
|
||||
off the raw signal stream deliberately — their collections are about OS state at the raw
|
||||
boundary (ESP exit, keep-awake), not about timeline agreement.
|
||||
|
||||
# Scope semantics (agent, `GatherRuleExecutor`)
|
||||
|
||||
* Scope applies to **all trigger types**:
|
||||
@@ -130,7 +159,9 @@ enter-rule and an exit-rule on the same phase never cannibalise each other's slo
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs` — scope gates, latch, canonical hash
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/GatherRuleExecutorHost.cs` — timeline-event trigger feed
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/TimelineEventStream.cs` — post-reduce observer surface
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — `ValidateScopeAndEmitMode`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs` — partial-PUT merge, `ContentEquivalent`
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs` — field contracts
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
* Tests: `GatherRuleExecutorPhaseScopeTests`, `GatherRuleExecutorEmitModeTests`, `GatherRuleExecutorHostTimelineFeedTests` (agent); `GatherRuleUpdatePartialMergeTests`, `GatherRuleScopeFieldsTests` (backend)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Rule Authoring Surface — MCP Resources, validate_rule, and the Analyze Dry-Run
|
||||
description: >-
|
||||
How customer AIs author gather/analyze rules against the real contract: the baked
|
||||
authoring resources (guide, schemas, guardrails) generated by combine.js, the
|
||||
MCP-local validate_rule pre-flight, and the side-effect-free backend dry-run that
|
||||
traces a draft analyze rule against one session's events.
|
||||
resource: src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts
|
||||
tags: [rules, mcp, analyze-rules, gather-rules, dry-run, authoring]
|
||||
timestamp: 2026-08-05
|
||||
---
|
||||
|
||||
# Concept
|
||||
|
||||
Customers author rules with an AI connected to the MCP server. Retrieval
|
||||
(`search_docs`) is deliberately NOT the knowledge source for authoring: chunked
|
||||
top-K retrieval can silently omit an operator, a guardrail prefix, or an enum,
|
||||
and the AI then produces a plausible-but-dead rule. The authoring surface is
|
||||
therefore **deterministic**: three `get_resource` entries carry the complete
|
||||
contract, a local validator rejects what production would reject or silently
|
||||
ignore, and a backend dry-run evaluates a draft against real session events with
|
||||
a full trace.
|
||||
|
||||
The intended loop: draft → `validate_rule` → `test_analyze_rule` against a real
|
||||
session (one that should fire, one that should not) → iterate → create in the
|
||||
portal. The `author-rule` MCP prompt walks a client through exactly this order.
|
||||
|
||||
# Schema
|
||||
|
||||
## Resources (deterministic authoring context)
|
||||
|
||||
| Resource | Content | Source |
|
||||
| --- | --- | --- |
|
||||
| `rule_authoring_guide` | Hand-written complete authoring knowledge: collector types, triggers, condition sources, operators, confidence model incl. the exact factor DSL, `{{token}}` interpolation order, guardrail matching semantics, pitfalls | `src/rule-authoring-guide.ts` |
|
||||
| `rule_schemas` | Both JSON Schemas (2020-12) verbatim | generated |
|
||||
| `rule_guardrails` | `rules/guardrails.json` verbatim | generated |
|
||||
|
||||
Generation: `node rules/scripts/combine.js` bakes schemas + guardrails into
|
||||
`src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts` (same
|
||||
pattern as the web's `utils/guardrails.generated.ts`; the tsconfig `rootDir` and
|
||||
the Docker build's exclusion of `rules/schema` rule out importing the JSON
|
||||
directly). Drift is closed twice: CI's `guardrails-in-sync` job re-runs
|
||||
combine.js and fails on any diff, and `rule-authoring-drift.test.ts` pins
|
||||
generated == repo source plus guide enums == schema enums.
|
||||
|
||||
## validate_rule (MCP-local)
|
||||
|
||||
`src/rule-validation.ts`, no backend call. Three layers:
|
||||
|
||||
1. **Schema** — ajv (draft 2020-12) against the baked schemas, validating the
|
||||
single-rule `$defs` entry directly (the top-level oneOf wrapper would double
|
||||
every error).
|
||||
2. **Guardrails** — mirrors the AGENT's matching semantics from
|
||||
`GatherRuleGuards.cs` (see [gather-rule-guardrails.md](gather-rule-guardrails.md)):
|
||||
hive-stripped segment-bounded registry/file prefixes, whitespace-bounded WMI
|
||||
prefixes, EXACT command matching, `/`-bounded event-log channels, and the
|
||||
code-enforced hard blocks (`Security` etc. channels, `C:\Users`,
|
||||
`C:\Windows\System32\config`).
|
||||
3. **Semantic lint** — schema-valid-but-dead rules: unreachable
|
||||
confidenceThreshold, factor conditions outside the three parseable shapes
|
||||
(`exists`, `count >= N`, `phase_duration > N`), unknown event types (warning;
|
||||
`gather_*` softened to info), unanchored `not_regex` allow-lists,
|
||||
unresolvable `{{token}}`s, trigger cross-fields, outputEventType
|
||||
convention/collision checks.
|
||||
|
||||
The validator is advisory pre-flight only — the agent re-enforces guardrails
|
||||
on-device and the backend re-validates structure. It must never be stricter
|
||||
than production.
|
||||
|
||||
## POST /api/rules/analyze/dryrun (backend)
|
||||
|
||||
`DryRunAnalyzeRuleFunction` → `RuleEngine.DryRunRuleAsync`. Body
|
||||
`{ sessionId, rule }` (draft `AnalyzeRule`, 1 MB cap). Policy tier
|
||||
`TenantAdminOrGlobalReader` — a POST by shape but a pure read in effect, so the
|
||||
read-only Global Reader is admitted; platform-scope callers reach any tenant's
|
||||
session via `FindSessionTenantIdAsync` (same pattern as `GetRuleResultsFunction`).
|
||||
|
||||
`RuleEngine.DryRun.cs` is a partial-class trace twin of `EvaluateRule`: it
|
||||
shares the exact evaluators (`EvaluatePrecondition` / `EvaluateCondition` /
|
||||
`EvaluateConfidenceFactor`) but evaluates **all** conditions instead of breaking
|
||||
on the first failed required one — the full picture is the point of a dry run.
|
||||
Verdicts: `fired`, `skipped_by_precondition`, `required_condition_not_met`,
|
||||
`no_conditions_matched`, `below_confidence_threshold`, `no_events`. Each
|
||||
condition entry carries `matched`, the production evidence dictionary (or the
|
||||
evaluator's reason string) and `matchingEventCount` — how many session events
|
||||
even have that eventType, the first thing to check on an unexpected miss.
|
||||
|
||||
Invariants, pinned by `RuleEngineDryRunTests`:
|
||||
|
||||
* **Parity**: verdict `fired` ⇔ `EvaluateRule` produces a result, with identical
|
||||
confidence and evidence keys. Factor evaluation stays sequential against the
|
||||
accumulating `matchedConditions` (a factor can observe earlier `factor_*`
|
||||
entries) — reordering would change results.
|
||||
* **Side-effect freedom**: exactly one strict event read; never a RuleResult
|
||||
write, never `TryMarkSessionFailedFromRuleAsync`, never a notification.
|
||||
`wouldMarkSessionAsFailed` is reported, not acted on.
|
||||
|
||||
`test_analyze_rule` (MCP) wraps the endpoint and adds the interpolated
|
||||
explanation/remediation preview via the existing `interpolate-rule-template.ts`
|
||||
against the returned `matchedConditions`.
|
||||
|
||||
## POST /api/rules/gather/test-pattern (backend)
|
||||
|
||||
The logparser counterpart to the analyze dry-run, added after a field incident:
|
||||
a customer verified a logparser regex with a PHP tester and the agent's .NET
|
||||
engine matched differently. `TestLogPatternFunction` evaluates a pattern against
|
||||
pasted sample lines with the AGENT's exact matching semantics:
|
||||
|
||||
* Regex construction identical to `LogParserCollector`: `Compiled`, 1 s timeout,
|
||||
**no IgnoreCase** — logparser matching is case-sensitive (unlike analyze-rule
|
||||
regex conditions, which run IgnoreCase).
|
||||
* `format` behaves exactly like the rule parameter: `cmtrace` (default) parses
|
||||
each line via the shared `CmTraceLogParser` and matches against the parsed
|
||||
MESSAGE only; `text` matches the raw line. First match per line
|
||||
(`Regex.Match`), group `"0"` excluded, unsuccessful groups omitted — the
|
||||
returned `groups` are exactly the event-data fields the device would emit.
|
||||
* Mirrors the agent's diagnostics: per-line outcomes
|
||||
(`matched | no_match | parse_failed | regex_timeout`), the all-lines-parse-fail
|
||||
hint ("set parameter format=text") verbatim, and a case-sensitivity note on
|
||||
zero matches.
|
||||
* Pure compute on the request body — no tenant data touched. Same policy tier
|
||||
as the dry-run (`TenantAdminOrGlobalReader`); caps: 200 lines, 8 KB/line,
|
||||
1 MB body.
|
||||
|
||||
Parity is structural, not duplicated: `CmTraceLogParser` moved from the agent
|
||||
(`Monitoring/Enrollment/Ime`) to **Shared** (`AutopilotMonitor.Shared.Logging`)
|
||||
so agent and backend run the SAME parsing implementation. Timestamp caveat:
|
||||
bias-less CMTrace lines parse with `AssumeLocal`, which on the backend is the
|
||||
server's timezone — the endpoint therefore reports match/parse results, never
|
||||
timestamps. `test_log_pattern` (MCP) wraps the endpoint; `validate_rule`
|
||||
additionally lints logparser parameters (missing/non-compiling pattern, unknown
|
||||
format value) and its `nextStep` routes logparser drafts to `test_log_pattern`.
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
* **No gather-rule remote execution** — gather rules run as SYSTEM on devices;
|
||||
session dry-run is analyze-only. Logparser authors get the pattern tester
|
||||
above; all gather authors get guardrail pre-flight plus the on-device debug
|
||||
log ([gather-rule-debug-log](../agent/gather-rule-debug-log.md)).
|
||||
* **No rule write/deploy via MCP** — creating rules stays in the portal.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/TestLogPatternFunction.cs` — logparser pattern tester
|
||||
* `src/Shared/AutopilotMonitor.Shared/Logging/CmTraceLogParser.cs` — shared CMTrace parser (agent + backend)
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs` — trace twin + DTOs
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs` — endpoint + draft validation
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts` — validator
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts` — validate_rule / test_analyze_rule
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts` — guide resource
|
||||
* `rules/scripts/combine.js` — MCP module generation
|
||||
@@ -60,8 +60,8 @@ param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net'
|
||||
@description('Container image tag')
|
||||
param imageTag string = 'latest'
|
||||
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret.')
|
||||
param entraClientId string = '1a400946-62c1-4ab4-aa37-f730ac89704d'
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret. Since the C4A8 app-reg move this is the NEW app (the old 1a400946-… app remains valid at the backend for legacy-homed tenants).')
|
||||
param entraClientId string = '886ab5e2-6144-442c-80cc-9b28e0667731'
|
||||
|
||||
@secure()
|
||||
@description('Entra ID client secret for OAuth token exchange')
|
||||
@@ -80,8 +80,8 @@ param mcpPublicUrl string = ''
|
||||
@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))')
|
||||
param oAuthStateSigningKey string = ''
|
||||
|
||||
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs)')
|
||||
param toolLoggingEnabled bool = false
|
||||
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs). Volume is one small JSON line per tool call - negligible at current usage.')
|
||||
param toolLoggingEnabled bool = true
|
||||
|
||||
// --- Azure Container Registry ---
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-SEC-008",
|
||||
"title": "Secure Boot is disabled",
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "note",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "secureboot_disabled",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiSecureBootEnabled",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"source": "event_data",
|
||||
"eventType": "hardware_spec",
|
||||
"dataField": "isVirtualMachine",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "physical_device", "condition": "exists", "weight": 20 }
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Enable Secure Boot in UEFI firmware",
|
||||
"steps": [
|
||||
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
|
||||
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
|
||||
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
|
||||
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
|
||||
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Check compliance impact",
|
||||
"steps": [
|
||||
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
|
||||
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Re-enable Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "secureboot", "firmware", "uefi", "compliance"]
|
||||
}
|
||||
+87
@@ -2687,6 +2687,93 @@
|
||||
"truncated",
|
||||
"coverage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-SEC-008",
|
||||
"title": "Secure Boot is disabled",
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "note",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "secureboot_disabled",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiSecureBootEnabled",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"source": "event_data",
|
||||
"eventType": "hardware_spec",
|
||||
"dataField": "isVirtualMachine",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Enable Secure Boot in UEFI firmware",
|
||||
"steps": [
|
||||
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
|
||||
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
|
||||
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
|
||||
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
|
||||
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Check compliance impact",
|
||||
"steps": [
|
||||
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
|
||||
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Re-enable Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
"firmware",
|
||||
"uefi",
|
||||
"compliance"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -161,3 +161,45 @@ ${flatArray(guardrails.blockedEventLogChannels)}
|
||||
} else {
|
||||
console.warn('guardrails.json not found — skipping guardrails generation');
|
||||
}
|
||||
|
||||
// ── MCP rule-authoring module generation ────────────────────────────────────
|
||||
// The MCP server exposes the rule schemas + guardrails as get_resource content
|
||||
// so a customer's AI can author rules against the real contract instead of
|
||||
// retrieval fragments. The server's tsconfig (rootDir: src, no resolveJsonModule)
|
||||
// and the Docker build (rules/schema not copied) rule out importing the JSON
|
||||
// directly — so we bake it into a TS module, exactly like the web mirror above.
|
||||
// CI's guardrails-in-sync job re-runs this script and fails on any diff, which
|
||||
// makes drift between rules/* and this generated module impossible to merge.
|
||||
|
||||
const mcpAuthoringOutput = path.resolve(
|
||||
rulesRoot, '..', 'src', 'McpServer', 'autopilot-monitor-mcp', 'src', 'rule-authoring.generated.ts'
|
||||
);
|
||||
|
||||
{
|
||||
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
|
||||
const gatherSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'gather-rule.schema.json'), 'utf8'));
|
||||
const analyzeSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'analyze-rule.schema.json'), 'utf8'));
|
||||
|
||||
const ts = `/**
|
||||
* AUTO-GENERATED from rules/guardrails.json + rules/schema/*.schema.json — DO NOT EDIT.
|
||||
* Run: node rules/scripts/combine.js
|
||||
*
|
||||
* Consumed by the MCP server's rule-authoring surface (get_resource +
|
||||
* validate_rule): the JSON Schemas are the validation contract, the guardrails
|
||||
* are the agent-side collection allowlists. Single source of truth is rules/;
|
||||
* the CI guardrails-in-sync job guards this file against drift.
|
||||
*/
|
||||
|
||||
/** JSON Schema (2020-12) for gather rules — rules/schema/gather-rule.schema.json verbatim. */
|
||||
export const GATHER_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(gatherSchema, null, 2)};
|
||||
|
||||
/** JSON Schema (2020-12) for analyze rules — rules/schema/analyze-rule.schema.json verbatim. */
|
||||
export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(analyzeSchema, null, 2)};
|
||||
|
||||
/** Gather-rule collection guardrails — rules/guardrails.json verbatim. */
|
||||
export const RULE_GUARDRAILS = ${JSON.stringify(guardrails, null, 2)} as const;
|
||||
`;
|
||||
|
||||
fs.writeFileSync(mcpAuthoringOutput, ts, 'utf8');
|
||||
console.log('rule-authoring.generated.ts: schemas + guardrails baked for MCP server');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
|
||||
# Install-AutopilotMonitor-dev.ps1 (v2.3-dev).
|
||||
#
|
||||
# The script under test is dot-sourced; its entry guard prevents the bootstrap
|
||||
# from executing, so loading it here is side-effect free. External state (WMI,
|
||||
# registry, filesystem, WinRT) is mocked; profile directories are real folders
|
||||
# under Pester's $TestDrive with a manipulated CreationTimeUtc.
|
||||
#
|
||||
# Run under Windows PowerShell 5.1 to match the IME runtime:
|
||||
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
|
||||
#
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor-dev.ps1')
|
||||
|
||||
function New-FakeProfile {
|
||||
param([string]$Name, [double]$AgeMinutes)
|
||||
$p = Join-Path $TestDrive $Name
|
||||
New-Item -ItemType Directory -Path $p -Force | Out-Null
|
||||
(Get-Item $p).CreationTimeUtc = (Get-Date).ToUniversalTime().AddMinutes(-$AgeMinutes)
|
||||
return $p
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-IsCloudPc' {
|
||||
# Marker set captured 2026-08-06 from a real W365 Enterprise Cloud PC: local WMI
|
||||
# is generic ('Virtual Machine'), so detection rests on the Windows365 registry
|
||||
# key AND the CloudManagedDesktopExtension service (both required).
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Microsoft Corporation'; Model = 'Virtual Machine' } }
|
||||
}
|
||||
|
||||
It 'detects a Cloud PC when both markers are present' {
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not detect with the registry key alone (e.g. W365-Boot physical client)' {
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { $null }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not detect with the service alone' {
|
||||
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not detect a plain device (no markers)' {
|
||||
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { $null }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'still evaluates markers when the WMI identity query fails' {
|
||||
Mock Get-CimInstance { throw 'RPC server unavailable' }
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-OobeState' {
|
||||
It 'returns a non-empty state string and never throws' {
|
||||
Get-OobeState | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RealUserProfilePaths' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
}
|
||||
|
||||
It 'merges WMI and filesystem views, drops special/system profiles, dedupes' {
|
||||
Mock Get-CimInstance {
|
||||
@(
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\LeonGottschalk' }
|
||||
[pscustomobject]@{ Special = $true; LocalPath = 'C:\Users\systemprofile' }
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'D:\Profiles\Elsewhere' }
|
||||
)
|
||||
}
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\LeonGottschalk' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Public' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
|
||||
)
|
||||
}
|
||||
$result = @(Get-RealUserProfilePaths)
|
||||
$result | Should -Be @('C:\Users\LeonGottschalk')
|
||||
}
|
||||
|
||||
It 'falls back to the filesystem view when the WMI query fails' {
|
||||
Mock Get-CimInstance { throw 'WMI broken' }
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\SomeUser' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Default' }
|
||||
)
|
||||
}
|
||||
$result = @(Get-RealUserProfilePaths)
|
||||
$result | Should -Be @('C:\Users\SomeUser')
|
||||
}
|
||||
|
||||
It 'returns an empty result when only special/system profiles exist' {
|
||||
Mock Get-CimInstance { @() }
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Public' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\defaultuser1' }
|
||||
)
|
||||
}
|
||||
@(Get-RealUserProfilePaths).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RelaxDecision' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
# Defaults: physical device, OOBE long done. Individual tests override.
|
||||
Mock Get-OobeState { 'Completed' }
|
||||
Mock Test-IsCloudPc { $false }
|
||||
}
|
||||
|
||||
Context 'Windows 365 Cloud PC' {
|
||||
BeforeEach {
|
||||
Mock Test-IsCloudPc { $true }
|
||||
}
|
||||
|
||||
It 'relaxes for a single fresh profile although OOBE is Completed (W365 field case)' {
|
||||
$p = New-FakeProfile 'LeonGottschalk' 0.6
|
||||
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
|
||||
$d.Active | Should -BeTrue
|
||||
$d.IsCloudPc | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not relax for an old profile (productive Cloud PC)' {
|
||||
$p = New-FakeProfile 'LeonGottschalk' 30
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not relax for two profiles' {
|
||||
$p1 = New-FakeProfile 'UserA' 2
|
||||
$p2 = New-FakeProfile 'UserB' 400
|
||||
(Get-RelaxDecision -ProfilePaths @($p1, $p2) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'relaxes even when the WinRT OOBE API is unavailable (old build)' {
|
||||
Mock Get-OobeState { 'Unavailable' }
|
||||
$p = New-FakeProfile 'CpcUser' 1
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not relax for a profile with a future creation time (clock skew)' {
|
||||
$p = New-FakeProfile 'SkewUser' -10
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Context 'OOBE InProgress (Windows Backup for Organizations, v2.2 behaviour)' {
|
||||
It 'relaxes for a single fresh profile during OOBE' {
|
||||
Mock Get-OobeState { 'InProgress' }
|
||||
$p = New-FakeProfile 'RestoreUser' 3
|
||||
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
|
||||
$d.Active | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Context 'no trigger' {
|
||||
It 'does not relax on a physical device with a fresh profile and OOBE Completed' {
|
||||
$p = New-FakeProfile 'SomeUser' 2
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'stays inactive with no profiles at all' {
|
||||
(Get-RelaxDecision -ProfilePaths @() -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-BootstrapDecision' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
# Baseline: clean device during Device ESP -- no marker, no profiles, no
|
||||
# logged-on user, 1h uptime, agent not installed. Tests override selectively.
|
||||
Mock Get-ItemProperty { $null }
|
||||
Mock Get-RealUserProfilePaths { @() }
|
||||
Mock Get-OobeState { 'Completed' }
|
||||
Mock Test-IsCloudPc { $false }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-1) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$script:cleanAgentBin = Join-Path $TestDrive 'AgentBin'
|
||||
}
|
||||
|
||||
It 'installs on a clean ESP device' {
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.ReasonCode | Should -BeNullOrEmpty
|
||||
$d.RelaxActive | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'skips when the Deployed marker exists (guard 1)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'AlreadyDeployed'
|
||||
}
|
||||
|
||||
It 'skips a productive device with an old profile (guard 2)' {
|
||||
$script:fakeProfile = New-FakeProfile 'OldUser' 5000
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'ProfilesFound'
|
||||
}
|
||||
|
||||
It 'installs on a freshly provisioned Cloud PC with one fresh profile (W365 field case)' {
|
||||
$script:fakeProfile = New-FakeProfile 'LeonGottschalk' 0.6
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Test-IsCloudPc { $true }
|
||||
# First-connect user is already visible in LogonUI on W365 -- must not block either.
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\LeonGottschalk' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'skips when a real user has logged on and no relax applies (guard 3)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\SomeUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'LastLoggedOnUser'
|
||||
}
|
||||
|
||||
It 'does not skip for the defaultuser0 OOBE account (guard 3)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'defaultuser0' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin).Install | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'skips when uptime exceeds the bootstrap window (guard 4)' {
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption, v2.3-dev.3)' {
|
||||
# W365 reality: provisioned days ago, running headless ever since; the user's
|
||||
# first connect just created the profile. Uptime is meaningless here.
|
||||
$script:fakeProfile = New-FakeProfile 'FirstConnectUser' 0.6
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Test-IsCloudPc { $true }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'still skips an over-uptime Cloud PC when the relax is NOT active (no fresh profile)' {
|
||||
Mock Test-IsCloudPc { $true }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'keeps guard 4 for the OOBE-restore relax (physical device, uptime is meaningful)' {
|
||||
$script:fakeProfile = New-FakeProfile 'RestoreUser' 3
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Get-OobeState { 'InProgress' }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'skips when the agent binary is already present (guard 5)' {
|
||||
$binPath = Join-Path $TestDrive 'AgentBinExisting'
|
||||
New-Item -ItemType Directory -Path $binPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $binPath 'AutopilotMonitor.Agent.exe') -Value 'stub'
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $binPath
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'AgentPresent'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'logging purity' {
|
||||
# Regression guard: Write-Log runs inside functions that RETURN decision
|
||||
# objects. If it ever emits to the output stream again (Write-Output), the
|
||||
# log lines leak into the return values and turn them into arrays. This
|
||||
# test deliberately does NOT mock Write-Log.
|
||||
It 'Get-BootstrapDecision returns exactly one object with real logging' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
||||
# Plain local assignment: PowerShell's dynamic scoping makes Write-Log
|
||||
# (called from Get-BootstrapDecision) resolve this $LogFile instead of
|
||||
# the ProgramData path defined by the dot-sourced script.
|
||||
$LogFile = Join-Path $TestDrive 'bootstrap_test.log'
|
||||
$d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath (Join-Path $TestDrive 'NoAgent'))
|
||||
$d.Count | Should -Be 1
|
||||
$d[0].ReasonCode | Should -Be 'AlreadyDeployed'
|
||||
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
DEV bootstrap script (v2.3-dev) to deploy and start the Autopilot Monitor agent.
|
||||
|
||||
.DESCRIPTION
|
||||
DEV VARIANT of Install-AutopilotMonitor.ps1 for validating the v2.3 Cloud-PC-relax
|
||||
on Windows 365 before promoting it to the production script. Differences to v2.2:
|
||||
- Cloud-PC-relax (see below) as a second trigger for the guard 2+3 exemption
|
||||
- Raw OutOfBoxExperienceState value is logged (diagnosis)
|
||||
- Logs to bootstrap_agent_dev.log so runs never interleave with the prod script
|
||||
- Testable structure: guard/relax logic lives in functions; the script only
|
||||
executes when run directly. Dot-sourcing it (as the Pester suite in
|
||||
Install-AutopilotMonitor-dev.Tests.ps1 does) loads the functions without
|
||||
side effects.
|
||||
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
A relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (Windows Backup for Organizations restore) or on a
|
||||
freshly provisioned Windows 365 Cloud PC.
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Two legitimate flows break that assumption:
|
||||
|
||||
(1) Windows Backup for Organizations: one user signs in + restores DURING OOBE.
|
||||
Trigger: the OS positively reports OOBE InProgress (Test via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState).
|
||||
|
||||
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
|
||||
complete before any user ever connects, so OutOfBoxExperienceState is NEVER
|
||||
InProgress when this script runs. The assigned user's profile is created at
|
||||
first connect, typically seconds before IME pulls this script. Trigger: the
|
||||
device positively identifies as a Cloud PC via TWO local markers (both
|
||||
required; captured from a real Cloud PC -- local WMI reports only the
|
||||
generic 'Virtual Machine' model): HKLM\SOFTWARE\Microsoft\Windows365
|
||||
registry key AND the CloudManagedDesktopExtension service.
|
||||
|
||||
Either trigger relaxes guards 2+3 ONLY when additionally:
|
||||
(a) there is exactly ONE real user profile, AND
|
||||
(b) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see
|
||||
.PARAMETER MaxBootstrapWindowHours for why boot uptime is meaningless there.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
On productive devices the profile is old, so (b) keeps skipping correctly.
|
||||
Known accepted edge: a W365 Frontline shared-mode device can legitimately show a
|
||||
single fresh profile while productive; a mis-install there is one-time (agent
|
||||
finds enrollment complete and self-destructs; Deployed marker prevents repeats).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production download endpoint;
|
||||
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
Defaults to "version.json"; override only for parallel lab/dev manifests.
|
||||
|
||||
.PARAMETER MaxBootstrapWindowHours
|
||||
Maximum device uptime (hours) within which the bootstrap is still considered
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
Cloud PC note: NOT applied when the Cloud-PC relax is active. A Cloud PC runs
|
||||
headless for days between provisioning and the user's first connect, and the
|
||||
phase worth monitoring (Account Setup) only starts at that first connect --
|
||||
there the fresh-profile window (OobeProfileMaxAgeMinutes) is the time anchor,
|
||||
not boot uptime.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear for the relax
|
||||
exemption to apply. The restore/first-connect -> bootstrap gap is always
|
||||
minutes; an older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> state reads as 'Unavailable' -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-08-06 v2.3-dev.3 Guard 4 (uptime window) exempted while the Cloud-PC relax is
|
||||
active: Cloud PCs run headless for days before the user's first
|
||||
connect, so boot uptime says nothing about enrollment freshness --
|
||||
the single-fresh-profile window (< OobeProfileMaxAgeMinutes) already
|
||||
anchors the relax in time. OOBE-restore keeps the uptime guard.
|
||||
2026-08-06 v2.3-dev.2 Cloud PC detection rebuilt on captured evidence: first field
|
||||
test proved local WMI reports only Manufacturer='Microsoft
|
||||
Corporation' + Model='Virtual Machine' (the 'Cloud PC ...' model
|
||||
string exists only in Intune/Graph). Detection now requires the
|
||||
HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
|
||||
CloudManagedDesktopExtension service (both verified present on a
|
||||
real Cloud PC); raw identity + marker states are always logged.
|
||||
2026-08-05 v2.3-dev DEV variant (-dev.ps1, own log file bootstrap_agent_dev.log)
|
||||
to validate on Windows 365 before promotion to v2.3:
|
||||
Cloud-PC-relax -- a positively identified Cloud PC
|
||||
(Win32_ComputerSystem Manufacturer 'Microsoft Corporation' +
|
||||
Model 'Cloud PC*') now triggers the guard 2+3 relax alongside
|
||||
OOBE InProgress; the single-fresh-profile conditions are
|
||||
unchanged. Raw OutOfBoxExperienceState is now logged.
|
||||
Refactored into functions with a dot-source entry guard so the
|
||||
Pester suite (*.Tests.ps1) can load the logic without executing
|
||||
the bootstrap; behaviour when run directly is unchanged.
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
(older entries: see Install-AutopilotMonitor.ps1)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.3-dev.3"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent_dev.log"
|
||||
|
||||
# Write-Host (NOT Write-Output): log calls happen inside functions that return
|
||||
# decision objects; Write-Output would leak every log line into those return
|
||||
# values and turn them into arrays. Write-Host goes to the console host only.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Raw OOBE state via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns the state string (InProgress/Completed/NotStarted). An older OS without the
|
||||
# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable'
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Get-OobeState {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
|
||||
}
|
||||
catch {
|
||||
return 'Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
# Positive Windows 365 Cloud PC detection.
|
||||
# Evidence captured 2026-08-06 on a real, freshly provisioned W365 Enterprise Cloud PC:
|
||||
# - Win32_ComputerSystem is GENERIC there: Manufacturer='Microsoft Corporation',
|
||||
# Model='Virtual Machine'. The 'Cloud PC Enterprise ...' model string exists only
|
||||
# in the Intune/Graph inventory, NOT in local WMI/SMBIOS -- it cannot be used here.
|
||||
# - HKLM:\SOFTWARE\Microsoft\Windows365 exists.
|
||||
# - Service 'CloudManagedDesktopExtension' (Microsoft Cloud Managed Desktop
|
||||
# Extension, the W365 management agent that runs ON Cloud PCs) is installed.
|
||||
# BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
|
||||
# physical clients may carry Windows365 policy state; the MCMD agent family also
|
||||
# served other managed-desktop offerings). Raw identity and both marker states are
|
||||
# always logged for field diagnosis; any error returns $false (SKIP-safe).
|
||||
function Test-IsCloudPc {
|
||||
try {
|
||||
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
Write-Log "Computer identity: Manufacturer='$($cs.Manufacturer)' Model='$($cs.Model)'."
|
||||
}
|
||||
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
|
||||
return ($regMarker -and $svcMarker)
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: Cloud PC marker check failed: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an
|
||||
# empty result returns $null and a single path returns a scalar string, whose [0] indexer
|
||||
# would yield its first char ('C') instead of the full path.
|
||||
function Get-RealUserProfilePaths {
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
}
|
||||
|
||||
# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION):
|
||||
# (1) OOBE InProgress -> Windows Backup for Organizations restore
|
||||
# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect)
|
||||
# Either trigger additionally requires: exactly one real profile AND that profile
|
||||
# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip.
|
||||
# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool).
|
||||
function Get-RelaxDecision {
|
||||
param(
|
||||
[string[]]$ProfilePaths,
|
||||
[int]$OobeProfileMaxAgeMinutes
|
||||
)
|
||||
if ($null -eq $ProfilePaths) { $ProfilePaths = @() }
|
||||
|
||||
$relaxActive = $false
|
||||
$oobeState = Get-OobeState
|
||||
$oobeInProgress = ($oobeState -eq 'InProgress')
|
||||
$isCloudPc = Test-IsCloudPc
|
||||
if ($isCloudPc) {
|
||||
Write-Log "Cloud PC detected (OOBE state: $oobeState)."
|
||||
}
|
||||
|
||||
$relaxTrigger = $null
|
||||
if ($oobeInProgress) {
|
||||
$relaxTrigger = "OOBE InProgress (Windows Backup restore case)"
|
||||
} elseif ($isCloudPc) {
|
||||
$relaxTrigger = "Cloud PC (headless provisioning, OOBE state: $oobeState)"
|
||||
}
|
||||
|
||||
if ($ProfilePaths.Count -eq 1 -and $relaxTrigger) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $ProfilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$relaxActive = $true
|
||||
Write-Log ("Relax active: {0} + single profile created {1:N1}min ago (< {2}min). Guards 2+3 relaxed." -f $relaxTrigger, $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("Relax NOT applied: {0} + single profile '{1}', but profile created {2:N1}min ago (created {3:u}) is outside the [0, {4})min window." -f $relaxTrigger, (Split-Path $ProfilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($ProfilePaths.Count -eq 1) {
|
||||
Write-Log "Relax NOT applied: single profile present but neither OOBE InProgress nor Cloud PC (OOBE state: $oobeState)."
|
||||
} elseif ($ProfilePaths.Count -gt 1) {
|
||||
Write-Log ("Relax NOT applied: {0} real profiles present (relax requires exactly one). OOBE state: {1}; CloudPC: {2}." -f $ProfilePaths.Count, $oobeState, $isCloudPc)
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Active = $relaxActive
|
||||
OobeState = $oobeState
|
||||
IsCloudPc = $isCloudPc
|
||||
}
|
||||
}
|
||||
|
||||
# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects
|
||||
# beyond logging. Returns an object with:
|
||||
# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound |
|
||||
# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool).
|
||||
function Get-BootstrapDecision {
|
||||
param(
|
||||
[int]$MaxBootstrapWindowHours,
|
||||
[int]$OobeProfileMaxAgeMinutes,
|
||||
[string]$AgentBinPath
|
||||
)
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
|
||||
$profilePaths = @(Get-RealUserProfilePaths)
|
||||
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE state: $($relax.OobeState). CloudPC: $($relax.IsCloudPc). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same relax exemption: the restoring/first-connect user signs in legitimately).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $relax.Active) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
# Cloud PC exemption (v2.3-dev.3): a Cloud PC runs headless from provisioning until the
|
||||
# user's first connect -- often days -- and the phase worth monitoring (Account Setup)
|
||||
# only STARTS at that first connect. Boot uptime is therefore meaningless there; the
|
||||
# single-fresh-profile window of the relax (< $OobeProfileMaxAgeMinutes min) is the time
|
||||
# anchor instead. The OOBE-restore trigger keeps the original uptime guard (physical
|
||||
# device, uptime is meaningful).
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
if ($relax.Active -and $relax.IsCloudPc) {
|
||||
Write-Log "Uptime $([int]$uptimeHours)h exceeds the ${MaxBootstrapWindowHours}h window, but the Cloud PC relax is active (fresh first-connect profile is the time anchor). Continuing."
|
||||
} else {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
if (Test-Path $AgentBinPath) {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
try {
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath
|
||||
if (-not $decision.Install) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
|
||||
$runtimeProcessName = 'AutopilotMonitor.Agent'
|
||||
$verifyTimeoutSec = 10
|
||||
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
|
||||
$runtimeProc = $null
|
||||
while ((Get-Date) -lt $verifyDeadline) {
|
||||
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -ne $runtimeProc) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if ($null -ne $runtimeProc) {
|
||||
$startedUtc = 'unavailable'
|
||||
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
|
||||
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
|
||||
} else {
|
||||
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
|
||||
}
|
||||
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
|
||||
# (". .\Install-AutopilotMonitor-dev.ps1", as the Pester suite does) only loads
|
||||
# the functions above and MUST stay side-effect free.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
Invoke-Bootstrap
|
||||
}
|
||||
@@ -23,7 +23,11 @@
|
||||
One or more high-level feature identifiers. The script translates each to the underlying
|
||||
Graph permissions. Use this when you don't want to know permission strings.
|
||||
Available features:
|
||||
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
|
||||
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
|
||||
- W365CloudPcValidation -> CloudPC.Read.All
|
||||
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
|
||||
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
|
||||
of Cloud PC first-connect enrollment)
|
||||
|
||||
.PARAMETER Permissions
|
||||
Microsoft Graph application permission names to grant directly. Use when the admin UI
|
||||
@@ -76,7 +80,7 @@ param(
|
||||
[string]$ClientId,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
|
||||
[ValidateSet('ScriptDisplayNames')]
|
||||
[ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')]
|
||||
[string[]]$Features,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
|
||||
@@ -97,7 +101,8 @@ $ErrorActionPreference = 'Stop'
|
||||
# ---- Feature -> Graph permission catalog -----------------------------------
|
||||
# Keep in lock-step with src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs
|
||||
$FeatureCatalog = @{
|
||||
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
|
||||
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
|
||||
'W365CloudPcValidation' = @('CloudPC.Read.All')
|
||||
}
|
||||
|
||||
# Translate Features -> Permissions when caller used the high-level form.
|
||||
|
||||
+22
-1
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
@@ -92,6 +92,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.InRange(entry.Timestamp, before.AddSeconds(-5), after.AddSeconds(5));
|
||||
}
|
||||
|
||||
// ── UTC-bias suffix on the time field → honored, not dropped ──────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("+480", 8 * 60)] // PST writer (UTC-8): UTC = local + 480min
|
||||
[InlineData("-060", -60)] // UTC+1 writer: UTC = local - 60min
|
||||
public void TryParseLine_applies_utc_bias_suffix(string bias, int expectedOffsetMinutes)
|
||||
{
|
||||
// Session df1fcf47: a writer whose timezone differs from the agent's stamped its
|
||||
// lines with an explicit bias. Without bias handling the line either failed to
|
||||
// match at all (old regex) or would be misread in agent-local time.
|
||||
var line = CmTraceLine("msg", "11:46:19.0226610" + bias, "7-29-2026");
|
||||
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(
|
||||
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
|
||||
entry.Timestamp);
|
||||
Assert.Equal(DateTimeKind.Utc, entry.Timestamp.Kind);
|
||||
}
|
||||
|
||||
// ── Oversized line → parses, no throw, no truncation of the message ───
|
||||
|
||||
[Fact]
|
||||
|
||||
+4
-1
@@ -54,6 +54,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
public ISignalIngressSink? CapturedIngress { get; private set; }
|
||||
public IClock? CapturedClock { get; private set; }
|
||||
public AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? CapturedTelemetrySpool { get; private set; }
|
||||
public TimelineEventStream? CapturedTimelineEvents { get; private set; }
|
||||
|
||||
public CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
@@ -62,12 +63,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool)
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds;
|
||||
CapturedIngress = ingress;
|
||||
CapturedClock = clock;
|
||||
CapturedTelemetrySpool = telemetrySpool;
|
||||
CapturedTimelineEvents = timelineEvents;
|
||||
foreach (var name in HostNames)
|
||||
{
|
||||
Hosts.Add(new FakeCollectorHost(name));
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool)
|
||||
AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
CapturedPatternIds = whiteGloveSealingPatternIds;
|
||||
return new CollectorSurfaces(Array.Empty<ICollectorHost>());
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// GatherRuleExecutorHost feeds its phase_change / phase_exit / on_event triggers from the
|
||||
/// POST-REDUCE <see cref="TimelineEventStream"/> (emitted timeline events), not from raw
|
||||
/// signals. Regression anchor: session 32312a32 (rsneuffen.de) — a phase_change rule on
|
||||
/// FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP exit),
|
||||
/// 7 minutes before the engine's RealmJoin-gated phase_transition(FinalizingSetup), and read
|
||||
/// a registry key before the RealmJoin package wrote it. With the timeline feed the rule
|
||||
/// fires only when the phase declaration is actually emitted.
|
||||
/// Rules use the absent-registry-path pattern so no real system state is touched; execution
|
||||
/// is observed via the InformationalEvent posts the host sends through its ingress sink.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorHostTimelineFeedTests : IDisposable
|
||||
{
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_5b1f03a2-0000-0000-0000-000000000000";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly CapturingSink _sink = new CapturingSink();
|
||||
private readonly TimelineEventStream _stream = new TimelineEventStream();
|
||||
private GatherRuleExecutorHost? _host;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host?.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CapturingSink : ISignalIngressSink
|
||||
{
|
||||
private readonly List<string> _eventTypes = new List<string>();
|
||||
private readonly object _gate = new object();
|
||||
|
||||
public int CountOf(string eventType)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var n = 0;
|
||||
foreach (var t in _eventTypes)
|
||||
if (string.Equals(t, eventType, StringComparison.Ordinal)) n++;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
public void Post(
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
string sourceOrigin,
|
||||
Evidence evidence,
|
||||
IReadOnlyDictionary<string, string>? payload = null,
|
||||
int kindSchemaVersion = 1,
|
||||
object? typedPayload = null)
|
||||
{
|
||||
if (kind != DecisionSignalKind.InformationalEvent || payload == null) return;
|
||||
if (!payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)) return;
|
||||
lock (_gate) _eventTypes.Add(eventType);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildHost(params GatherRule[] rules)
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_host = new GatherRuleExecutorHost(
|
||||
sessionId: "sess",
|
||||
tenantId: "tenant",
|
||||
ingress: _sink,
|
||||
clock: new SystemClock(),
|
||||
logger: logger,
|
||||
rules: new List<GatherRule>(rules),
|
||||
imeLogPathOverride: null,
|
||||
unrestrictedMode: true,
|
||||
gatherDebugLogPath: null,
|
||||
timelineEvents: _stream);
|
||||
_host.Start();
|
||||
}
|
||||
|
||||
private bool WaitForCount(string eventType, int count, int timeoutMs = 10000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
if (_sink.CountOf(eventType) >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
return _sink.CountOf(eventType) >= count;
|
||||
}
|
||||
|
||||
private static GatherRule Rule(string id, string trigger, string? triggerPhase = null, string? triggerEventType = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = trigger,
|
||||
TriggerPhase = triggerPhase ?? string.Empty,
|
||||
TriggerEventType = triggerEventType ?? string.Empty,
|
||||
OutputEventType = "gather_host_test",
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void PhaseChangeRule_FiresOnEmittedPhaseDeclaration_NotOnPhaselessEvents()
|
||||
{
|
||||
BuildHost(Rule("GATHER-HOST-001", "phase_change", triggerPhase: "FinalizingSetup"));
|
||||
|
||||
// The raw-world sequence of session 32312a32 as it reaches the emitted timeline:
|
||||
// esp_exiting and hello_wizard_started are emitted WITHOUT a phase declaration
|
||||
// (the engine holds FinalizingSetup back behind the RealmJoin gate) — no fire.
|
||||
_stream.Publish("esp_exiting", EnrollmentPhase.Unknown);
|
||||
_stream.Publish("hello_wizard_started", EnrollmentPhase.Unknown);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
// Only the engine's actual phase declaration fires the rule.
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnEventRule_FiresForEngineEmittedEventTypes()
|
||||
{
|
||||
// enrollment_complete is an engine-emitted effect event; it never existed on the raw
|
||||
// InformationalEvent signal stream, so the docs-promised "On Event with
|
||||
// enrollment_complete" trigger was dead under the previous SignalPosted feed.
|
||||
BuildHost(Rule("GATHER-HOST-002", "on_event", triggerEventType: "enrollment_complete"));
|
||||
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseFeed_DedupsConsecutiveSamePhaseDeclarations()
|
||||
{
|
||||
// "Any phase" phase_change rule fires on every phase TRANSITION — a re-declared
|
||||
// identical phase (e.g. duplicate esp_phase_changed) must not count as one.
|
||||
BuildHost(Rule("GATHER-HOST-003", "phase_change", triggerPhase: ""));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.DeviceSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, _sink.CountOf("gather_host_test"));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
|
||||
Assert.True(WaitForCount("gather_host_test", 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhaseExitRule_FiresWhenEmittedTimelineLeavesThePhase()
|
||||
{
|
||||
BuildHost(Rule("GATHER-HOST-004", "phase_exit", triggerPhase: "AccountSetup"));
|
||||
|
||||
_stream.Publish("esp_phase_changed", EnrollmentPhase.AccountSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
|
||||
_stream.Publish("phase_transition", EnrollmentPhase.FinalizingSetup);
|
||||
Assert.True(WaitForCount("gather_host_test", 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoppedHost_NoLongerReactsToTheStream()
|
||||
{
|
||||
BuildHost(Rule("GATHER-HOST-005", "on_event", triggerEventType: "enrollment_complete"));
|
||||
|
||||
_host!.Stop();
|
||||
_stream.Publish("enrollment_complete", EnrollmentPhase.Unknown);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, _sink.CountOf("gather_host_test"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -66,6 +66,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
// same facts signal (safe default "false" off-device).
|
||||
var selfDeploying = payload["isSelfDeployingProfile"];
|
||||
Assert.True(selfDeploying == "true" || selfDeploying == "false");
|
||||
// W365 — the Cloud PC marker fact rides the same signal (safe default "false"
|
||||
// off-device; a build host is not a Cloud PC).
|
||||
Assert.Contains("isCloudPc", (IDictionary<string, string>)payload);
|
||||
var cloudPc = payload["isCloudPc"];
|
||||
Assert.True(cloudPc == "true" || cloudPc == "false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the marker-AND contract of <see cref="CloudPcDetector.ResolveIsCloudPc"/>: BOTH
|
||||
/// the Windows365 registry key and the CloudManagedDesktopExtension service key must be
|
||||
/// present — each alone has plausible look-alikes (W365-Boot physical clients carry
|
||||
/// Windows365 policy state; the MCMD agent family served other managed-desktop
|
||||
/// offerings). Mirrors the bootstrap script's field-verified <c>Test-IsCloudPc</c>.
|
||||
/// </summary>
|
||||
public sealed class CloudPcDetectorTests
|
||||
{
|
||||
private static Func<string, bool> Probe(params string[] existingKeys)
|
||||
{
|
||||
var set = new HashSet<string>(existingKeys, StringComparer.OrdinalIgnoreCase);
|
||||
return path => set.Contains(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_markers_present_resolves_true()
|
||||
{
|
||||
Assert.True(CloudPcDetector.ResolveIsCloudPc(Probe(
|
||||
CloudPcDetector.Windows365MarkerKey,
|
||||
CloudPcDetector.CmdeServiceKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows365_key_alone_resolves_false()
|
||||
{
|
||||
// W365-Boot physical client shape: policy state without the on-device MCMD agent.
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.Windows365MarkerKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cmde_service_alone_resolves_false()
|
||||
{
|
||||
// MCMD agent family also served other managed-desktop offerings.
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.CmdeServiceKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_markers_resolves_false()
|
||||
{
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_probe_resolves_false()
|
||||
{
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throwing_probe_degrades_to_false()
|
||||
{
|
||||
// SKIP-safe contract: a broken registry read must never classify, let alone throw.
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(_ => throw new UnauthorizedAccessException("registry denied")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectIsCloudPc_never_throws_on_build_host()
|
||||
{
|
||||
// Real-registry shell: exercises the exception-swallowing wrapper end to end.
|
||||
// A build host is not a Cloud PC, but the contract under test is "no throw",
|
||||
// not the detected value.
|
||||
var ex = Record.Exception(() => CloudPcDetector.DetectIsCloudPc());
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -137,6 +137,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(Environment.MachineName, captured.DeviceName);
|
||||
Assert.False(string.IsNullOrEmpty(captured.EnrollmentType));
|
||||
Assert.True(captured.IsUserDriven);
|
||||
// W365 marker rides the registration body. A build host is not a Cloud PC
|
||||
// (marker-AND: Windows365 key + CloudManagedDesktopExtension service), so the
|
||||
// detector's SKIP-safe default is what must land here.
|
||||
Assert.False(captured.IsCloudPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+28
-5
@@ -83,13 +83,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddDays(-7);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
// Line-grounded path: the [DO TEL] line's own timestamp travels with the callback.
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddDays(-7));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddMinutes(-1);
|
||||
adapter.TriggerDoTelemetryFromTest(app);
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
adapter.TriggerDoTelemetryFromTest(app, ClockNow.AddMinutes(-1));
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow.AddMinutes(-1), info.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Poll_driven_do_telemetry_stamps_clock_ignoring_unrelated_last_matched_line()
|
||||
{
|
||||
// Session df1fcf47: the DO collector observed the completion live, but the event
|
||||
// inherited LastMatchedLogTimestamp from an unrelated, tz-skewed log line 8h in
|
||||
// the past — which dragged Sessions.StartedAt back and inflated the duration.
|
||||
// Poll-driven telemetry (null source timestamp) must stamp the clock, never the
|
||||
// last matched line.
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-do-poll", 0);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddHours(-8);
|
||||
adapter.TriggerDoTelemetryFromTest(app, sourceTimestampUtc: null);
|
||||
|
||||
var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry));
|
||||
Assert.Equal(ClockNow, info.OccurredAtUtc);
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
// Not a replay — live observation must never trip the replay suppression,
|
||||
// even with a stale unrelated line on the tracker.
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.HistoricImeReplayDetected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
-3
@@ -647,13 +647,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
int priorDoCalls = 0;
|
||||
int priorScriptCalls = 0;
|
||||
f.Tracker.OnImeAgentVersion = _ => priorVersionCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = _ => priorDoCalls++;
|
||||
f.Tracker.OnDoTelemetryReceived = (_, _) => priorDoCalls++;
|
||||
f.Tracker.OnScriptCompleted = _ => priorScriptCalls++;
|
||||
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.OnImeAgentVersion("1.0.0.0");
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0));
|
||||
f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0), null);
|
||||
f.Tracker.OnScriptCompleted(new ScriptExecutionState { PolicyId = "p", ScriptType = "platform" });
|
||||
|
||||
Assert.Equal(1, priorVersionCalls);
|
||||
@@ -667,7 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
Action<string> priorVersion = _ => { };
|
||||
Action<AppPackageState> priorDo = _ => { };
|
||||
Action<AppPackageState, DateTime?> priorDo = (_, _) => { };
|
||||
Action<ScriptExecutionState> priorScript = _ => { };
|
||||
f.Tracker.OnImeAgentVersion = priorVersion;
|
||||
f.Tracker.OnDoTelemetryReceived = priorDo;
|
||||
|
||||
+52
@@ -153,6 +153,58 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
Assert.Equal("Device Setup", (string?)parsed["PhaseName"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emit_publishes_eventType_and_parsed_phase_to_TimelineEventStream()
|
||||
{
|
||||
// Post-reduce observer surface: gather-rule phase/event triggers subscribe here so
|
||||
// they fire in step with what the timeline shows (session 32312a32 regression).
|
||||
using var r = new Rig();
|
||||
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
|
||||
var published = new List<(string EventType, EnrollmentPhase Phase)>();
|
||||
stream.EventEmitted += (eventType, phase) => published.Add((eventType, phase));
|
||||
var sut = new EventTimelineEmitter(r.Inner, stream);
|
||||
|
||||
sut.Emit(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = "phase_transition",
|
||||
["phase"] = "FinalizingSetup",
|
||||
},
|
||||
State(),
|
||||
At);
|
||||
sut.Emit(
|
||||
new Dictionary<string, string> { ["eventType"] = "enrollment_complete" },
|
||||
State(),
|
||||
At);
|
||||
|
||||
Assert.Equal(2, published.Count);
|
||||
Assert.Equal(("phase_transition", EnrollmentPhase.FinalizingSetup), published[0]);
|
||||
Assert.Equal(("enrollment_complete", EnrollmentPhase.Unknown), published[1]);
|
||||
// Publish happens AFTER the transport enqueue — wire ordering is authoritative.
|
||||
Assert.Equal(2, r.Transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimelineEventStream_subscriber_exception_does_not_break_the_emit_path()
|
||||
{
|
||||
using var r = new Rig();
|
||||
var stream = new AutopilotMonitor.Agent.V2.Core.Orchestration.TimelineEventStream();
|
||||
var secondSubscriberCalls = 0;
|
||||
stream.EventEmitted += (_, _) => throw new InvalidOperationException("subscriber bug");
|
||||
stream.EventEmitted += (_, _) => secondSubscriberCalls++;
|
||||
var sut = new EventTimelineEmitter(r.Inner, stream);
|
||||
|
||||
sut.Emit(
|
||||
new Dictionary<string, string> { ["eventType"] = "enrollment_complete" },
|
||||
State(),
|
||||
At);
|
||||
|
||||
// Emit neither threw (would surface as an EffectRunner retry = duplicated event)
|
||||
// nor skipped the remaining subscribers.
|
||||
Assert.Equal(1, r.Transport.EnqueueCount);
|
||||
Assert.Equal(1, secondSubscriberCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Phase_parameter_with_invalid_value_falls_back_to_Unknown()
|
||||
{
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
|
||||
$"http={bytesFromHttp}, cache={bytesFromCacheServer}, mode={downloadMode}, duration={downloadDuration}");
|
||||
|
||||
OnDoTelemetryReceived?.Invoke(pkg);
|
||||
OnDoTelemetryReceived?.Invoke(pkg, LastMatchedLogTimestamp);
|
||||
_stateDirty = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
@@ -194,7 +194,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
public Action<AppPackageState> OnDoTelemetryReceived { 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
|
||||
// line's LastMatchedLogTimestamp put a tz-skewed time on the event (session df1fcf47).
|
||||
public Action<AppPackageState, DateTime?> OnDoTelemetryReceived { get; set; }
|
||||
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
|
||||
@@ -56,6 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
private DateTime? _lastReportTime;
|
||||
private int _reportCount;
|
||||
|
||||
// Local W365 marker verdict, captured once at construction (same UNVERIFIED contract
|
||||
// as the hardware fields). Optional ctor arg so test fakes stay source-compatible.
|
||||
private readonly bool _isCloudPc;
|
||||
|
||||
public DistressReporter(
|
||||
string baseUrl,
|
||||
string tenantId,
|
||||
@@ -64,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
string serialNumber,
|
||||
string agentVersion,
|
||||
AgentLogger logger,
|
||||
X509Certificate2 clientCertificate = null)
|
||||
X509Certificate2 clientCertificate = null,
|
||||
bool isCloudPc = false)
|
||||
{
|
||||
_tenantId = tenantId;
|
||||
_manufacturer = manufacturer;
|
||||
@@ -72,6 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
_serialNumber = serialNumber;
|
||||
_agentVersion = agentVersion;
|
||||
_logger = logger;
|
||||
_isCloudPc = isCloudPc;
|
||||
|
||||
// Capture cert context. Each field is independently nullable so the backend can tell
|
||||
// "agent had no cert at all" (NoCertInStore + all-null fields) from "agent had a cert
|
||||
@@ -169,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = Truncate(message, 256),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IsCloudPc = _isCloudPc,
|
||||
CertSourceState = _capturedCertState,
|
||||
CertThumbprint = _capturedCertThumbprint,
|
||||
CertSubject = Truncate(_capturedCertSubject, 96),
|
||||
|
||||
@@ -97,7 +97,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool)
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId));
|
||||
@@ -471,8 +472,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
if (officeHost != null) hosts.Add(officeHost);
|
||||
|
||||
// M4.6.δ — Gather-rules runtime executor. Runs "startup" rules once the agent is up
|
||||
// and "interval" rules on their timers; "phase_change" / "on_event" rules are driven
|
||||
// by the host's SignalIngress.SignalPosted subscription (MON-A1).
|
||||
// and "interval" rules on their timers; "phase_change" / "phase_exit" / "on_event"
|
||||
// rules are driven by the post-reduce TimelineEventStream (emitted timeline events),
|
||||
// NOT the raw SignalPosted stream — the raw phase can run minutes ahead of the
|
||||
// engine-declared one (RealmJoin gate, session 32312a32). See TimelineEventStream doc.
|
||||
if (_remoteConfig.GatherRules != null && _remoteConfig.GatherRules.Count > 0)
|
||||
{
|
||||
hosts.Add(new GatherRuleExecutorHost(
|
||||
@@ -484,7 +487,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
rules: _remoteConfig.GatherRules,
|
||||
imeLogPathOverride: _agentConfig.ImeLogPathOverride,
|
||||
unrestrictedMode: _agentConfig.UnrestrictedMode,
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath));
|
||||
gatherDebugLogPath: _agentConfig.GatherRuleDebugLogPath,
|
||||
timelineEvents: timelineEvents));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_agentConfig.GatherRuleDebugLogPath))
|
||||
{
|
||||
|
||||
@@ -107,6 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private TelemetrySignalEmitter? _signalEmitter;
|
||||
private TelemetryTransitionEmitter? _transitionEmitter;
|
||||
private EventTimelineEmitter? _timelineEmitter;
|
||||
private TimelineEventStream? _timelineEvents;
|
||||
private BackPressureEventObserver? _backPressureObserver;
|
||||
private IDeadlineScheduler? _scheduler;
|
||||
private ClassifierRegistry? _classifierRegistry;
|
||||
@@ -413,7 +414,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_transport, _eventSequenceCounter, _sessionId, _tenantId, _traceEventsEnabled);
|
||||
_signalEmitter = new TelemetrySignalEmitter(_transport, _sessionId, _tenantId);
|
||||
_transitionEmitter = new TelemetryTransitionEmitter(_transport, _sessionId, _tenantId);
|
||||
_timelineEmitter = new EventTimelineEmitter(eventEmitter);
|
||||
// Post-reduce timeline observer: EventTimelineEmitter publishes every emitted event's
|
||||
// (eventType, phase) here. Fed to the collector-host factory at step 14 so gather-rule
|
||||
// phase_change/on_event triggers key off the ENGINE-REDUCED timeline (RealmJoin gate
|
||||
// et al. respected) instead of raw pre-reduce signals — see TimelineEventStream doc.
|
||||
_timelineEvents = new TimelineEventStream();
|
||||
_timelineEmitter = new EventTimelineEmitter(eventEmitter, _timelineEvents);
|
||||
_backPressureObserver = new BackPressureEventObserver(eventEmitter, _clock);
|
||||
|
||||
// 5) Deadlines + Classifiers.
|
||||
@@ -653,7 +659,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
var surfaces = _componentFactory.CreateCollectorHosts(
|
||||
_sessionId, _tenantId, _logger, _whiteGloveSealingPatternIds,
|
||||
ingress: _ingress, clock: _clock, telemetrySpool: _spool);
|
||||
ingress: _ingress, clock: _clock, telemetrySpool: _spool,
|
||||
timelineEvents: _timelineEvents);
|
||||
CollectorSurfaces = surfaces;
|
||||
_collectorHosts = surfaces.Hosts;
|
||||
foreach (var host in _collectorHosts)
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
getPackageStates: () => imeHost.PackageStates,
|
||||
onDoTelemetryReceived: pkg =>
|
||||
{
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); }
|
||||
// null source timestamp: poll-driven completion, no log line backs it —
|
||||
// the adapter stamps the clock instead of LastMatchedLogTimestamp.
|
||||
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg, null); }
|
||||
catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); }
|
||||
},
|
||||
logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
|
||||
|
||||
+44
-40
@@ -4,7 +4,6 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
@@ -19,17 +18,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly bool _unrestrictedMode;
|
||||
private int _disposed;
|
||||
|
||||
// MON-A1: drive phase_change / on_event gather triggers from the central signal stream.
|
||||
// V1 fired these from MonitoringService on every EnrollmentEvent (OnPhaseChanged(evt.Phase)
|
||||
// on phase change + OnEvent(evt.EventType) per event); the V2 host previously only called
|
||||
// UpdateRules, so 5 of 10 shipped rules (incl. the only enabled one, dsregcmd at
|
||||
// FinalizingSetup) silently never fired. In V2 every event is an InformationalEvent signal
|
||||
// carrying eventType + (optional) phase in its payload. Null on test fakes / non-SignalIngress
|
||||
// sinks — then signal-triggers degrade off (startup + interval rules still run).
|
||||
private readonly SignalIngress? _observableIngress;
|
||||
private Action<DecisionSignalKind, IReadOnlyDictionary<string, string>?>? _signalPostedHandler;
|
||||
// MON-A1 (revised): drive phase_change / phase_exit / on_event gather triggers from the
|
||||
// POST-REDUCE emitted timeline (TimelineEventStream), not from the raw signal stream.
|
||||
// V1 fired these from MonitoringService on every emitted EnrollmentEvent; the first V2
|
||||
// wiring subscribed to SignalIngress.SignalPosted instead, which fires at enqueue time
|
||||
// with the RAW collector payload — before the reducer has applied gates like the
|
||||
// RealmJoin completion gate. Consequence (session 32312a32, rsneuffen.de): a phase_change
|
||||
// rule on FinalizingSetup fired at the raw EspPhaseChanged(FinalizingSetup) signal (ESP
|
||||
// exit / Hello wizard), 7 minutes before the engine declared phase_transition(FinalizingSetup)
|
||||
// on the timeline — and before the RealmJoin package wrote the registry key the rule was
|
||||
// built to read. The raw feed also never carried engine-emitted event types at all, so
|
||||
// on_event rules on enrollment_complete (documented) could never fire. The emitted-event
|
||||
// feed restores V1 semantics and matches what the UI timeline shows ("collect once when
|
||||
// the enrollment reaches this phase"). Null on test fakes / standalone configurations —
|
||||
// then phase/event triggers degrade off (startup + interval rules still run).
|
||||
private readonly TimelineEventStream? _timelineEvents;
|
||||
private Action<string, EnrollmentPhase>? _timelineHandler;
|
||||
private readonly object _sync = new object();
|
||||
private string? _lastPhaseName;
|
||||
private EnrollmentPhase _lastPhase = EnrollmentPhase.Unknown;
|
||||
|
||||
public GatherRuleExecutorHost(
|
||||
string sessionId,
|
||||
@@ -40,7 +46,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
List<GatherRule> rules,
|
||||
string? imeLogPathOverride,
|
||||
bool unrestrictedMode = false,
|
||||
string? gatherDebugLogPath = null)
|
||||
string? gatherDebugLogPath = null,
|
||||
TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -60,7 +67,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
|
||||
sessionId, tenantId, evt => post.Emit(evt), logger, imeLogPathOverride,
|
||||
debugLogPath: gatherDebugLogPath);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
_timelineEvents = timelineEvents;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -71,46 +78,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_executor.UnrestrictedMode = _unrestrictedMode;
|
||||
_executor.UpdateRules(_rules);
|
||||
|
||||
// MON-A1: observe the central signal stream so phase_change / on_event rules fire.
|
||||
if (_observableIngress != null && _signalPostedHandler == null)
|
||||
// MON-A1 (revised): observe the emitted timeline so phase/event triggers fire in
|
||||
// step with what the timeline actually shows.
|
||||
if (_timelineEvents != null && _timelineHandler == null)
|
||||
{
|
||||
_signalPostedHandler = OnSignalPosted;
|
||||
_observableIngress.SignalPosted += _signalPostedHandler;
|
||||
_timelineHandler = OnTimelineEventEmitted;
|
||||
_timelineEvents.EventEmitted += _timelineHandler;
|
||||
}
|
||||
|
||||
_logger.Info(
|
||||
$"GatherRuleExecutorHost: started with {_rules.Count} rule(s), unrestrictedMode={_unrestrictedMode}, signalTriggers={(_observableIngress != null)}.");
|
||||
$"GatherRuleExecutorHost: started with {_rules.Count} rule(s), unrestrictedMode={_unrestrictedMode}, timelineTriggers={(_timelineEvents != null)}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translates posted signals into the executor's phase_change / on_event triggers (MON-A1).
|
||||
/// Phase: fire <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnPhaseChanged"/> when
|
||||
/// the observed phase changes and parses to an <see cref="EnrollmentPhase"/> (raw collector
|
||||
/// phase strings that aren't enum names are ignored — they carry no gather-rule meaning).
|
||||
/// Event: fire <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnEvent"/> for every
|
||||
/// InformationalEvent's eventType. The executor dispatches rule execution on the ThreadPool,
|
||||
/// so this stays off the signal-posting hot path; it also dedups phase rules per (rule, phase).
|
||||
/// Translates emitted timeline events into the executor's triggers. Phase: fire
|
||||
/// <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnPhaseChanged"/> when a
|
||||
/// phase-declaration event (Phase != Unknown) moves the timeline to a new phase — this
|
||||
/// is by construction the engine-reduced phase, so deferred transitions (RealmJoin gate)
|
||||
/// fire the rules exactly when the timeline shows them. Event: fire
|
||||
/// <see cref="Monitoring.Telemetry.Gather.GatherRuleExecutor.OnEvent"/> for every emitted
|
||||
/// event's type — including engine-emitted types (enrollment_complete, phase_transition,
|
||||
/// realmjoin_resolved, …) that never existed on the raw signal stream. The executor
|
||||
/// dispatches rule execution on the ThreadPool, so this stays off the ingress worker's
|
||||
/// effect path; it also dedups phase rules per (rule, phase).
|
||||
/// </summary>
|
||||
private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
private void OnTimelineEventEmitted(string eventType, EnrollmentPhase phase)
|
||||
{
|
||||
if (payload == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (payload.TryGetValue(SignalPayloadKeys.EspPhase, out var phaseName)
|
||||
&& !string.IsNullOrEmpty(phaseName)
|
||||
&& !string.Equals(phaseName, _lastPhaseName, StringComparison.OrdinalIgnoreCase)
|
||||
&& Enum.TryParse<EnrollmentPhase>(phaseName, ignoreCase: true, out var phase))
|
||||
if (phase != EnrollmentPhase.Unknown && phase != _lastPhase)
|
||||
{
|
||||
_lastPhaseName = phaseName;
|
||||
_lastPhase = phase;
|
||||
_executor.OnPhaseChanged(phase);
|
||||
}
|
||||
|
||||
if (kind == DecisionSignalKind.InformationalEvent
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
&& !string.IsNullOrEmpty(eventType))
|
||||
if (!string.IsNullOrEmpty(eventType))
|
||||
{
|
||||
_executor.OnEvent(eventType);
|
||||
}
|
||||
@@ -118,17 +122,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Verbose($"GatherRuleExecutorHost: signal-trigger dispatch failed: {ex.Message}");
|
||||
_logger.Verbose($"GatherRuleExecutorHost: timeline-trigger dispatch failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_observableIngress != null && _signalPostedHandler != null)
|
||||
if (_timelineEvents != null && _timelineHandler != null)
|
||||
{
|
||||
try { _observableIngress.SignalPosted -= _signalPostedHandler; }
|
||||
try { _timelineEvents.EventEmitted -= _timelineHandler; }
|
||||
catch { /* best-effort unsubscribe during shutdown */ }
|
||||
_signalPostedHandler = null;
|
||||
_timelineHandler = null;
|
||||
}
|
||||
// GatherRuleExecutor is IDisposable; no explicit Stop beyond unsubscribe. Rely on Dispose.
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <c>spool.pendingItemCount</c> / <c>spool.fileSizeBytes</c>. Nullable — fakes and
|
||||
/// spool-less configurations pass null and the metrics fields are simply absent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <paramref name="timelineEvents"/> flows into the <c>GatherRuleExecutorHost</c> so
|
||||
/// <c>phase_change</c> / <c>phase_exit</c> / <c>on_event</c> gather triggers key off the
|
||||
/// ENGINE-REDUCED emitted timeline (post-reduce, RealmJoin gate respected) instead of raw
|
||||
/// pre-reduce signals — see <see cref="TimelineEventStream"/>. Nullable — fakes pass null
|
||||
/// and the host's phase/event triggers degrade off (startup + interval rules still run).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
@@ -47,6 +54,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool);
|
||||
Transport.Telemetry.ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Post-reduce observer surface for emitted timeline events: <see cref="Telemetry.Events.EventTimelineEmitter"/>
|
||||
/// publishes every <see cref="EnrollmentEvent"/> it emits as an <c>(eventType, phase)</c> pair,
|
||||
/// AFTER the decision engine has reduced the originating signal and its effects have been
|
||||
/// executed.
|
||||
/// <para>
|
||||
/// This is deliberately different from <see cref="SignalIngress.SignalPosted"/>, which fires
|
||||
/// at enqueue time on the producer thread and therefore only ever carries the RAW collector
|
||||
/// payload — before the reducer has applied gates like the RealmJoin completion gate that can
|
||||
/// defer or suppress a phase transition. Consumers that must agree with what the timeline
|
||||
/// actually shows (the gather-rule phase/event triggers) subscribe here, not to the raw
|
||||
/// signal stream. See session 32312a32: a <c>phase_change</c> gather rule on FinalizingSetup
|
||||
/// fired at the raw <c>EspPhaseChanged(FinalizingSetup)</c> signal (ESP exit), 7 minutes
|
||||
/// before the engine — deferred behind the RealmJoin gate — actually declared
|
||||
/// <c>phase_transition(FinalizingSetup)</c> on the timeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Threading</b>: <see cref="Publish"/> runs on the ingress worker thread (the effect
|
||||
/// execution path). Subscribers must stay fast and must not block; subscriber exceptions are
|
||||
/// swallowed per handler so the emit path can never throw (an exception here would surface
|
||||
/// as an EffectRunner transient retry and duplicate the emitted event).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TimelineEventStream
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised once per emitted timeline event with its <see cref="EnrollmentEvent.EventType"/>
|
||||
/// and parsed <see cref="EnrollmentEvent.Phase"/> (<see cref="EnrollmentPhase.Unknown"/>
|
||||
/// for the vast majority of events — only phase-declaration events carry a real phase,
|
||||
/// which is exactly the set the UI timeline groups on).
|
||||
/// </summary>
|
||||
public event Action<string, EnrollmentPhase>? EventEmitted;
|
||||
|
||||
internal void Publish(string eventType, EnrollmentPhase phase)
|
||||
{
|
||||
var handler = EventEmitted;
|
||||
if (handler == null) return;
|
||||
|
||||
foreach (Action<string, EnrollmentPhase> subscriber in handler.GetInvocationList())
|
||||
{
|
||||
try { subscriber(eventType, phase); }
|
||||
catch
|
||||
{
|
||||
// Subscriber isolation — see class doc: the emit path must never throw.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects whether the agent runs on a Windows 365 Cloud PC, using the same marker
|
||||
/// evidence the bootstrap script's <c>Test-IsCloudPc</c> settled on after the first
|
||||
/// W365 field test (2026-08-06) disproved WMI-model matching (a Cloud PC reports only
|
||||
/// Manufacturer='Microsoft Corporation' + Model='Virtual Machine' locally — identical
|
||||
/// to any Hyper-V VM):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>HKLM\SOFTWARE\Microsoft\Windows365</c> exists, AND</item>
|
||||
/// <item>the <c>CloudManagedDesktopExtension</c> service (Microsoft Cloud Managed
|
||||
/// Desktop Extension, the W365 management agent that runs ON Cloud PCs) is
|
||||
/// installed — probed via its <c>HKLM\SYSTEM\CurrentControlSet\Services</c> key,
|
||||
/// so the agent needs no System.ServiceProcess dependency.</item>
|
||||
/// </list>
|
||||
/// BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
|
||||
/// physical clients may carry Windows365 policy state; the MCMD agent family also
|
||||
/// served other managed-desktop offerings).
|
||||
/// <para>
|
||||
/// Display/context metadata only — the flag rides on <c>SessionRegistration</c> and the
|
||||
/// <c>EnrollmentFactsObserved</c> signal. It is NEVER an auth input: backend device
|
||||
/// validation trusts exclusively the cert-CN-bound Graph lookup
|
||||
/// (<c>CloudPcDeviceValidator</c>), not this locally-derived value.
|
||||
/// Exception-swallowing, false on any error — mirrors the SKIP-safe bootstrap contract
|
||||
/// and the <see cref="EnrollmentRegistryDetector"/> degradation rules.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CloudPcDetector
|
||||
{
|
||||
internal const string Windows365MarkerKey = @"SOFTWARE\Microsoft\Windows365";
|
||||
internal const string CmdeServiceKey = @"SYSTEM\CurrentControlSet\Services\CloudManagedDesktopExtension";
|
||||
|
||||
public static bool DetectIsCloudPc()
|
||||
{
|
||||
try
|
||||
{
|
||||
return ResolveIsCloudPc(SubKeyExists);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision logic for <see cref="DetectIsCloudPc"/>. Exposed internally so the
|
||||
/// marker-AND semantics can be exercised without a real registry (same seam contract
|
||||
/// as <see cref="EnrollmentRegistryDetector.ResolveHybridJoinFromValues"/>).
|
||||
/// <paramref name="subKeyExists"/> receives an HKLM-relative subkey path and reports
|
||||
/// whether it exists; a throwing probe degrades to <c>false</c>.
|
||||
/// </summary>
|
||||
internal static bool ResolveIsCloudPc(Func<string, bool> subKeyExists)
|
||||
{
|
||||
if (subKeyExists == null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
return subKeyExists(Windows365MarkerKey) && subKeyExists(CmdeServiceKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SubKeyExists(string subKeyPath)
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(subKeyPath))
|
||||
{
|
||||
return key != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(),
|
||||
IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(),
|
||||
IsSelfDeployingProfile = EnrollmentRegistryDetector.DetectSelfDeployingProfile(),
|
||||
IsCloudPc = CloudPcDetector.DetectIsCloudPc(),
|
||||
// Deliberately stays true even for self-deploying profiles: the WhiteGlove
|
||||
// Part1/Part2 merge paths and existing dashboards key on IsUserDriven;
|
||||
// IsSelfDeployingProfile is the additive classification signal.
|
||||
|
||||
@@ -72,7 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevOnAppStateChanged;
|
||||
private readonly Action<string>? _prevOnPatternMatched;
|
||||
private readonly Action<string>? _prevOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
|
||||
|
||||
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
|
||||
private readonly Action<string> _ourOnPatternMatched;
|
||||
private readonly Action<string> _ourOnImeAgentVersion;
|
||||
private readonly Action<AppPackageState> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
|
||||
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
|
||||
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
|
||||
|
||||
@@ -242,10 +242,10 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitImeAgentVersion(version);
|
||||
}
|
||||
|
||||
private void OnDoTelemetryReceived(AppPackageState app)
|
||||
private void OnDoTelemetryReceived(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
_prevOnDoTelemetryReceived?.Invoke(app);
|
||||
EmitDoTelemetry(app);
|
||||
_prevOnDoTelemetryReceived?.Invoke(app, sourceTimestampUtc);
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
}
|
||||
|
||||
private void OnScriptCompleted(ScriptExecutionState script)
|
||||
@@ -266,7 +266,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
EmitAppState(app, oldState, newState);
|
||||
internal void TriggerPatternMatchedFromTest(string patternId) => MaybeEmitWhiteGloveSealingPattern(patternId);
|
||||
internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app) => EmitDoTelemetry(app);
|
||||
internal void TriggerDoTelemetryFromTest(AppPackageState app, DateTime? sourceTimestampUtc = null) =>
|
||||
EmitDoTelemetry(app, sourceTimestampUtc);
|
||||
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
|
||||
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
|
||||
|
||||
@@ -876,7 +877,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
|
||||
}
|
||||
|
||||
private void EmitDoTelemetry(AppPackageState app)
|
||||
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
|
||||
{
|
||||
if (app == null || string.IsNullOrEmpty(app.Id)) return;
|
||||
|
||||
@@ -912,11 +913,31 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
|
||||
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
|
||||
// Two trigger paths with different time grounding (session df1fcf47: a single
|
||||
// tz-skewed unrelated log line inherited via LastMatchedLogTimestamp dragged the
|
||||
// session StartedAt back 7h and inflated the reported duration):
|
||||
// - [DO TEL] log line (sourceTimestampUtc set): the line's own timestamp is the
|
||||
// event time — resolve/clamp it and apply historic-replay suppression.
|
||||
// - DO collector poll (sourceTimestampUtc null): the completion was observed live
|
||||
// via Get-DeliveryOptimizationStatus; no log line is involved, so the clock is
|
||||
// the only honest source. LastMatchedLogTimestamp must NOT be consulted here.
|
||||
DateTime now;
|
||||
bool derivedFromClock;
|
||||
DateTime? rawSourceTs;
|
||||
if (sourceTimestampUtc.HasValue)
|
||||
{
|
||||
now = ResolveOccurredAt(sourceTimestampUtc, out derivedFromClock, out rawSourceTs);
|
||||
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
// Historic replay — a week-old DO summary is not this enrollment's download.
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, SharedEventTypes.DoTelemetry, app.Id))
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
now = _clock.UtcNow;
|
||||
derivedFromClock = true;
|
||||
rawSourceTs = null;
|
||||
}
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
|
||||
|
||||
@@ -81,10 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
internal const string ReasonParamKey = "reason";
|
||||
|
||||
private readonly TelemetryEventEmitter _emitter;
|
||||
private readonly TimelineEventStream? _timelineEvents;
|
||||
|
||||
public EventTimelineEmitter(TelemetryEventEmitter emitter)
|
||||
public EventTimelineEmitter(TelemetryEventEmitter emitter, TimelineEventStream? timelineEvents = null)
|
||||
{
|
||||
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
|
||||
_timelineEvents = timelineEvents;
|
||||
}
|
||||
|
||||
public void Emit(
|
||||
@@ -142,6 +144,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
};
|
||||
|
||||
_emitter.Emit(evt);
|
||||
|
||||
// Post-reduce observer surface: published AFTER the transport enqueue so subscribers
|
||||
// (gather-rule phase/event triggers) see exactly the ordering that reaches the wire.
|
||||
// Publish never throws (subscriber isolation inside the stream).
|
||||
_timelineEvents?.Publish(evt.EventType, evt.Phase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -102,7 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
serialNumber: hardware.SerialNumber,
|
||||
agentVersion: agentVersion,
|
||||
logger: logger,
|
||||
clientCertificate: clientCertificate);
|
||||
clientCertificate: clientCertificate,
|
||||
isCloudPc: CloudPcDetector.DetectIsCloudPc());
|
||||
|
||||
var emergencyReporter = new EmergencyReporter(
|
||||
apiClient: backendApiClient,
|
||||
|
||||
@@ -309,12 +309,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
|
||||
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
|
||||
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
|
||||
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
|
||||
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["enrollmentType"] = enrollmentType,
|
||||
["isHybridJoin"] = isHybridJoin ? "true" : "false",
|
||||
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
|
||||
["isCloudPc"] = isCloudPc ? "true" : "false",
|
||||
};
|
||||
|
||||
var evidence = new Evidence(
|
||||
@@ -329,7 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
evidence: evidence,
|
||||
payload: payload);
|
||||
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}).");
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// W365 — reducer-level coverage for the Cloud PC marker fact riding
|
||||
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/>: the observation records BOTH
|
||||
/// values set-once (mirror of the registry self-deploying fact), the profile reason
|
||||
/// carries the "no Device-ESP phase expected" expectation, and the signal census folds the
|
||||
/// positive marker into terminal audit trails. The engine deliberately has no DeviceSetup
|
||||
/// arrival requirement, so no behavioural gate is added — the observation is the
|
||||
/// engine-side context that a session starting at Account Setup is the expected shape.
|
||||
/// </summary>
|
||||
public sealed class CloudPcObservationTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null) =>
|
||||
new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
|
||||
payload: payload);
|
||||
|
||||
private static DecisionState ReduceFacts(DecisionEngine engine, DecisionState state, long ordinal, string isCloudPc)
|
||||
=> engine.Reduce(state, MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, T0.AddSeconds(ordinal),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsCloudPc] = isCloudPc,
|
||||
})).NewState;
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_true_recordsObservation_andExpectationReason()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = engine.Reduce(
|
||||
DecisionState.CreateInitial("cpc-1", "t", T0),
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
state = ReduceFacts(engine, state, 1, "true");
|
||||
|
||||
Assert.NotNull(state.ScenarioObservations.CloudPc);
|
||||
Assert.True(state.ScenarioObservations.CloudPc!.Value);
|
||||
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
|
||||
Assert.Equal(
|
||||
EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason,
|
||||
state.ScenarioProfile.Reason);
|
||||
// Stage-agnostic fact signal — the reducer must not move the session anywhere.
|
||||
Assert.Equal(SessionStage.SessionStarted, state.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_false_recordsObservation_setOnce()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = engine.Reduce(
|
||||
DecisionState.CreateInitial("cpc-2", "t", T0),
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
state = ReduceFacts(engine, state, 1, "false");
|
||||
Assert.NotNull(state.ScenarioObservations.CloudPc);
|
||||
Assert.False(state.ScenarioObservations.CloudPc!.Value);
|
||||
|
||||
// Set-once: a later contradicting re-read must not overwrite the first sighting
|
||||
// (same contract as RegistrySelfDeployingProfile — replay-deterministic evidence).
|
||||
state = ReduceFacts(engine, state, 2, "true");
|
||||
Assert.False(state.ScenarioObservations.CloudPc!.Value);
|
||||
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_foldsPositiveMarker_only()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var initial = engine.Reduce(
|
||||
DecisionState.CreateInitial("cpc-3", "t", T0),
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
var cloudPcState = ReduceFacts(engine, initial, 1, "true");
|
||||
var census = DecisionStateSignalCensus.Build(cloudPcState);
|
||||
Assert.Contains("cloud_pc_marker", census.SignalsSeen);
|
||||
Assert.True(census.SignalEvidence.ContainsKey("cloudPcMarker"));
|
||||
|
||||
var physicalState = ReduceFacts(engine, initial, 1, "false");
|
||||
var physicalCensus = DecisionStateSignalCensus.Build(physicalState);
|
||||
Assert.DoesNotContain("cloud_pc_marker", physicalCensus.SignalsSeen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,7 +490,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3)
|
||||
.WithSkipUserEsp(value: true, sourceSignalOrdinal: 4)
|
||||
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
|
||||
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6))
|
||||
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)
|
||||
.WithCloudPc(value: true, sourceSignalOrdinal: 7))
|
||||
.Build();
|
||||
|
||||
var json = StateSerializer.Serialize(state);
|
||||
@@ -504,6 +505,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.Equal(5, o.EspSyncFailureTimeoutMinutes!.SourceSignalOrdinal);
|
||||
Assert.False(o.RegistrySelfDeployingProfile!.Value);
|
||||
Assert.Equal(6, o.RegistrySelfDeployingProfile!.SourceSignalOrdinal);
|
||||
Assert.True(o.CloudPc!.Value);
|
||||
Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal);
|
||||
|
||||
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
|
||||
Assert.Null(o.WhiteGloveSealingPatternSeen);
|
||||
|
||||
+93
@@ -515,6 +515,99 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
|
||||
// ================================================================== helpers
|
||||
|
||||
// ------------------------------------------------ isCloudPc annotation (W365)
|
||||
// Cloud PC first connect: Device-ESP already ran headless at provisioning, so the
|
||||
// profile records the "no Device-ESP phase expected" expectation as the reason.
|
||||
// Reason-only by design — Mode is classified by AccountSetup/IME signals (Classic),
|
||||
// and EspConfig keeps the CONFIGURED FirstSync semantics.
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPc_annotatesReason_keepsModeUnknown()
|
||||
{
|
||||
var signal = MakeSignal(
|
||||
DecisionSignalKind.EnrollmentFactsObserved,
|
||||
ordinal: 0,
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsHybridJoin] = "false",
|
||||
[SignalPayloadKeys.IsCloudPc] = "true",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, signal);
|
||||
|
||||
Assert.Equal(EnrollmentMode.Unknown, profile.Mode);
|
||||
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
|
||||
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
|
||||
Assert.Equal(EspConfig.Unknown, profile.EspConfig);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPc_repeatPost_isIdempotent()
|
||||
{
|
||||
Dictionary<string, string> Facts() => new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsHybridJoin] = "false",
|
||||
[SignalPayloadKeys.IsCloudPc] = "true",
|
||||
};
|
||||
var first = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: Facts());
|
||||
var second = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 9, payload: Facts());
|
||||
|
||||
var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, first);
|
||||
var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, second);
|
||||
|
||||
// The repeat must be a full no-op: the Cloud PC annotation already owns the
|
||||
// reason slot, and the cloudPcAnnotates gate keeps the generic v1 annotation
|
||||
// from re-claiming it (which would bump EvidenceOrdinal every post).
|
||||
Assert.Same(p1, p2);
|
||||
Assert.Equal(0, p2.EvidenceOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPcFalse_keepsGenericV1Reason()
|
||||
{
|
||||
var signal = MakeSignal(
|
||||
DecisionSignalKind.EnrollmentFactsObserved,
|
||||
ordinal: 0,
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsCloudPc] = "false",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, signal);
|
||||
|
||||
Assert.Equal("enrollment_facts_observed:v1", profile.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPc_doesNotDisturbSelfDeployingSeed()
|
||||
{
|
||||
// Defensive combination (not expected in the field — Cloud PCs are never
|
||||
// Autopilot-registered): the deterministic self-deploying Mode seed must
|
||||
// survive; only the reason slot goes to the more specific annotation.
|
||||
var signal = MakeSignal(
|
||||
DecisionSignalKind.EnrollmentFactsObserved,
|
||||
ordinal: 0,
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsSelfDeployingProfile] = "true",
|
||||
[SignalPayloadKeys.IsCloudPc] = "true",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, signal);
|
||||
|
||||
Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode);
|
||||
Assert.Equal(ProfileConfidence.High, profile.Confidence);
|
||||
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
|
||||
}
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
DecisionSignalKind kind,
|
||||
long ordinal,
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Default-value + round-trip tests for <see cref="AdminConfiguration.AutoApproveNewTenants"/>.
|
||||
/// The flag gates the tenant auto-approve queue worker — defaulting to false keeps new tenant
|
||||
/// signups on manual Global Admin approval until the operator deliberately opts in.
|
||||
/// </summary>
|
||||
public class AdminConfigurationAutoApproveNewTenantsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AutoApproveNewTenants_defaults_to_false_on_new_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration();
|
||||
Assert.False(cfg.AutoApproveNewTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoApproveNewTenants_defaults_to_false_on_CreateDefault()
|
||||
{
|
||||
// CreateDefault() is called when no admin-config row exists yet — if it somehow
|
||||
// enabled auto-approve, tenants would activate without any operator opt-in.
|
||||
var cfg = AdminConfiguration.CreateDefault();
|
||||
Assert.False(cfg.AutoApproveNewTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoApproveNewTenants_true_persists_on_the_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration { AutoApproveNewTenants = true };
|
||||
Assert.True(cfg.AutoApproveNewTenants);
|
||||
}
|
||||
}
|
||||
@@ -51,5 +51,7 @@ public class AdminConfigurationRateLimitSyncTests
|
||||
// ...but NO tenant configuration is enumerated or mutated (sync removed).
|
||||
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
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>
|
||||
/// Default-value + fail-closed tests for <see cref="AdminConfiguration.SelfServiceAppHomingEnabled"/>.
|
||||
/// The flag is the kill switch of the dual app-reg self-service homing flip — defaulting to false
|
||||
/// keeps the consent funnel and auto-flip off until the operator deliberately opts in, and a
|
||||
/// storage error must read as "disabled" (kill switch wins over a blip).
|
||||
/// </summary>
|
||||
public class AdminConfigurationSelfServiceAppHomingTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelfServiceAppHoming_defaults_to_false_on_new_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration();
|
||||
Assert.False(cfg.SelfServiceAppHomingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfServiceAppHoming_defaults_to_false_on_CreateDefault()
|
||||
{
|
||||
var cfg = AdminConfiguration.CreateDefault();
|
||||
Assert.False(cfg.SelfServiceAppHomingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfServiceAppHoming_true_persists_on_the_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration { SelfServiceAppHomingEnabled = true };
|
||||
Assert.True(cfg.SelfServiceAppHomingEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_returns_true_when_flag_enabled()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { SelfServiceAppHomingEnabled = true });
|
||||
var svc = new AdminConfigurationService(
|
||||
repo.Object, NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
Assert.True(await svc.IsSelfServiceAppHomingEnabledAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_fails_closed_on_repository_error()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = new AdminConfigurationService(
|
||||
repo.Object, NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
Assert.False(await svc.IsSelfServiceAppHomingEnabledAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization/validation matrix for <see cref="AppHomingService.EvaluateManualFlip"/> — the
|
||||
/// pure decision behind <c>POST config/{tenantId}/app-homing</c>. Contract: tenant admins may
|
||||
/// only flip TO primary, without force, with the self-service flag on, and only after a
|
||||
/// successful consent probe; Global Admins may flip both directions and force past the probe.
|
||||
/// </summary>
|
||||
public class AppHomingDecisionTests
|
||||
{
|
||||
private static readonly AppHomingProbeResult ProbeOk = new(Succeeded: true, IsTransient: false);
|
||||
private static readonly AppHomingProbeResult ProbeFail = new(Succeeded: false, IsTransient: false);
|
||||
private static readonly AppHomingProbeResult ProbeTransient = new(Succeeded: false, IsTransient: true);
|
||||
|
||||
private static AppHomingDecision Evaluate(
|
||||
bool isGlobalAdmin = false,
|
||||
bool selfServiceEnabled = true,
|
||||
bool legacyConfigured = true,
|
||||
bool currentlyPrimary = false,
|
||||
bool targetPrimary = true,
|
||||
bool force = false,
|
||||
AppHomingProbeResult? probe = null)
|
||||
=> AppHomingService.EvaluateManualFlip(
|
||||
isGlobalAdmin, selfServiceEnabled, legacyConfigured, currentlyPrimary, targetPrimary, force, probe);
|
||||
|
||||
[Fact]
|
||||
public void ParallelWindowInactive_denies_for_everyone()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true, legacyConfigured: false, force: true);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("parallel-window-inactive", decision.ReasonCode);
|
||||
Assert.Equal(409, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)] // legacy-homed, target legacy
|
||||
[InlineData(true, true)] // primary-homed, target primary
|
||||
public void AlreadyAtTarget_is_a_noop(bool currentlyPrimary, bool targetPrimary)
|
||||
{
|
||||
var decision = Evaluate(currentlyPrimary: currentlyPrimary, targetPrimary: targetPrimary);
|
||||
Assert.Equal(AppHomingDecisionKind.AllowNoOp, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGa_revert_to_legacy_is_denied()
|
||||
{
|
||||
var decision = Evaluate(currentlyPrimary: true, targetPrimary: false);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("revert-is-ga-only", decision.ReasonCode);
|
||||
Assert.Equal(403, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGa_force_is_denied()
|
||||
{
|
||||
var decision = Evaluate(force: true);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("force-is-ga-only", decision.ReasonCode);
|
||||
Assert.Equal(403, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonGa_with_flag_off_is_denied()
|
||||
{
|
||||
// The kill switch: turning the admin-config flag off must stop tenant-admin flips
|
||||
// immediately, while the GA path below stays open (operator lever survives).
|
||||
var decision = Evaluate(selfServiceEnabled: false);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("self-service-disabled", decision.ReasonCode);
|
||||
Assert.Equal(409, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_with_flag_off_still_requires_only_the_probe()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true, selfServiceEnabled: false);
|
||||
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_without_probe_requires_probe()
|
||||
{
|
||||
var decision = Evaluate();
|
||||
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_with_successful_probe_is_allowed()
|
||||
{
|
||||
var decision = Evaluate(probe: ProbeOk);
|
||||
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_with_failed_probe_is_denied()
|
||||
{
|
||||
var decision = Evaluate(probe: ProbeFail);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("probe-failed", decision.ReasonCode);
|
||||
Assert.Equal(409, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlipToPrimary_with_transient_probe_is_denied_as_retryable()
|
||||
{
|
||||
var decision = Evaluate(probe: ProbeTransient);
|
||||
Assert.Equal(AppHomingDecisionKind.Deny, decision.Kind);
|
||||
Assert.Equal("probe-transient", decision.ReasonCode);
|
||||
Assert.Equal(503, decision.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_flip_without_force_also_requires_probe()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true);
|
||||
Assert.Equal(AppHomingDecisionKind.RequireProbe, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_force_skips_the_probe()
|
||||
{
|
||||
var decision = Evaluate(isGlobalAdmin: true, force: true);
|
||||
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ga_revert_to_legacy_needs_no_probe()
|
||||
{
|
||||
// The legacy app is consented by construction — probing it would be meaningless.
|
||||
var decision = Evaluate(isGlobalAdmin: true, currentlyPrimary: true, targetPrimary: false);
|
||||
Assert.Equal(AppHomingDecisionKind.Allow, decision.Kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.GraphResolution;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AppHomingService"/> — funnel eligibility, the consent-driven
|
||||
/// auto-flip, and the flip persist side-effect chain (save + cache invalidation + audit +
|
||||
/// ops events). The consent probe and the kill-switch flag are mocked at their virtual seams.
|
||||
/// </summary>
|
||||
public class AppHomingServiceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string PrimaryId = "aaaaaaaa-0000-0000-0000-000000000001";
|
||||
private const string LegacyId = "bbbbbbbb-0000-0000-0000-000000000002";
|
||||
private const string Actor = "admin@contoso.com";
|
||||
|
||||
private readonly Mock<AdminConfigurationService> _adminConfigMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<GraphTokenService> _graphTokenMock;
|
||||
private readonly Mock<IGraphFeatureDetector> _detectorMock;
|
||||
private readonly Mock<IMaintenanceRepository> _maintenanceMock;
|
||||
private readonly List<OpsEventEntry> _savedOpsEvents = new();
|
||||
private readonly EntraAppRegistry _registry;
|
||||
private readonly AppHomingService _sut;
|
||||
|
||||
public AppHomingServiceTests() : this(legacyConfigured: true) { }
|
||||
|
||||
private AppHomingServiceTests(bool legacyConfigured)
|
||||
{
|
||||
var configValues = new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
};
|
||||
if (legacyConfigured)
|
||||
{
|
||||
configValues["EntraId:LegacyClientId"] = LegacyId;
|
||||
configValues["EntraId:LegacyClientSecret"] = "legacy-secret";
|
||||
}
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build();
|
||||
_registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_adminConfigMock = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
_tenantConfigMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
_graphTokenMock = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
Mock.Of<System.Net.Http.IHttpClientFactory>(),
|
||||
cache,
|
||||
configuration,
|
||||
_registry,
|
||||
_tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
_detectorMock = new Mock<IGraphFeatureDetector>();
|
||||
_maintenanceMock = new Mock<IMaintenanceRepository>();
|
||||
_maintenanceMock
|
||||
.Setup(m => m.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
|
||||
.Returns(Task.CompletedTask);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfigMock.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
_sut = new AppHomingService(
|
||||
NullLogger<AppHomingService>.Instance,
|
||||
_adminConfigMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
_registry,
|
||||
_graphTokenMock.Object,
|
||||
_detectorMock.Object,
|
||||
_maintenanceMock.Object,
|
||||
opsService,
|
||||
new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true }));
|
||||
|
||||
// Defaults: flag on, legacy-homed config exists, probe succeeds. FlipAsync reads via
|
||||
// the cache-bypassing GetConfigurationFreshAsync (read-modify-write on the whole entity).
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(true);
|
||||
var config = LegacyHomedConfig();
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_tenantConfigMock.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
SetupProbe(GraphTokenResult.Success("tok"));
|
||||
}
|
||||
|
||||
private static TenantConfiguration LegacyHomedConfig()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.HomedAppClientId = null;
|
||||
return config;
|
||||
}
|
||||
|
||||
private void SetupProbe(GraphTokenResult result) =>
|
||||
_graphTokenMock
|
||||
.Setup(x => x.GetAccessTokenForAppAsync(TenantId, It.Is<EntraAppCredentials>(c => !c.IsLegacy),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(result);
|
||||
|
||||
// ── IsFunnelEligibleAsync ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_eligible_for_legacy_homed_tenant_with_flag_on()
|
||||
{
|
||||
Assert.True(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_not_eligible_when_legacy_pair_unconfigured()
|
||||
{
|
||||
var sut = new AppHomingServiceTests(legacyConfigured: false)._sut;
|
||||
Assert.False(await sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_not_eligible_for_primary_homed_tenant()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
Assert.False(await _sut.IsFunnelEligibleAsync(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_is_not_eligible_when_flag_off()
|
||||
{
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
|
||||
Assert.False(await _sut.IsFunnelEligibleAsync(LegacyHomedConfig()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Funnel_does_not_block_on_entra_app_roles()
|
||||
{
|
||||
// Deliberate: app-role tenants may flip (operator informs them personally); the
|
||||
// reminder trail is asserted in the flip test below.
|
||||
var config = LegacyHomedConfig();
|
||||
config.EntraAppRolesEnabled = true;
|
||||
Assert.True(await _sut.IsFunnelEligibleAsync(config));
|
||||
}
|
||||
|
||||
// ── TryAutoFlipToPrimaryAsync ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_flips_on_probe_success_with_full_side_effect_chain()
|
||||
{
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.Flipped, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
|
||||
string.Equals(c.HomedAppClientId, PrimaryId, StringComparison.OrdinalIgnoreCase)
|
||||
&& c.UpdatedBy == Actor), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
_maintenanceMock.Verify(m => m.LogAuditEntryAsync(TenantId, "UPDATE", "TenantConfiguration",
|
||||
TenantId, Actor, It.Is<Dictionary<string, string>?>(d =>
|
||||
d != null && d.ContainsKey("HomedAppClientId"))), Times.Once);
|
||||
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
|
||||
Assert.DoesNotContain(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_does_not_flip_on_permanent_probe_failure()
|
||||
{
|
||||
SetupProbe(GraphTokenResult.PermanentFailure());
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeFailed, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_does_not_flip_on_transient_probe()
|
||||
{
|
||||
SetupProbe(GraphTokenResult.TransientFailure());
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.ProbeTransient, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_is_not_eligible_for_primary_homed_tenant_and_never_probes()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(config, Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
|
||||
_graphTokenMock.Verify(x => x.GetAccessTokenForAppAsync(It.IsAny<string>(),
|
||||
It.IsAny<EntraAppCredentials>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoFlip_is_not_eligible_when_flag_off()
|
||||
{
|
||||
_adminConfigMock.Setup(x => x.IsSelfServiceAppHomingEnabledAsync()).ReturnsAsync(false);
|
||||
|
||||
var outcome = await _sut.TryAutoFlipToPrimaryAsync(LegacyHomedConfig(), Actor);
|
||||
|
||||
Assert.Equal(AppHomingAutoFlipOutcome.NotEligible, outcome);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── FlipAsync ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_of_entra_app_roles_tenant_emits_reminder_ops_event()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.EntraAppRolesEnabled = true;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlipped");
|
||||
Assert.Contains(_savedOpsEvents, e => e.EventType == "AppHomingFlippedWithEntraRoles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_noops_when_already_at_target()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
await _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_throws_when_config_row_missing_and_saves_nothing()
|
||||
{
|
||||
// A flip must never materialize a default row (e.g. resurrect an offboarded tenant);
|
||||
// the fresh read returning null is a hard stop.
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.FlipAsync(TenantId, PrimaryId, Actor, "manual-ga"));
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Flip_back_to_legacy_persists_null_homing()
|
||||
{
|
||||
var config = LegacyHomedConfig();
|
||||
config.HomedAppClientId = PrimaryId;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
await _sut.FlipAsync(TenantId, null, Actor, "manual-ga");
|
||||
|
||||
// The null-homing invariant: legacy is represented as null, never as the legacy GUID.
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.Is<TenantConfiguration>(c =>
|
||||
c.HomedAppClientId == null), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_detectorMock.Verify(x => x.InvalidateTenant(TenantId), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -44,7 +45,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
var delegatedAdminMock = new Mock<DelegatedAdminService>(
|
||||
adminRepo,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache, Mock.Of<ILogger<DelegatedAdminService>>())
|
||||
{ CallBase = false };
|
||||
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
|
||||
@@ -76,6 +77,7 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
_metricsRepoMock = new Mock<IMetricsRepository>();
|
||||
_autoApproveEnqueuerMock = new Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer>();
|
||||
|
||||
_sut = new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
@@ -87,11 +89,15 @@ public class AuthFunctionSideEffectTests
|
||||
previewMock.Object,
|
||||
_telegramMock.Object,
|
||||
_globalNotificationMock.Object,
|
||||
mcpUserMock.Object);
|
||||
mcpUserMock.Object,
|
||||
_autoApproveEnqueuerMock.Object,
|
||||
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.Setup(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
_telegramMock
|
||||
.Setup(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
@@ -126,12 +132,89 @@ public class AuthFunctionSideEffectTests
|
||||
// OnboardedBy is the immutable copy of the first-login UPN that auto-promote on
|
||||
// preview approval reads — UpdatedBy may later be clobbered by background syncs.
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(TenantId, Upn), Times.Once);
|
||||
_globalNotificationMock.Verify(x => x.CreateNotificationAsync(
|
||||
"preview_signup", "New Preview Signup",
|
||||
"preview_signup", "New Tenant Signup",
|
||||
It.Is<string>(m => m.Contains(TenantId) && m.Contains("contoso.com") && m.Contains(Upn)),
|
||||
$"/admin/tenants/management?tenantId={TenantId}"), Times.Once);
|
||||
// Signup enqueues the delayed auto-approve unconditionally — the worker is the
|
||||
// decision point (flag check at processing time).
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.Is<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(
|
||||
e => e.TenantId == TenantId && e.SignupUpn == Upn),
|
||||
AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope.ActivationDelay,
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING
|
||||
// read, never the (possibly stale) cached view. Regression for the prod
|
||||
// incident 2026-07-31: the login right after an app-homing flip carries the
|
||||
// changed audience; blind-saving the stale cached entity reverted the flip.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const string LegacyAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
|
||||
private const string PrimaryAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthClientIdTracking_MutatesFreshConfig_PreservingConcurrentHomingFlip()
|
||||
{
|
||||
// Cached view: pre-flip (homing null, last-seen legacy). Fresh row: flipped meanwhile.
|
||||
var cached = DefaultConfig();
|
||||
cached.DomainName = "contoso.com";
|
||||
cached.LastAuthClientId = LegacyAppId;
|
||||
cached.HomedAppClientId = null;
|
||||
|
||||
var fresh = DefaultConfig();
|
||||
fresh.DomainName = "contoso.com";
|
||||
fresh.LastAuthClientId = LegacyAppId;
|
||||
fresh.HomedAppClientId = PrimaryAppId; // the concurrent flip that must survive
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
|
||||
|
||||
// The FRESH entity was mutated and saved — the flip survives, the stale view is discarded.
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(fresh, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(cached, It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
Assert.Equal(PrimaryAppId, fresh.HomedAppClientId);
|
||||
Assert.Equal(PrimaryAppId, fresh.LastAuthClientId);
|
||||
Assert.NotNull(fresh.LastAuthClientIdSince);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthClientIdTracking_FreshAlreadyCurrent_DoesNotWrite()
|
||||
{
|
||||
// Another instance already recorded the new client id — the fresh re-check must
|
||||
// suppress the redundant write even though the cached view still looks outdated.
|
||||
var cached = DefaultConfig();
|
||||
cached.DomainName = "contoso.com";
|
||||
cached.LastAuthClientId = LegacyAppId;
|
||||
|
||||
var fresh = DefaultConfig();
|
||||
fresh.DomainName = "contoso.com";
|
||||
fresh.LastAuthClientId = PrimaryAppId;
|
||||
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(fresh);
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, PrimaryAppId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthClientIdTracking_UnchangedOnCachedView_SkipsFreshRead()
|
||||
{
|
||||
// Steady state (same app as last time) must stay a zero-I/O no-op.
|
||||
var cached = DefaultConfig();
|
||||
cached.DomainName = "contoso.com";
|
||||
cached.LastAuthClientId = PrimaryAppId;
|
||||
|
||||
await _sut.HandleAuthClientIdTrackingAsync(cached, TenantId, $"api://{PrimaryAppId}");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,7 +239,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -168,7 +251,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -179,7 +262,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, "nodomain");
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -196,7 +279,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
// SaveConfig should still have been called before the fire-and-forget
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -217,7 +300,7 @@ public class AuthFunctionSideEffectTests
|
||||
Assert.Null(config.DisabledReason);
|
||||
Assert.Null(config.DisabledUntil);
|
||||
Assert.Equal("System (auto-re-enable)", config.UpdatedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config), Times.Once);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -230,7 +313,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -241,7 +324,7 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -254,7 +337,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandleAutoReEnableAsync(config, TenantId);
|
||||
|
||||
Assert.True(config.Disabled);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -223,11 +223,11 @@ public class AuthFunctionTests
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Gate 2: Preview Gate
|
||||
// Gate 2: Activation Gate
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PreviewGate_NotApproved_Returns403PrivatePreview()
|
||||
public void PreviewGate_NotApproved_Returns403PendingActivation()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false,
|
||||
@@ -238,7 +238,7 @@ public class AuthFunctionTests
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.Equal("PrivatePreview", (string)body.error);
|
||||
Assert.Equal("PendingActivation", (string)body.error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+32
@@ -203,6 +203,38 @@ public class AuthenticationMiddlewareTokenValidationTests
|
||||
Mint(RsaCreds, audience: "api://some-third-app"), ClientId, SecondaryClientId));
|
||||
}
|
||||
|
||||
// ── Dual app-reg window (C4A8 move): primary=new app, legacy=old app via LegacyClientId ──
|
||||
// The middleware folds EntraId:LegacyClientId into the additional-id set via
|
||||
// CombineAdditionalClientIdSources, so both apps' tokens validate in bare AND api:// form.
|
||||
|
||||
private const string NewAppId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
private const string OldAppId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
|
||||
|
||||
[Theory]
|
||||
[InlineData(NewAppId)]
|
||||
[InlineData("api://" + NewAppId)]
|
||||
[InlineData(OldAppId)]
|
||||
[InlineData("api://" + OldAppId)]
|
||||
public void DualAppWindow_accepts_both_apps_tokens_in_both_audience_forms(string audience)
|
||||
{
|
||||
var clientIds = AuthenticationMiddleware.ResolveConfiguredClientIds(
|
||||
NewAppId,
|
||||
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, null),
|
||||
out _);
|
||||
|
||||
Assert.NotNull(ValidateWith(Mint(RsaCreds, audience: audience), clientIds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CombineAdditionalClientIdSources_joins_and_skips_empty_sources()
|
||||
{
|
||||
Assert.Equal($"{OldAppId},{SecondaryClientId}",
|
||||
AuthenticationMiddleware.CombineAdditionalClientIdSources(OldAppId, SecondaryClientId));
|
||||
Assert.Equal(OldAppId,
|
||||
AuthenticationMiddleware.CombineAdditionalClientIdSources($" {OldAppId} ", null, ""));
|
||||
Assert.Null(AuthenticationMiddleware.CombineAdditionalClientIdSources(null, "", " "));
|
||||
}
|
||||
|
||||
// ── BuildValidAudiences: id → {id, api://id}, deduped, empties dropped ────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CloudPcDeviceValidator"/> — focused on pure-function pieces:
|
||||
/// JSON-to-DTO mapping (incl. exact-match guard on managedDeviceId) and cache key shape.
|
||||
/// The HTTP/cache/retry resilience is mirrored 1:1 from <c>AutopilotDeviceValidator</c>;
|
||||
/// behavioural drift would surface here. Response shapes match a real
|
||||
/// virtualEndpoint/cloudPCs payload (field-captured 2026-08-06 on a W365 tenant).
|
||||
/// </summary>
|
||||
public class CloudPcDeviceValidatorTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string DeviceId = "07623d56-1e77-4948-bff5-5bdac8167560";
|
||||
|
||||
// -- BuildCacheKey --
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_StableShape()
|
||||
{
|
||||
var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId);
|
||||
Assert.Equal($"cloudpc-device-validation:{TenantId}:{DeviceId}", key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_DistinctFromOtherValidatorKeys()
|
||||
{
|
||||
// The validators must NOT share cache keys; otherwise a positive hit in one
|
||||
// would incorrectly satisfy a lookup in another.
|
||||
var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId);
|
||||
Assert.StartsWith("cloudpc-device-validation:", key);
|
||||
Assert.DoesNotContain("autopilot", key);
|
||||
Assert.DoesNotContain("device-association", key);
|
||||
}
|
||||
|
||||
// -- ParseCloudPcResponse: empty / missing / malformed --
|
||||
|
||||
[Fact]
|
||||
public void Parse_EmptyValueArray_NotFound()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse("{\"value\":[]}", DeviceId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.Equal(DeviceId, result.IntuneDeviceId);
|
||||
Assert.Contains("not a Windows 365 Cloud PC", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MissingValueProperty_NotFound()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse("{}", DeviceId);
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MalformedJson_NotFound_NotTransient()
|
||||
{
|
||||
// Defensive: bad payload is treated as "not a Cloud PC", not as a transient
|
||||
// — transient handling is only triggered by HTTP-level failures.
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse("this is not json", DeviceId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
// -- ParseCloudPcResponse: exact-match guard --
|
||||
|
||||
[Fact]
|
||||
public void Parse_DifferentManagedDeviceId_NotFound()
|
||||
{
|
||||
// Guards against widened filter semantics: even if Graph returns Cloud PCs whose
|
||||
// managedDeviceId does not exactly match the queried id, we must reject.
|
||||
var body = "{\"value\":[{\"id\":\"423a1fb4-6033-4b69-a31f-e7f3f54194dc\",\"managedDeviceId\":\"99999999-9999-9999-9999-999999999999\"}]}";
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ExactMatch_PopulatesAllFields()
|
||||
{
|
||||
var body = $@"{{
|
||||
""value"":[{{
|
||||
""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",
|
||||
""displayName"":""GKT-CloudPC - Obi-Wan Kenobi"",
|
||||
""managedDeviceId"":""{DeviceId}"",
|
||||
""managedDeviceName"":""CPC-cloud-AY5HT"",
|
||||
""userPrincipalName"":""cloudadmin@gktatooine.net"",
|
||||
""servicePlanName"":""Cloud PC Enterprise 2vCPU/4GB/128GB""
|
||||
}}]
|
||||
}}";
|
||||
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.Equal(DeviceId, result.IntuneDeviceId);
|
||||
Assert.Equal("423a1fb4-6033-4b69-a31f-e7f3f54194dc", result.CloudPcId);
|
||||
Assert.Equal("CPC-cloud-AY5HT", result.ManagedDeviceName);
|
||||
Assert.Equal("Cloud PC Enterprise 2vCPU/4GB/128GB", result.ServicePlanName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CaseInsensitiveIdMatch()
|
||||
{
|
||||
var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":""{DeviceId.ToUpperInvariant()}""}}]}}";
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TrimsWhitespace_OnGraphIdBeforeMatching()
|
||||
{
|
||||
var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":"" {DeviceId} ""}}]}}";
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The universal pre-write snapshot hook in <see cref="TableConfigRepository"/>:
|
||||
/// every overwriting save of a TenantConfiguration/AdminConfiguration row must first
|
||||
/// snapshot the stored row into ConfigurationBackups — except row creations and
|
||||
/// noise-only changes — and a backup-storage failure must never break the save (fail-soft).
|
||||
/// </summary>
|
||||
public class ConfigBackupHookTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableConfigRepository Sut { get; }
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public Mock<IConfigBackupRepository> Backup { get; } = new();
|
||||
public List<TableEntity> Upserts { get; } = new();
|
||||
|
||||
public Harness(TableEntity? stored)
|
||||
{
|
||||
if (stored == null)
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found", "ResourceNotFound", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
|
||||
}
|
||||
|
||||
Table.Setup(c => c.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
|
||||
{
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
|
||||
|
||||
Sut = new TableConfigRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
Backup.Object,
|
||||
NullLogger<TableConfigRepository>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static TenantConfiguration StoredConfig()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.DomainName = "contoso.com";
|
||||
config.UpdatedBy = "admin@contoso.com";
|
||||
config.DataRetentionDays = 30;
|
||||
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors production: every real writer round-trips the STORED row (read-modify-write),
|
||||
/// so time-stamped fields like OnboardedAt match the stored values exactly. Constructing
|
||||
/// a second CreateDefault instead would fabricate an OnboardedAt diff that cannot occur.
|
||||
/// </summary>
|
||||
private static TenantConfiguration RoundtripClone(TenantConfiguration stored)
|
||||
=> TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
[Fact]
|
||||
public async Task RealChange_SnapshotsStoredRow_ThenSaves_ThenPrunes()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
incoming.UpdatedBy = "ga@operator.example";
|
||||
|
||||
ConfigBackupEntry? snapshot = null;
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", "test change");
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts); // the config row itself
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal(TenantId, snapshot!.PartitionKey);
|
||||
Assert.Equal("portal-put", snapshot.Source);
|
||||
Assert.Equal("test change", snapshot.Reason);
|
||||
Assert.Equal("ga@operator.example", snapshot.ChangedBy); // identity about to write
|
||||
// The snapshot holds the STORED row (retention 30), not the incoming one.
|
||||
Assert.Contains("\"DataRetentionDays\":30", snapshot.EntityJson);
|
||||
// Raw secrets ARE in EntityJson (full-fidelity restore source)…
|
||||
Assert.Contains("contoso.webhook.office.com", snapshot.EntityJson);
|
||||
// …but the advisory diff masks them by property-name heuristics (ConfigDiffHelper).
|
||||
Assert.DoesNotContain("contoso.webhook.office.com", snapshot.DiffJson ?? string.Empty);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync(TenantId, Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowCreation_NoExistingRow_NoSnapshot()
|
||||
{
|
||||
var harness = new Harness(stored: null);
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(StoredConfig(), "portal-put", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoiseOnlyChange_AuthClientIdFlip_NoSnapshot()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
// The auth-side-effect write: only LastAuthClientId/Since + bookkeeping move.
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.LastAuthClientId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
incoming.LastAuthClientIdSince = DateTime.UtcNow;
|
||||
incoming.LastUpdated = DateTime.UtcNow;
|
||||
incoming.UpdatedBy = "System (auth)";
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "auth", null);
|
||||
|
||||
Assert.True(saved); // the save itself still goes through
|
||||
Assert.Single(harness.Upserts);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoOpSave_IdenticalContent_NoSnapshot()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(RoundtripClone(stored), "portal-put", null);
|
||||
|
||||
Assert.True(saved);
|
||||
harness.Backup.Verify(
|
||||
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupStorageFailure_IsFailSoft_SaveStillSucceeds()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "backup table throttled"));
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
|
||||
var saved = await harness.Sut.SaveTenantConfigurationAsync(incoming, "portal-put", null);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.Single(harness.Upserts); // the config write went through regardless
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyOneArgOverload_StillSnapshots_WithUnknownSource()
|
||||
{
|
||||
var stored = StoredConfig();
|
||||
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
|
||||
|
||||
ConfigBackupEntry? snapshot = null;
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var incoming = RoundtripClone(stored);
|
||||
incoming.DataRetentionDays = 90;
|
||||
await harness.Sut.SaveTenantConfigurationAsync(incoming);
|
||||
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("unknown", snapshot!.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminConfigSave_SnapshotsUnderGlobalConfigPartition()
|
||||
{
|
||||
var stored = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 100,
|
||||
};
|
||||
var harness = new Harness(TableConfigRepository.ConvertToAdminTableEntity(stored));
|
||||
|
||||
ConfigBackupEntry? snapshot = null;
|
||||
harness.Backup
|
||||
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var incoming = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "ga@operator.example",
|
||||
GlobalRateLimitRequestsPerMinute = 200,
|
||||
};
|
||||
|
||||
var saved = await harness.Sut.SaveAdminConfigurationAsync(incoming);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.NotNull(snapshot);
|
||||
Assert.Equal("GlobalConfig", snapshot!.PartitionKey);
|
||||
Assert.Equal("admin-config", snapshot.Source);
|
||||
harness.Backup.Verify(
|
||||
b => b.PruneAsync("GlobalConfig", Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TableConfigBackupRepository: Store↔Map roundtrip (project rule "table-serialization"),
|
||||
/// reverse-ticks RowKey ordering (newest-first), and prune-beyond-keep semantics.
|
||||
/// </summary>
|
||||
public class ConfigBackupRepositoryTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableConfigBackupRepository Sut { get; }
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<TableEntity> Upserts { get; } = new();
|
||||
public List<(string Pk, string Rk)> Deletes { get; } = new();
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(c => c.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
|
||||
{
|
||||
Upserts.Add(e);
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(c => c.DeleteEntityAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, ETag, CancellationToken>((pk, rk, _, _) =>
|
||||
{
|
||||
Deletes.Add((pk, rk));
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
|
||||
Sut = new TableConfigBackupRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
NullLogger<TableConfigBackupRepository>.Instance);
|
||||
}
|
||||
|
||||
public void SetupQuery(params TableEntity[] rows)
|
||||
{
|
||||
var page = Page<TableEntity>.FromValues(rows, continuationToken: null, Mock.Of<Response>());
|
||||
Table.Setup(c => c.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[] { page }));
|
||||
}
|
||||
}
|
||||
|
||||
private static ConfigBackupEntry Entry(string rowKey = "0001_abc") => new()
|
||||
{
|
||||
PartitionKey = TenantId,
|
||||
RowKey = rowKey,
|
||||
TenantId = TenantId,
|
||||
EntityJson = "{\"DomainName\":\"contoso.com\"}",
|
||||
ChangedBy = "ga@operator.example",
|
||||
Source = "mcp-patch",
|
||||
Reason = "retention bump",
|
||||
DiffJson = "{\"DataRetentionDays\":\"30 \\u2192 90\"}",
|
||||
BackupTakenAt = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Roundtrip_StoreAndMap_PreservesEveryField()
|
||||
{
|
||||
var harness = new Harness();
|
||||
var entry = Entry();
|
||||
|
||||
await harness.Sut.UpsertAsync(entry);
|
||||
var stored = Assert.Single(harness.Upserts);
|
||||
|
||||
// Feed the stored entity back through the read path (TryGetAsync → Map).
|
||||
harness.Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
|
||||
var mapped = await harness.Sut.TryGetAsync(TenantId, entry.RowKey);
|
||||
|
||||
Assert.NotNull(mapped);
|
||||
Assert.Equal(entry.PartitionKey, mapped!.PartitionKey);
|
||||
Assert.Equal(entry.RowKey, mapped.RowKey);
|
||||
Assert.Equal(entry.TenantId, mapped.TenantId);
|
||||
Assert.Equal(entry.EntityJson, mapped.EntityJson);
|
||||
Assert.Equal(entry.ChangedBy, mapped.ChangedBy);
|
||||
Assert.Equal(entry.Source, mapped.Source);
|
||||
Assert.Equal(entry.Reason, mapped.Reason);
|
||||
Assert.Equal(entry.DiffJson, mapped.DiffJson);
|
||||
Assert.Equal(entry.BackupTakenAt, mapped.BackupTakenAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRowKey_LaterTimestamps_SortFirst()
|
||||
{
|
||||
var earlier = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc));
|
||||
var later = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
// Reverse ticks: the LATER backup gets the LEXICOGRAPHICALLY SMALLER RowKey, so
|
||||
// Azure's RowKey-ascending partition scan returns newest-first.
|
||||
Assert.True(string.CompareOrdinal(later, earlier) < 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRowKey_SameTick_DistinctViaGuidSuffix()
|
||||
{
|
||||
var at = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc);
|
||||
Assert.NotEqual(
|
||||
TableConfigBackupRepository.BuildRowKey(at),
|
||||
TableConfigBackupRepository.BuildRowKey(at));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prune_DeletesEverythingBeyondKeep_InScanOrder()
|
||||
{
|
||||
var harness = new Harness();
|
||||
// Scan order = RowKey ascending = newest first (reverse ticks). keep=2 must
|
||||
// delete rows 3 and 4, never the two newest.
|
||||
harness.SetupQuery(
|
||||
new TableEntity(TenantId, "0001_newest"),
|
||||
new TableEntity(TenantId, "0002_second"),
|
||||
new TableEntity(TenantId, "0003_old"),
|
||||
new TableEntity(TenantId, "0004_oldest"));
|
||||
|
||||
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
|
||||
|
||||
Assert.Equal(2, deleted);
|
||||
Assert.Equal(new[] { "0003_old", "0004_oldest" }, harness.Deletes.Select(d => d.Rk).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Prune_WithinKeep_DeletesNothing()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetupQuery(
|
||||
new TableEntity(TenantId, "0001_newest"),
|
||||
new TableEntity(TenantId, "0002_second"));
|
||||
|
||||
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
|
||||
|
||||
Assert.Equal(0, deleted);
|
||||
Assert.Empty(harness.Deletes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByPartition_CapsAtMax()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.SetupQuery(
|
||||
new TableEntity(TenantId, "0001"),
|
||||
new TableEntity(TenantId, "0002"),
|
||||
new TableEntity(TenantId, "0003"));
|
||||
|
||||
var listed = await harness.Sut.ListByPartitionAsync(TenantId, max: 2);
|
||||
|
||||
Assert.Equal(2, listed.Count);
|
||||
Assert.Equal("0001", listed[0].RowKey);
|
||||
}
|
||||
}
|
||||
@@ -192,4 +192,37 @@ public class ConfigRedactionTests
|
||||
// Operator/Viewer arrives with IsTenantAdmin=false ⇒ always the redacted copy.
|
||||
=> Assert.False(GetTenantConfigurationFunction.CanViewSecrets(
|
||||
new RequestContext { UserRole = role, TenantId = TenantA, TargetTenantId = TenantA }));
|
||||
|
||||
// ── ?view=redacted opt-in (MCP get_tenant_config): forces redaction even for GA ──
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_GlobalAdmin_ViewRedacted_True()
|
||||
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
"?view=redacted"));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_OwnTenantAdmin_ViewRedacted_True()
|
||||
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsTenantAdmin = true, TenantId = TenantA, TargetTenantId = TenantA },
|
||||
"?view=redacted"));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_GlobalAdmin_NoParam_False()
|
||||
// Without the opt-in the GA clearance stands — the portal Settings UI depends on it.
|
||||
=> Assert.False(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
""));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_GlobalAdmin_OtherViewValue_False()
|
||||
=> Assert.False(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalAdmin = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
"?view=full"));
|
||||
|
||||
[Fact]
|
||||
public void ShouldRedact_ReaderWithoutClearance_AlwaysTrue_RegardlessOfParam()
|
||||
=> Assert.True(GetTenantConfigurationFunction.ShouldRedact(
|
||||
new RequestContext { IsGlobalReader = true, TenantId = TenantA, TargetTenantId = TenantB },
|
||||
"?view=full"));
|
||||
}
|
||||
|
||||
@@ -259,20 +259,21 @@ public class ContinuationTokenTests
|
||||
public void TryDecode_rejects_token_signed_with_a_different_key()
|
||||
{
|
||||
// Simulate key rotation: token issued under key A, validated under key B.
|
||||
// (We toggle the test key around the encode/decode pair.)
|
||||
// The scoped (AsyncLocal) override keeps the swap confined to this test's
|
||||
// execution context — mutating the process-wide test key here raced the
|
||||
// concurrently running pagination test classes.
|
||||
var keyA = new byte[32]; for (int i = 0; i < keyA.Length; i++) keyA[i] = (byte)(i * 3 + 1);
|
||||
var keyB = new byte[32]; for (int i = 0; i < keyB.Length; i++) keyB[i] = (byte)(i * 5 + 7);
|
||||
|
||||
var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
|
||||
|
||||
ContinuationToken.SetSigningKeyForTesting(keyA);
|
||||
ContinuationToken.SetScopedSigningKeyForTesting(keyA);
|
||||
var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp);
|
||||
|
||||
ContinuationToken.SetSigningKeyForTesting(keyB);
|
||||
ContinuationToken.SetScopedSigningKeyForTesting(keyB);
|
||||
var ok = ContinuationToken.TryDecode(encoded, TenantA, fp, out _, out var reason);
|
||||
|
||||
// Restore the test default before leaving so subsequent tests aren't affected.
|
||||
TestSetup.Initialize();
|
||||
ContinuationToken.SetScopedSigningKeyForTesting(null);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal("bad_signature", reason);
|
||||
|
||||
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
|
||||
|
||||
Assert.Equal(BackupOutcome.Success, result.Outcome);
|
||||
Assert.True(store.ManifestWritten, "manifest must be written LAST");
|
||||
Assert.Equal(16, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins)
|
||||
Assert.Equal(17, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups)
|
||||
Assert.All(result.Manifest.Tables, t =>
|
||||
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class DelegatedAdminEditionGateTests
|
||||
return new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ public class DelegatedAdminEditionGateTests
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Pro : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public class DelegatedAdminServiceTests
|
||||
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
return (svc, repo);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Resolution matrix for the dual app-registration window (see EntraAppRegistry):
|
||||
/// legacy unconfigured ⇒ always primary (code deploy is a no-op before the config swap);
|
||||
/// null homing ⇒ legacy; known ids ⇒ that app; unknown ⇒ primary fallback.
|
||||
/// </summary>
|
||||
public class EntraAppRegistryTests
|
||||
{
|
||||
private const string PrimaryId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
private const string LegacyId = "1a400946-62c1-4ab4-aa37-f730ac89704d";
|
||||
|
||||
private static EntraAppRegistry Build(Dictionary<string, string?> settings) =>
|
||||
new(new ConfigurationBuilder().AddInMemoryCollection(settings).Build(),
|
||||
NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
private static EntraAppRegistry DualApp() => Build(new()
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
["EntraId:LegacyClientId"] = LegacyId,
|
||||
["EntraId:LegacyClientSecret"] = "legacy-secret",
|
||||
});
|
||||
|
||||
private static TenantConfiguration Tenant(string? homedAppClientId) => new()
|
||||
{
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
HomedAppClientId = homedAppClientId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void LegacyUnconfigured_AlwaysResolvesPrimary_EvenForNullHoming()
|
||||
{
|
||||
var registry = Build(new()
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
});
|
||||
|
||||
Assert.False(registry.LegacyConfigured);
|
||||
Assert.Equal(PrimaryId, registry.ResolveForTenant(Tenant(null)).ClientId);
|
||||
Assert.Equal(PrimaryId, registry.ResolveForTenant(null).ClientId);
|
||||
Assert.False(registry.ResolveForTenant(Tenant(null)).IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullHoming_ResolvesLegacy()
|
||||
{
|
||||
var resolved = DualApp().ResolveForTenant(Tenant(null));
|
||||
Assert.True(resolved.IsLegacy);
|
||||
Assert.Equal(LegacyId, resolved.ClientId);
|
||||
Assert.Equal("legacy-secret", resolved.ClientSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingConfigRow_ResolvesLegacy_SameAsNullHoming()
|
||||
{
|
||||
Assert.True(DualApp().ResolveForTenant(null).IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrimaryHoming_ResolvesPrimary_CaseAndBraceInsensitive()
|
||||
{
|
||||
var registry = DualApp();
|
||||
Assert.False(registry.ResolveForTenant(Tenant(PrimaryId)).IsLegacy);
|
||||
Assert.False(registry.ResolveForTenant(Tenant(PrimaryId.ToUpperInvariant())).IsLegacy);
|
||||
Assert.False(registry.ResolveForTenant(Tenant("{" + PrimaryId + "}")).IsLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyHoming_ResolvesLegacy()
|
||||
{
|
||||
var resolved = DualApp().ResolveForTenant(Tenant(LegacyId));
|
||||
Assert.True(resolved.IsLegacy);
|
||||
Assert.Equal("legacy-secret", resolved.ClientSecret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownHoming_FallsBackToPrimary()
|
||||
{
|
||||
var resolved = DualApp().ResolveForTenant(Tenant("99999999-9999-9999-9999-999999999999"));
|
||||
Assert.False(resolved.IsLegacy);
|
||||
Assert.Equal(PrimaryId, resolved.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MalformedLegacyClientId_TreatedAsUnconfigured()
|
||||
{
|
||||
var registry = Build(new()
|
||||
{
|
||||
["EntraId:ClientId"] = PrimaryId,
|
||||
["EntraId:LegacyClientId"] = "not-a-guid",
|
||||
["EntraId:LegacyClientSecret"] = "legacy-secret",
|
||||
});
|
||||
|
||||
Assert.False(registry.LegacyConfigured);
|
||||
Assert.False(registry.ResolveForTenant(Tenant(null)).IsLegacy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PrimaryId, true)]
|
||||
[InlineData("api://" + PrimaryId, true)]
|
||||
[InlineData("API://" + PrimaryId, true)]
|
||||
[InlineData(LegacyId, false)]
|
||||
[InlineData("api://" + LegacyId, false)]
|
||||
[InlineData("https://graph.microsoft.com", false)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
public void IsPrimary_MatchesBareAndApiPrefixedAudiences(string? audience, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, DualApp().IsPrimary(audience));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("api://" + LegacyId, LegacyId)]
|
||||
[InlineData(LegacyId, LegacyId)]
|
||||
[InlineData("API://886AB5E2-6144-442C-80CC-9B28E0667731", PrimaryId)]
|
||||
[InlineData("https://graph.microsoft.com", null)] // non-GUID audience must never persist as provenance
|
||||
[InlineData("api://not-a-guid", null)]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(" ", null)]
|
||||
public void NormalizeAudience_StripsApiPrefixAndRequiresGuid(string? audience, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, EntraAppRegistry.NormalizeAudience(audience));
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// Pins the edition-resolution matrix and the per-edition entitlement values of
|
||||
/// <see cref="FeatureEntitlementCatalog"/>. Fail-closed contract: ONLY the exact tier
|
||||
/// "enterprise" (or an active trial) yields Enterprise — legacy stored tiers ("free", "pro"),
|
||||
/// null/empty and unknown values all resolve to Community without any data migration.
|
||||
/// "pro" / the legacy stored value "enterprise" (or an active trial) yields Pro — the legacy
|
||||
/// stored tier "free", null/empty and unknown values all resolve to Community without any
|
||||
/// data migration.
|
||||
/// </summary>
|
||||
public class FeatureEntitlementCatalogTests
|
||||
{
|
||||
@@ -16,31 +17,34 @@ public class FeatureEntitlementCatalogTests
|
||||
// ── ResolveEdition matrix ────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("enterprise")]
|
||||
[InlineData("pro")]
|
||||
[InlineData("Pro")]
|
||||
[InlineData("PRO")]
|
||||
[InlineData(" pro ")]
|
||||
[InlineData("enterprise")] // legacy stored value — must stay readable as Pro
|
||||
[InlineData("Enterprise")]
|
||||
[InlineData("ENTERPRISE")]
|
||||
[InlineData(" enterprise ")]
|
||||
public void ResolveEdition_EnterpriseTier_IsEnterprise(string tier)
|
||||
public void ResolveEdition_ProOrLegacyEnterpriseTier_IsPro(string tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("free")]
|
||||
[InlineData("pro")]
|
||||
[InlineData("community")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("premium")] // unknown value → fail-closed
|
||||
public void ResolveEdition_NonEnterpriseTier_IsCommunity(string? tier)
|
||||
public void ResolveEdition_NonProTier_IsCommunity(string? tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ActiveTrial_IsEnterprise_EvenOnFreeTier()
|
||||
public void ResolveEdition_ActiveTrial_IsPro_EvenOnFreeTier()
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise,
|
||||
Assert.Equal(TenantEdition.Pro,
|
||||
FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
|
||||
}
|
||||
|
||||
@@ -59,11 +63,28 @@ public class FeatureEntitlementCatalogTests
|
||||
FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ExpiredTrial_ButEnterpriseTier_StaysEnterprise()
|
||||
[Theory]
|
||||
[InlineData("pro")]
|
||||
[InlineData("enterprise")]
|
||||
public void ResolveEdition_ExpiredTrial_ButPermanentProTier_StaysPro(string tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise,
|
||||
FeatureEntitlementCatalog.ResolveEdition("enterprise", Now.AddDays(-1), Now));
|
||||
Assert.Equal(TenantEdition.Pro,
|
||||
FeatureEntitlementCatalog.ResolveEdition(tier, Now.AddDays(-1), Now));
|
||||
}
|
||||
|
||||
// ── IsPermanentProTier ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("pro", true)]
|
||||
[InlineData(" PRO ", true)]
|
||||
[InlineData("enterprise", true)] // legacy stored value
|
||||
[InlineData("free", false)]
|
||||
[InlineData("community", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsPermanentProTier_Matrix(string? tier, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, FeatureEntitlementCatalog.IsPermanentProTier(tier));
|
||||
}
|
||||
|
||||
// ── Entitlement values ───────────────────────────────────────────────────────
|
||||
@@ -76,20 +97,24 @@ public class FeatureEntitlementCatalogTests
|
||||
Assert.Null(e.UserRateLimitPerMinute);
|
||||
Assert.Null(e.DeviceRateLimitPerMinute);
|
||||
Assert.False(e.DelegatedAdminAllowed);
|
||||
Assert.False(e.BootstrapIncluded);
|
||||
Assert.False(e.UnrestrictedModeAvailable);
|
||||
Assert.Equal("community", e.McpUsagePlanName);
|
||||
Assert.Equal(100, e.McpDailyRequestLimit);
|
||||
Assert.Equal(3000, e.McpMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enterprise_Entitlements_MatchMatrix()
|
||||
public void Pro_Entitlements_MatchMatrix()
|
||||
{
|
||||
var e = FeatureEntitlementCatalog.Get(TenantEdition.Enterprise);
|
||||
var e = FeatureEntitlementCatalog.Get(TenantEdition.Pro);
|
||||
Assert.Equal(365, e.RetentionCapDays);
|
||||
Assert.Equal(150, e.UserRateLimitPerMinute);
|
||||
Assert.Equal(150, e.DeviceRateLimitPerMinute);
|
||||
Assert.True(e.DelegatedAdminAllowed);
|
||||
Assert.Equal("enterprise", e.McpUsagePlanName);
|
||||
Assert.True(e.BootstrapIncluded);
|
||||
Assert.True(e.UnrestrictedModeAvailable);
|
||||
Assert.Equal("pro", e.McpUsagePlanName);
|
||||
Assert.Equal(1000, e.McpDailyRequestLimit);
|
||||
Assert.Equal(20000, e.McpMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
public class GatherRuleUpdatePartialMergeTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string RuleId = "GATHER-CUST-001";
|
||||
private const string RuleId = "GATHER-CUSTOM-001";
|
||||
|
||||
private static GatherRule ExistingCustomRule() => new()
|
||||
{
|
||||
|
||||
+39
-1
@@ -164,12 +164,49 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Cloud PC marker aggregation
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_IsCloudPc_StickyTrueAcrossGroup()
|
||||
{
|
||||
// One old agent (no field → false) + one new agent reporting the W365 marker:
|
||||
// the aggregate must say Cloud PC even though the MOST RECENT report is false —
|
||||
// same OR semantics as the Sessions-table IsCloudPc merge.
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0, isCloudPc: true),
|
||||
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0.AddMinutes(5), isCloudPc: false),
|
||||
};
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.True((bool)item.isCloudPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_IsCloudPc_FalseWhenNoReportCarriesMarker()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0),
|
||||
};
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.False((bool)item.isCloudPc);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static DistressReportEntry MakeEntry(
|
||||
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) =>
|
||||
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt,
|
||||
bool isCloudPc = false) =>
|
||||
new()
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
@@ -179,6 +216,7 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
SerialNumber = serial,
|
||||
IngestedAt = ingestedAt,
|
||||
AgentTimestamp = ingestedAt,
|
||||
IsCloudPc = isCloudPc,
|
||||
};
|
||||
|
||||
private static dynamic ToDynamic(object obj)
|
||||
|
||||
@@ -34,7 +34,13 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
// — and that forces a deliberate code review of whether the new field is non-sensitive.
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
// Non-sensitive by review: bool only, exposes no client ids (dual app-reg
|
||||
// self-service migration banner).
|
||||
"appHomingFunnelActive",
|
||||
"bootstrapTokenEnabled",
|
||||
// Non-sensitive by review: bool only ("is a contact address set"), never the
|
||||
// address itself (admin-gated full config only).
|
||||
"contactEmailSet",
|
||||
"diagnosticsUploadConfigured",
|
||||
"edition",
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
@@ -54,11 +60,13 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
PlanTier = "pro", // unrestrictedMode is EFFECTIVE — requires Pro + gate + toggle
|
||||
BootstrapTokenEnabled = true,
|
||||
ValidateAutopilotDevice = true,
|
||||
ShowScriptOutput = false,
|
||||
EnableSoftwareInventoryAnalyzer = true,
|
||||
EnableIntegrityBypassAnalyzer = false,
|
||||
UnrestrictedModeEnabled = true,
|
||||
UnrestrictedMode = true,
|
||||
};
|
||||
|
||||
@@ -72,6 +80,55 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
// ── Effective feature gates (plan-derived) ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Payload_Bootstrap_IncludedInPro_WithoutGaFlag()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "pro", BootstrapTokenEnabled = false });
|
||||
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_Bootstrap_GaFlagStillWorksForCommunity()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = true });
|
||||
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_Bootstrap_CommunityWithoutFlag_Disabled()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = false });
|
||||
Assert.False(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_UnrestrictedMode_CommunityTenant_ForcedOff_DespiteBothFlags()
|
||||
{
|
||||
// Fail-closed re-gate: a downgraded/expired tenant keeps its stored flags but the
|
||||
// effective value must be false.
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
PlanTier = "free",
|
||||
UnrestrictedModeEnabled = true,
|
||||
UnrestrictedMode = true,
|
||||
});
|
||||
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_UnrestrictedMode_ProWithoutGaGate_Off()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration
|
||||
{
|
||||
PlanTier = "pro",
|
||||
UnrestrictedModeEnabled = false,
|
||||
UnrestrictedMode = true,
|
||||
});
|
||||
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_NullableFlags_FallBackToAgentSideDefaults()
|
||||
{
|
||||
@@ -159,12 +216,14 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.Equal("community", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_EnterpriseTier_NotTrial()
|
||||
[Theory]
|
||||
[InlineData("pro")]
|
||||
[InlineData("enterprise")] // legacy stored value resolves — and reports — as "pro"
|
||||
public void Payload_PermanentProTier_NotTrial(string storedTier)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "enterprise" });
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = storedTier });
|
||||
|
||||
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
|
||||
Assert.Equal("pro", element.GetProperty("edition").GetString());
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(365, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
|
||||
@@ -178,7 +237,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
var expiry = Now.AddDays(10);
|
||||
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
|
||||
|
||||
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
|
||||
Assert.Equal("pro", element.GetProperty("edition").GetString());
|
||||
Assert.True(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
|
||||
@@ -193,4 +252,27 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean(), "consumed trial must not be offered again");
|
||||
}
|
||||
|
||||
// ── contactEmailSet (drives the Pro-requires-contact trial gate + banner) ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(" ", false)]
|
||||
[InlineData("it-ops@contoso.com", true)]
|
||||
public void Payload_ContactEmailSet_ReflectsWhetherAnAddressIsStored(string? stored, bool expected)
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { ContactEmail = stored });
|
||||
|
||||
Assert.Equal(expected, element.GetProperty("contactEmailSet").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
|
||||
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-contact", json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guard: the customer-side grant script (scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1)
|
||||
/// duplicates the feature→permission catalog from <see cref="GraphFeatureCatalog"/> — in its
|
||||
/// <c>$FeatureCatalog</c> hashtable AND in the <c>[ValidateSet(...)]</c> of <c>-Features</c>.
|
||||
/// The sync was previously comment-only ("Keep in lock-step with …") and had no enforcement;
|
||||
/// this test fails the build when either side drifts.
|
||||
/// </summary>
|
||||
public class GraphFeatureCatalogSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public void GrantScript_FeatureCatalog_MatchesSharedCatalog()
|
||||
{
|
||||
var script = ReadGrantScript();
|
||||
|
||||
// Parse: $FeatureCatalog = @{ 'Name' = @('Perm', ...) ... }
|
||||
var blockMatch = Regex.Match(script, @"\$FeatureCatalog\s*=\s*@\{(?<body>[^}]*)\}", RegexOptions.Singleline);
|
||||
Assert.True(blockMatch.Success, "Could not locate the $FeatureCatalog hashtable in the grant script.");
|
||||
|
||||
var scriptCatalog = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match entry in Regex.Matches(blockMatch.Groups["body"].Value, @"'(?<feature>[^']+)'\s*=\s*@\((?<perms>[^)]*)\)"))
|
||||
{
|
||||
var perms = Regex.Matches(entry.Groups["perms"].Value, @"'(?<p>[^']+)'")
|
||||
.Select(m => m.Groups["p"].Value)
|
||||
.ToArray();
|
||||
scriptCatalog[entry.Groups["feature"].Value] = perms;
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
|
||||
scriptCatalog.Keys.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var feature in GraphFeatureCatalog.Features)
|
||||
{
|
||||
Assert.Equal(
|
||||
GraphFeatureCatalog.RequiredPermissions(feature).OrderBy(p => p, StringComparer.OrdinalIgnoreCase),
|
||||
scriptCatalog[feature].OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GrantScript_ValidateSet_MatchesSharedCatalog()
|
||||
{
|
||||
var script = ReadGrantScript();
|
||||
|
||||
var vsMatch = Regex.Match(script, @"\[ValidateSet\((?<body>[^\)]*)\)\]");
|
||||
Assert.True(vsMatch.Success, "Could not locate the [ValidateSet(...)] of -Features in the grant script.");
|
||||
|
||||
var scriptFeatures = Regex.Matches(vsMatch.Groups["body"].Value, @"'(?<f>[^']+)'")
|
||||
.Select(m => m.Groups["f"].Value)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
|
||||
scriptFeatures.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string ReadGrantScript()
|
||||
{
|
||||
var path = Path.Combine(FindRepoRoot(), "scripts", "CustomerSetup", "Grant-AutopilotMonitorAddOn.ps1");
|
||||
Assert.True(File.Exists(path), $"Grant script not found at {path}");
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
+44
-28
@@ -327,17 +327,23 @@ public class GraphFeatureDetectorTests
|
||||
var handler = new SwappableTokenHandler(
|
||||
BuildJwt(Array.Empty<Claim>(), expires: DateTime.UtcNow.AddHours(1))); // pre-grant: no roles
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tokenConfig = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var tokenService = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
cache,
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build());
|
||||
tokenConfig,
|
||||
new EntraAppRegistry(tokenConfig, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
var telemetry = new TelemetryClient(new TelemetryConfiguration { DisableTelemetry = true });
|
||||
var detector = new GraphFeatureDetector(
|
||||
tokenService, cache, NullLogger<GraphFeatureDetector>.Instance, telemetry);
|
||||
@@ -454,6 +460,28 @@ public class GraphFeatureDetectorTests
|
||||
|
||||
// ── Test seam: token service stub + detector factory ──────────────────────────────
|
||||
|
||||
/// <summary>Minimal EntraId config for the GraphTokenService base ctor (no legacy app).</summary>
|
||||
private static Microsoft.Extensions.Configuration.IConfiguration StubEntraConfig() =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Dual app-reg dependencies for the GraphTokenService base ctor. No legacy app configured
|
||||
/// ⇒ the registry always resolves primary and the tenant-config lookup is never consulted.
|
||||
/// </summary>
|
||||
private static EntraAppRegistry StubAppRegistry() =>
|
||||
new(StubEntraConfig(), NullLogger<EntraAppRegistry>.Instance);
|
||||
|
||||
private static AutopilotMonitor.Functions.Services.TenantConfigurationService StubTenantConfigService() =>
|
||||
new(Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
/// <summary>
|
||||
/// Subclass that bypasses the real AAD HTTP call so tests can drive the detector with
|
||||
/// canned <see cref="GraphTokenResult"/> values. Base ctor dependencies are satisfied
|
||||
@@ -469,13 +497,9 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build())
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
}
|
||||
|
||||
@@ -506,13 +530,9 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new NoopHttpClientFactory(),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build())
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
_firstToken = firstToken;
|
||||
_laterToken = laterToken;
|
||||
@@ -549,13 +569,9 @@ public class GraphFeatureDetectorTests
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
cache,
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "test-client-id",
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build())
|
||||
StubEntraConfig(),
|
||||
StubAppRegistry(),
|
||||
StubTenantConfigService())
|
||||
{
|
||||
_onInvalidate = onInvalidate;
|
||||
}
|
||||
|
||||
@@ -33,15 +33,28 @@ public class GraphTokenServiceTests
|
||||
})
|
||||
.Build();
|
||||
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new StubHttpClientFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config);
|
||||
var svc = NewService(config, new StubHttpClientFactory(handler));
|
||||
|
||||
return (svc, handler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the service with the dual app-reg dependencies. No legacy app is configured in
|
||||
/// these tests ⇒ the registry always resolves the primary credentials and the tenant-config
|
||||
/// lookup is never consulted for the decision — a null-returning repo is fine.
|
||||
/// </summary>
|
||||
private static GraphTokenService NewService(IConfiguration config, System.Net.Http.IHttpClientFactory factory) =>
|
||||
new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
factory,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config,
|
||||
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
|
||||
private static int TokenPosts(StubHttpMessageHandler handler, string tenantId)
|
||||
=> handler.Requests.Count(u => u.Contains($"/{tenantId}/oauth2/v2.0/token"));
|
||||
|
||||
@@ -148,11 +161,7 @@ public class GraphTokenServiceTests
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config);
|
||||
var svc = NewService(config, new SingleHandlerFactory(handler));
|
||||
|
||||
// R1 starts; its POST parks in flight (holding the gate, before writing to the cache).
|
||||
var inflight = svc.GetAccessTokenAsync(TenantA);
|
||||
@@ -188,11 +197,7 @@ public class GraphTokenServiceTests
|
||||
["EntraId:ClientSecret"] = "test-secret",
|
||||
})
|
||||
.Build();
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new SingleHandlerFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config);
|
||||
var svc = NewService(config, new SingleHandlerFactory(handler));
|
||||
|
||||
var tasks = Enumerable.Range(0, 10).Select(_ => svc.GetAccessTokenAsync(TenantA)).ToArray();
|
||||
|
||||
@@ -209,6 +214,54 @@ public class GraphTokenServiceTests
|
||||
Assert.Equal(1, handler.Entered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAccessTokenForAppAsync_caches_under_the_supplied_app_without_cross_app_poisoning()
|
||||
{
|
||||
// Dual app-reg window: the homing-flip consent probe mints under EXPLICIT primary
|
||||
// credentials for a still-legacy-homed tenant. Contract: the probe token caches under the
|
||||
// primary key, a second probe is served from cache, and the tenant's normal (legacy-
|
||||
// resolved) path still mints its own token — never the probe's.
|
||||
var handler = new StubHttpMessageHandler()
|
||||
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, "{\"access_token\":\"tok\",\"expires_in\":3599}");
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
["EntraId:ClientSecret"] = "primary-secret",
|
||||
["EntraId:LegacyClientId"] = "bbbbbbbb-0000-0000-0000-000000000002",
|
||||
["EntraId:LegacyClientSecret"] = "legacy-secret",
|
||||
})
|
||||
.Build();
|
||||
var registry = new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance);
|
||||
var svc = new GraphTokenService(
|
||||
NullLogger<GraphTokenService>.Instance,
|
||||
new StubHttpClientFactory(handler),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
config,
|
||||
registry,
|
||||
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
|
||||
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
|
||||
NullLogger<AutopilotMonitor.Functions.Services.TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions())));
|
||||
|
||||
var probe1 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
var probe2 = await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
Assert.Equal("tok", probe1.AccessToken);
|
||||
Assert.Equal("tok", probe2.AccessToken);
|
||||
Assert.Equal(1, TokenPosts(handler, TenantA)); // second probe served from the primary-keyed cache
|
||||
|
||||
// Normal path resolves LEGACY for this tenant (no homing stored) — must be a cache miss
|
||||
// against the probe's primary-keyed entry and mint its own token.
|
||||
var homed = await svc.GetAccessTokenAsync(TenantA);
|
||||
Assert.Equal("tok", homed.AccessToken);
|
||||
Assert.Equal(2, TokenPosts(handler, TenantA));
|
||||
|
||||
// InvalidateTenant drops BOTH apps' entries — the next probe re-mints fresh.
|
||||
svc.InvalidateTenant(TenantA);
|
||||
await svc.GetAccessTokenForAppAsync(TenantA, registry.Primary);
|
||||
Assert.Equal(3, TokenPosts(handler, TenantA));
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -22,9 +23,11 @@ public class HealthCheckServicePoisonQueueTests
|
||||
[Fact]
|
||||
public async Task Check_AllPoisonQueuesEmpty_ReportsHealthy()
|
||||
{
|
||||
// Existing-but-drained poison queues: enumerated, probed, all zero.
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
foreach (var q in MaintenanceService.MonitoredPoisonQueues)
|
||||
probe.Counts[q] = 0;
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
@@ -32,10 +35,40 @@ public class HealthCheckServicePoisonQueueTests
|
||||
Assert.Equal("Poison Queues", check.Name);
|
||||
Assert.Equal("healthy", check.Status);
|
||||
Assert.Equal("All poison queues empty", check.Message);
|
||||
Assert.Equal(MaintenanceService.MonitoredPoisonQueues.Length, check.Details!.Count);
|
||||
Assert.Equal(3, check.Details!.Count);
|
||||
Assert.All(check.Details.Values, v => Assert.Equal("0 messages", v));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_NoPoisonQueuesExist_ReportsHealthy()
|
||||
{
|
||||
// Steady state on a healthy system: poison queues are created lazily on
|
||||
// first poison-move, so an empty enumeration means nothing ever failed.
|
||||
var svc = BuildService(new FakePoisonQueueProbe());
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
|
||||
Assert.Equal("healthy", check.Status);
|
||||
Assert.Equal("All poison queues empty", check.Message);
|
||||
Assert.Empty(check.Details!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_EnumerationFails_ReportsWarningWithError()
|
||||
{
|
||||
// A failed listing must never read as an all-clear.
|
||||
var probe = new FakePoisonQueueProbe
|
||||
{
|
||||
ListException = new InvalidOperationException("auth denied")
|
||||
};
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
|
||||
Assert.Equal("warning", check.Status);
|
||||
Assert.Contains("enumeration failed", check.Message);
|
||||
Assert.Contains("auth denied", (string)check.Details!["error"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_SingleQueueWithBacklog_ReportsWarning()
|
||||
{
|
||||
@@ -155,6 +188,19 @@ public class HealthCheckServicePoisonQueueTests
|
||||
{
|
||||
public Dictionary<string, long> Counts { get; } = new();
|
||||
public Dictionary<string, Exception> ExceptionFor { get; } = new();
|
||||
public Exception? ListException { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<string>> ListPoisonQueuesAsync(CancellationToken ct)
|
||||
{
|
||||
if (ListException is not null)
|
||||
throw ListException;
|
||||
IReadOnlyList<string> names = Counts.Keys
|
||||
.Concat(ExceptionFor.Keys)
|
||||
.Distinct()
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult(names);
|
||||
}
|
||||
|
||||
public Task<long> GetApproximateMessageCountAsync(string queueName, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
@@ -10,7 +9,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// of the parts that decide whether an operator gets paged, without standing up
|
||||
/// the full MaintenanceService dependency graph. Full-service integration is
|
||||
/// exercised by the live timer + ops event tables — what we guard here is the
|
||||
/// classifier math, the dedup-key extractor, and the watch-list inventory.
|
||||
/// classifier math and the dedup-key extractor. The watched queues themselves are
|
||||
/// enumerated dynamically at runtime (every existing <c>-poison</c> queue), so
|
||||
/// there is no static inventory left to guard.
|
||||
/// </summary>
|
||||
public class MaintenanceServicePoisonQueueTests
|
||||
{
|
||||
@@ -116,36 +117,6 @@ public class MaintenanceServicePoisonQueueTests
|
||||
Assert.Equal(string.Empty, MaintenanceService.ExtractQueueName(detailsJson));
|
||||
}
|
||||
|
||||
// --- Watch-list inventory ---
|
||||
|
||||
[Fact]
|
||||
public void MonitoredPoisonQueues_CoversAllProducerQueues()
|
||||
{
|
||||
// Guard against silently adding a new producer queue without a poison entry.
|
||||
// If a new queue is introduced, MonitoredPoisonQueues + this assertion must move together.
|
||||
Assert.Equal(6, MaintenanceService.MonitoredPoisonQueues.Length);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.VulnerabilityCorrelate + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.TelemetryIndexReconcile + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
// PR1 critical-table backup queue (plan §Wave5 #2).
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.CriticalTableBackupPoison,
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
// Self-hosted poll-loop workers (no QueueTrigger) — see SessionDeletionWorker / TenantOffboardingWorker.
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.SessionDeletion + "-poison",
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
Assert.Contains(
|
||||
Constants.QueueNames.TenantOffboardingPoison,
|
||||
MaintenanceService.MonitoredPoisonQueues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_AreCalibratedForStrictMode()
|
||||
{
|
||||
|
||||
@@ -131,14 +131,14 @@ public class McpQuotaServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_EnterpriseEdition_UsesEnterpriseFallbackLimits()
|
||||
public async Task Check_ProEdition_UsesProFallbackLimits()
|
||||
{
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Enterprise);
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("enterprise", d.Plan);
|
||||
Assert.Equal("pro", d.Plan);
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(20000, d.MonthlyLimit);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public class McpUserServiceDelegatedTests
|
||||
_adminRepo.Object, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
_delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
_adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
|
||||
+6
-2
@@ -180,10 +180,14 @@ public class OffboardingMarkerCleanupFunctionTests
|
||||
Assert.Equal(0, result.TenantConfigsSpared);
|
||||
Assert.Equal(0, result.TenantConfigSweepRetries);
|
||||
|
||||
// The sweep ran with the right table + tenant id.
|
||||
Assert.Single(fixture.SafeWipe.WipeCalls);
|
||||
// The sweep ran with the right tables + tenant id: the tombstone itself, plus the
|
||||
// tenant's config-backup partition (a save against the lingering tombstone may have
|
||||
// re-created snapshots after the cascade's bulk wipe).
|
||||
Assert.Equal(2, fixture.SafeWipe.WipeCalls.Count);
|
||||
Assert.Equal(Constants.TableNames.TenantConfiguration, fixture.SafeWipe.WipeCalls[0].Table);
|
||||
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[0].TenantId);
|
||||
Assert.Equal(Constants.TableNames.ConfigurationBackups, fixture.SafeWipe.WipeCalls[1].Table);
|
||||
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[1].TenantId);
|
||||
Assert.False(fixture.Repo.Markers.ContainsKey(TenantId));
|
||||
}
|
||||
|
||||
|
||||
+17
-1
@@ -279,6 +279,20 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
Assert.Contains(Constants.TableNames.TenantGroups, harness.SafeWipeProbe.PropertyOnlyWipes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostDrain_ExactPartitionWipe_CoversConfigurationBackups()
|
||||
{
|
||||
// Pre-write config snapshots (PK=tenantId) must die with the tenant — the row
|
||||
// contents include webhook URLs and the contact address. The bulk wipe runs
|
||||
// before the final TenantConfiguration delete, so no snapshot can be re-created
|
||||
// afterwards by the cascade itself.
|
||||
var harness = Harness.New();
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope());
|
||||
|
||||
Assert.Contains(Constants.TableNames.ConfigurationBackups, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
}
|
||||
|
||||
// ── Harness (copied minimal — only what these tests need) ───────────────────
|
||||
|
||||
private sealed class Harness
|
||||
@@ -425,12 +439,14 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public int WipeCallCount { get; private set; }
|
||||
/// <summary>Table names passed to the property-only (Variant C) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> PropertyOnlyWipes { get; } = new();
|
||||
/// <summary>Table names passed to the exact-partition (Variant A) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> ExactPartitionWipes { get; } = new();
|
||||
public CountingSafeWipeService() : base(
|
||||
new TableStorageService(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance),
|
||||
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
NullLogger<BlobStorageService>.Instance, usesManagedIdentity: false),
|
||||
NullLogger<SafeWipeService>.Instance) { }
|
||||
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; ExactPartitionWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PlanManagementFunction.EvaluateTrialStart"/> — the self-service
|
||||
/// trial verdict, including the Pro-requires-contact gate. The check ORDER is semantic
|
||||
/// and pinned here: terminal conditions (consumed / already Pro) must win over the
|
||||
/// contact-address prompt, and the contact gate exists only at this entry point (never
|
||||
/// as a runtime lockout for existing Pro tenants).
|
||||
/// </summary>
|
||||
public class PlanManagementTrialGateTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 8, 2, 12, 0, 0, DateTimeKind.Utc);
|
||||
private const string Contact = "it-ops@contoso.com";
|
||||
|
||||
[Fact]
|
||||
public void Allowed_WhenCommunityWithContactAndUnconsumedTrial()
|
||||
{
|
||||
var config = new TenantConfiguration { ContactEmail = Contact };
|
||||
|
||||
Assert.Null(PlanManagementFunction.EvaluateTrialStart(config, Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Denied_ContactEmailRequired_WhenNoAddressStored(string? contact)
|
||||
{
|
||||
var config = new TenantConfiguration { ContactEmail = contact };
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("ContactEmailRequired", deny?.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_TrialAlreadyConsumed_WinsOverMissingContact()
|
||||
{
|
||||
// A consumed trial is terminal — prompting for a contact address would be pointless.
|
||||
var config = new TenantConfiguration { TrialConsumed = true, ContactEmail = null };
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("TrialAlreadyConsumed", deny?.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_AlreadyPro_WinsOverMissingContact()
|
||||
{
|
||||
// Existing Pro tenants without a contact address are nagged via the dashboard
|
||||
// banner, never blocked — the verdict must name the Pro state, not the address.
|
||||
var config = new TenantConfiguration { PlanTier = "pro", ContactEmail = null };
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("AlreadyPro", deny?.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_AlreadyPro_ForActiveTrialEdition()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TrialExpiresUtc = Now.AddDays(5),
|
||||
TrialConsumed = false, // hypothetical shape — edition resolution alone must deny
|
||||
ContactEmail = Contact,
|
||||
};
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("AlreadyPro", deny?.Error);
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
// mechanics; the Enterprise-only edition gate is covered by DelegatedAdminEditionGateTests.
|
||||
var delegatedAdmin = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
|
||||
@@ -605,6 +605,87 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
// ── Transactional config patch / backups / revert — GlobalAdminOnly (phase 1) ──
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_GlobalAdmin_IsAllowed()
|
||||
{
|
||||
const string upn = "ga@vendor.example";
|
||||
var h = BuildHarness();
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
|
||||
foreach (var (method, path) in new[]
|
||||
{
|
||||
("PATCH", $"/api/config/{TenantB}/fields"),
|
||||
("GET", $"/api/config/{TenantB}/backups"),
|
||||
("POST", $"/api/config/{TenantB}/revert"),
|
||||
})
|
||||
{
|
||||
var result = await h.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, upn));
|
||||
Assert.True(result.Allowed, $"{method} {path} must admit a Global Admin");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigFieldsSchema_LiteralRoute_WinsOverTenantIdTemplate()
|
||||
{
|
||||
// "fields-schema" must never be swallowed as a {tenantId} of the MemberRead config GET —
|
||||
// FindPolicy's literal-over-template precedence is what this feature's route relies on
|
||||
// (same mechanism as config/all).
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/config/fields-schema");
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("config/fields-schema", entry!.RouteTemplate);
|
||||
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldsSchema_GlobalAdminAllowed_ReaderAndTenantAdminForbidden()
|
||||
{
|
||||
var ga = BuildHarness();
|
||||
ga.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
Assert.True((await ga.Middleware.DecideAsync(
|
||||
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "ga@vendor.example"))).Allowed);
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
Assert.False((await reader.Middleware.DecideAsync(
|
||||
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "reader@vendor.example"))).Allowed);
|
||||
|
||||
var tenantAdmin = BuildHarness();
|
||||
tenantAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
Assert.False((await tenantAdmin.Middleware.DecideAsync(
|
||||
"GET", "/api/config/fields-schema", null, AuthedPrincipal(TenantA, "admin@contoso.com"))).Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigFieldPatchRoutes_TenantAdmin_GlobalReader_Delegated_AreForbidden()
|
||||
{
|
||||
// Phase 1 is deliberately GA-only — even the tenant's OWN admin is denied until the
|
||||
// phase-2 flip to TenantAdminOrGA (+ the caller-tier field deny-list in the service).
|
||||
foreach (var (method, path) in new[]
|
||||
{
|
||||
("PATCH", $"/api/config/{TenantA}/fields"),
|
||||
("GET", $"/api/config/{TenantA}/backups"),
|
||||
("POST", $"/api/config/{TenantA}/revert"),
|
||||
})
|
||||
{
|
||||
var ownAdmin = BuildHarness();
|
||||
ownAdmin.AsTenantAdmin(TenantA, "admin@contoso.com");
|
||||
var adminResult = await ownAdmin.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantA, "admin@contoso.com"));
|
||||
Assert.False(adminResult.Allowed, $"{method} {path} must deny a tenant admin in phase 1");
|
||||
|
||||
var reader = BuildHarness();
|
||||
reader.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
|
||||
var readerResult = await reader.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "reader@vendor.example"));
|
||||
Assert.False(readerResult.Allowed, $"{method} {path} must deny a read-only Global Reader");
|
||||
|
||||
var delegated = BuildHarness();
|
||||
delegated.AsDelegated(TenantA, Constants.DelegatedRoles.DelegatedAdmin);
|
||||
var delegatedResult = await delegated.Middleware.DecideAsync(method, path, null, AuthedPrincipal(TenantB, "msp@partner.example"));
|
||||
Assert.False(delegatedResult.Allowed, $"{method} {path} must deny a delegated (MSP) admin in phase 1");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delegated_OwnHomeTenant_NonMember_IsStillForbidden()
|
||||
{
|
||||
@@ -672,6 +753,8 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed); // the delegated read itself is allowed…
|
||||
// …but no config row was persisted for the MSP's home tenant A (nor any tenant).
|
||||
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
h.ConfigRepo.Verify(r => r.SaveTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Phase 2a: delegated single-tenant access to cross-tenant /api/global/* read endpoints ──
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the static helpers <c>PreviewWhitelistFunction.PickRequesterUpn</c>
|
||||
/// and <c>PreviewWhitelistFunction.IsRealUserUpn</c>.
|
||||
/// Unit tests for the static helpers <c>TenantApprovalService.PickRequesterUpn</c>
|
||||
/// and <c>TenantApprovalService.IsRealUserUpn</c>.
|
||||
/// <para>
|
||||
/// These guard the auto-promote-on-preview-approval path, which previously corrupted
|
||||
/// 10 tenants' TenantAdmins rows by writing the sentinel string
|
||||
@@ -31,7 +31,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("cadm_user@xx3t8.onmicrosoft.com")]
|
||||
public void IsRealUserUpn_AcceptsRealUpnShapes(string upn)
|
||||
{
|
||||
Assert.True(PreviewWhitelistFunction.IsRealUserUpn(upn));
|
||||
Assert.True(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -46,7 +46,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
// The shape-based prefix check is exactly the defense against the corruption
|
||||
// pattern: any new "System (...)" sentinel introduced anywhere in the codebase
|
||||
// is rejected without anyone needing to remember to update this method.
|
||||
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(upn));
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -57,7 +57,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("just-a-string")]
|
||||
public void IsRealUserUpn_RejectsNullOrNonEmailShapes(string? upn)
|
||||
{
|
||||
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(upn));
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -73,7 +73,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("onboarder@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.Equal("onboarder@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,7 +85,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("editor@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,7 +97,7 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("editor@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -112,8 +112,8 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "System (Global Rate Limit Sync)",
|
||||
};
|
||||
|
||||
Assert.Equal("real.requester@contoso.com", PreviewWhitelistFunction.PickRequesterUpn(cfg));
|
||||
Assert.True(PreviewWhitelistFunction.IsRealUserUpn(PreviewWhitelistFunction.PickRequesterUpn(cfg)));
|
||||
Assert.Equal("real.requester@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
Assert.True(TenantApprovalService.IsRealUserUpn(TenantApprovalService.PickRequesterUpn(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -127,8 +127,8 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
UpdatedBy = "System (Global Rate Limit Sync)",
|
||||
};
|
||||
|
||||
var picked = PreviewWhitelistFunction.PickRequesterUpn(cfg);
|
||||
var picked = TenantApprovalService.PickRequesterUpn(cfg);
|
||||
Assert.Equal("System (Global Rate Limit Sync)", picked);
|
||||
Assert.False(PreviewWhitelistFunction.IsRealUserUpn(picked));
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(picked));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -11,43 +9,24 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gate-coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
|
||||
/// call is not exercised here — the const-false arm-flag short-circuits before any client
|
||||
/// construction. These tests instead guard the disarmed-by-default contract:
|
||||
/// Coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
|
||||
/// call is not exercised here — every test drives one of the no-op gates. Pins:
|
||||
/// <list type="bullet">
|
||||
/// <item>The arm constant defaults to <c>false</c>, so an accidental merge cannot ship
|
||||
/// the placeholder template.</item>
|
||||
/// <item><see cref="ResendEmailService.SendAsync"/> never throws under disarmed state —
|
||||
/// not even when the API key is empty or the recipient is null. The handler's
|
||||
/// fail-soft try/catch is a belt; this is the suspenders.</item>
|
||||
/// <item>The disarmed code path logs a debug-level "feature disarmed" line so operators
|
||||
/// have a positive signal in app insights that "the build is intentionally not
|
||||
/// sending farewell emails", as opposed to a silent regression.</item>
|
||||
/// <item><see cref="ResendEmailService.SendAsync"/> never throws — not when the API key
|
||||
/// is empty and not when the recipient is missing. The handler's fail-soft
|
||||
/// try/catch is a belt; this is the suspenders.</item>
|
||||
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
|
||||
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
|
||||
/// interpolated, empty domain falls back to "your organization".</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ResendEmailServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void OffboardFarewellEmailArmed_DefaultsToFalse()
|
||||
{
|
||||
// Guard the disarmed-by-design contract. Flipping this constant to true is the
|
||||
// explicit "scharf schalten" action the user does after finalising the template.
|
||||
// If this test fails, somebody flipped the flag — make sure the template, feedback
|
||||
// form, and unsubscribe story are all signed off before letting the change land.
|
||||
var field = typeof(ResendEmailService).GetField(
|
||||
"OffboardFarewellEmailArmed",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.NotNull(field);
|
||||
var armed = (bool)field!.GetRawConstantValue()!;
|
||||
Assert.False(armed,
|
||||
"OffboardFarewellEmailArmed must default to false until the offboarding farewell template + feedback-form story is signed off.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Disarmed_DoesNotThrow_AndLogsDisarmedDebugLine()
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "any-non-empty-key", logger);
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "ops@contoso.invalid",
|
||||
@@ -56,31 +35,11 @@ public sealed class ResendEmailServiceTests
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Disarmed_EmptyApiKey_StillNoOps()
|
||||
{
|
||||
// Even with no API key configured the disarmed early-return fires FIRST — so the
|
||||
// missing-key debug line must NOT show up; only the disarmed line does. This pins
|
||||
// the gate ordering: disarmed > missing-key > missing-email.
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "ops@contoso.invalid",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_Disarmed_EmptyRecipient_StillNoOps()
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
|
||||
{
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "any-non-empty-key", logger);
|
||||
@@ -91,11 +50,58 @@ public sealed class ResendEmailServiceTests
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("OffboardFarewellEmail disarmed"));
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
|
||||
{
|
||||
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
|
||||
// the key line must show up.
|
||||
var logger = new CapturingLogger<ResendEmailService>();
|
||||
var sut = Build(apiKey: "", logger);
|
||||
|
||||
await sut.SendAsync(
|
||||
toEmail: "",
|
||||
domainName: "contoso.invalid",
|
||||
tenantId: "88888888-8888-8888-8888-888888888888");
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_IsFinal_NoDraftResidue()
|
||||
{
|
||||
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
|
||||
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
Assert.DoesNotContain("DRAFT", html);
|
||||
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
|
||||
Assert.DoesNotContain("TODO", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
|
||||
|
||||
Assert.Contains("contoso.invalid", html);
|
||||
// The mail is sent from a noreply address after portal access is gone, so the
|
||||
// feedback pointers must be external channels the recipient can actually reach.
|
||||
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
|
||||
Assert.Contains("linkedin.com", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
|
||||
{
|
||||
var html = EmailTemplates.GetOffboardingFarewellHtml("");
|
||||
Assert.Contains("your organization", html);
|
||||
}
|
||||
|
||||
private static ResendEmailService Build(string apiKey, ILogger<ResendEmailService> logger)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
|
||||
@@ -29,7 +29,7 @@ public class RevokeEnforcementTests
|
||||
private static DelegatedAdminService BuildService(Mock<IAdminRepository> repo) =>
|
||||
new(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the dry-run (rule-authoring) evaluation path:
|
||||
/// 1. The trace evaluates EVERY condition (no early break) so authors see the full picture.
|
||||
/// 2. The verdict/confidence is outcome-equivalent to the production EvaluateRule path
|
||||
/// (parity tests via AnalyzeSessionAsync).
|
||||
/// 3. The dry-run is side-effect free: only the strict event read touches storage — never
|
||||
/// UpdateSessionStatusAsync, never a rule-result write.
|
||||
/// Plus the draft validation surface of <see cref="DryRunAnalyzeRuleFunction"/>.
|
||||
/// </summary>
|
||||
public class RuleEngineDryRunTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
// ===== Trace semantics =====
|
||||
|
||||
[Fact]
|
||||
public async Task Fired_FullTrace_WithFactorAndInterpolationEvidence()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.ConfidenceFactors = new List<ConfidenceFactor>
|
||||
{
|
||||
new() { Signal = "app_install_failed", Condition = "count >= 2", Weight = 15 },
|
||||
};
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppFailedEvent("app-1", "0x80070005"),
|
||||
AppFailedEvent("app-1", "0x80070005"),
|
||||
};
|
||||
|
||||
var (dry, sessionRepo, ruleRepo) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.Fired, dry.Verdict);
|
||||
Assert.Equal(2, dry.EventCount);
|
||||
Assert.Single(dry.Conditions);
|
||||
Assert.True(dry.Conditions[0].Matched);
|
||||
Assert.Equal(2, dry.Conditions[0].MatchingEventCount);
|
||||
Assert.IsType<Dictionary<string, object>>(dry.Conditions[0].Evidence);
|
||||
|
||||
Assert.Single(dry.ConfidenceFactors);
|
||||
Assert.True(dry.ConfidenceFactors[0].Matched);
|
||||
Assert.Equal(50 + 15, dry.FinalConfidence);
|
||||
|
||||
// Evidence map matches what production would persist — including the factor marker —
|
||||
// so clients can preview {{token}} interpolation against it.
|
||||
Assert.NotNull(dry.MatchedConditions);
|
||||
Assert.True(dry.MatchedConditions!.ContainsKey("app_failure"));
|
||||
Assert.True(dry.MatchedConditions.ContainsKey("factor_app_install_failed"));
|
||||
|
||||
// Side-effect freedom: exactly one strict event read, nothing else.
|
||||
sessionRepo.Verify(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()), Times.Once);
|
||||
sessionRepo.VerifyNoOtherCalls();
|
||||
ruleRepo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequiredConditionNotMet_StillEvaluatesAllConditions()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions.Add(new RuleCondition
|
||||
{
|
||||
Signal = "os_edition", Source = "event_data", EventType = "os_info",
|
||||
DataField = "edition", Operator = "equals", Value = "Enterprise", Required = false,
|
||||
});
|
||||
var events = new List<EnrollmentEvent> { OsInfoEvent("Enterprise") }; // no app_install_failed
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.RequiredConditionNotMet, dry.Verdict);
|
||||
// Production breaks on the first failed required condition; the dry-run keeps going —
|
||||
// the second (optional) condition must appear in the trace, matched.
|
||||
Assert.Equal(2, dry.Conditions.Count);
|
||||
Assert.False(dry.Conditions[0].Matched);
|
||||
Assert.Equal("no matching events", dry.Conditions[0].Evidence);
|
||||
Assert.Equal(0, dry.Conditions[0].MatchingEventCount);
|
||||
Assert.True(dry.Conditions[1].Matched);
|
||||
|
||||
Assert.Null(dry.FinalConfidence);
|
||||
Assert.Empty(dry.ConfidenceFactors); // factor stage never reached, mirrors production
|
||||
Assert.False(dry.WouldMarkSessionAsFailed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreconditionFails_VerdictSkipped_ConditionsStillTraced()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Preconditions = new List<RulePrecondition>
|
||||
{
|
||||
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" },
|
||||
};
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
HardwareSpecEvent(isVirtualMachine: "true"),
|
||||
AppFailedEvent("app-1", "0x1"),
|
||||
};
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.SkippedByPrecondition, dry.Verdict);
|
||||
Assert.Single(dry.Preconditions);
|
||||
Assert.False(dry.Preconditions[0].Passed);
|
||||
// Author still sees that the condition WOULD have matched.
|
||||
Assert.Single(dry.Conditions);
|
||||
Assert.True(dry.Conditions[0].Matched);
|
||||
Assert.Null(dry.FinalConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BelowThreshold_ReportsFinalConfidence()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.BaseConfidence = 30;
|
||||
rule.ConfidenceThreshold = 60;
|
||||
var events = new List<EnrollmentEvent> { AppFailedEvent("app-1", "0x1") };
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.BelowConfidenceThreshold, dry.Verdict);
|
||||
Assert.Equal(30, dry.FinalConfidence);
|
||||
Assert.Equal(60, dry.ConfidenceThreshold);
|
||||
Assert.False(dry.WouldMarkSessionAsFailed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoEvents_ShortCircuits()
|
||||
{
|
||||
var (dry, _, _) = await DryRunAsync(MakeRule(), new List<EnrollmentEvent>());
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.NoEvents, dry.Verdict);
|
||||
Assert.Equal(0, dry.EventCount);
|
||||
Assert.Empty(dry.Conditions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllOptionalNoneMatch_VerdictNoConditionsMatched()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Required = false;
|
||||
var events = new List<EnrollmentEvent> { OsInfoEvent("Pro") };
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.NoConditionsMatched, dry.Verdict);
|
||||
Assert.Null(dry.FinalConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fired_WithMarkSessionAsFailed_OnlyReportsFlag()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.MarkSessionAsFailedDefault = true;
|
||||
var events = new List<EnrollmentEvent> { AppFailedEvent("app-1", "0x1") };
|
||||
|
||||
var (dry, sessionRepo, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.Fired, dry.Verdict);
|
||||
Assert.True(dry.WouldMarkSessionAsFailed);
|
||||
// The flag is REPORTED, never acted upon: no session read, no status write.
|
||||
sessionRepo.Verify(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()), Times.Once);
|
||||
sessionRepo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
// ===== Parity with the production path =====
|
||||
|
||||
public static TheoryData<string> ParityScenarios => new() { "fires", "required_miss", "below_threshold", "precondition_skip" };
|
||||
|
||||
/// <summary>
|
||||
/// The dry-run verdict must be outcome-equivalent to EvaluateRule (via AnalyzeSessionAsync):
|
||||
/// fired ⇔ a RuleResult is produced, and on fire the confidence and evidence keys are identical.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(ParityScenarios))]
|
||||
public async Task DryRunVerdict_MatchesProductionOutcome(string scenario)
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.ConfidenceFactors = new List<ConfidenceFactor>
|
||||
{
|
||||
new() { Signal = "app_install_failed", Condition = "count >= 2", Weight = 15 },
|
||||
};
|
||||
List<EnrollmentEvent> events = scenario switch
|
||||
{
|
||||
"fires" => new() { AppFailedEvent("a", "0x1"), AppFailedEvent("a", "0x1") },
|
||||
"required_miss" => new() { OsInfoEvent("Pro") },
|
||||
"below_threshold" => new() { AppFailedEvent("a", "0x1") },
|
||||
"precondition_skip" => new() { HardwareSpecEvent("true"), AppFailedEvent("a", "0x1") },
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(scenario)),
|
||||
};
|
||||
if (scenario == "below_threshold") { rule.BaseConfidence = 30; rule.ConfidenceThreshold = 60; }
|
||||
if (scenario == "precondition_skip")
|
||||
{
|
||||
rule.Preconditions = new List<RulePrecondition>
|
||||
{
|
||||
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" },
|
||||
};
|
||||
}
|
||||
|
||||
var production = await RunProductionAsync(rule, events);
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
var productionFired = production.Results.Count == 1;
|
||||
Assert.Equal(productionFired, dry.Verdict == RuleDryRunVerdict.Fired);
|
||||
|
||||
if (productionFired)
|
||||
{
|
||||
var result = production.Results[0];
|
||||
Assert.Equal(result.ConfidenceScore, dry.FinalConfidence);
|
||||
Assert.Equal(
|
||||
result.MatchedConditions.Keys.OrderBy(k => k),
|
||||
dry.MatchedConditions!.Keys.OrderBy(k => k));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Draft validation (DryRunAnalyzeRuleFunction.ValidateDraftRule) =====
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRule_NoErrors()
|
||||
{
|
||||
Assert.Empty(DryRunAnalyzeRuleFunction.ValidateDraftRule(MakeRule()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullRule_Error()
|
||||
{
|
||||
Assert.Contains("rule is required", DryRunAnalyzeRuleFunction.ValidateDraftRule(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NoConditions_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions.Clear();
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("at least one condition"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Event_Type")] // wrong case — the evaluator switch is case-sensitive
|
||||
[InlineData("eventtype")]
|
||||
[InlineData("")]
|
||||
public void Validate_UnknownOrMissingSource_Error(string source)
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Source = source;
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("source"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnknownOperator_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Operator = "starts_with";
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("unknown operator 'starts_with'"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CorrelationWithoutJoinField_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Source = "event_correlation";
|
||||
rule.Conditions[0].CorrelateEventType = "app_install_completed";
|
||||
rule.Conditions[0].JoinField = "";
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("joinField"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidRegex_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Operator = "regex";
|
||||
rule.Conditions[0].Value = "([unclosed";
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("not a valid regex"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("count > 3")] // wrong comparator
|
||||
[InlineData("Count >= 3")] // wrong case
|
||||
[InlineData("phase_duration >= 3")] // wrong comparator
|
||||
[InlineData("always")]
|
||||
public void Validate_UnsupportedFactorCondition_Error(string condition)
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.ConfidenceFactors = new List<ConfidenceFactor> { new() { Signal = "x", Condition = condition, Weight = 10 } };
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("not evaluable"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("exists")]
|
||||
[InlineData("count >= 3")]
|
||||
[InlineData("count >=3")]
|
||||
[InlineData("phase_duration > 300")]
|
||||
public void Validate_SupportedFactorCondition_NoError(string condition)
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.ConfidenceFactors = new List<ConfidenceFactor> { new() { Signal = "x", Condition = condition, Weight = 10 } };
|
||||
Assert.Empty(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateSignals_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions.Add(new RuleCondition
|
||||
{
|
||||
Signal = "app_failure", Source = "event_type", EventType = "os_info", Operator = "exists", Required = false,
|
||||
});
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("duplicate signal"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ConfidenceOutOfRange_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.BaseConfidence = 120;
|
||||
rule.ConfidenceThreshold = -5;
|
||||
var errors = DryRunAnalyzeRuleFunction.ValidateDraftRule(rule);
|
||||
Assert.Contains(errors, e => e.Contains("baseConfidence"));
|
||||
Assert.Contains(errors, e => e.Contains("confidenceThreshold"));
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
/// <summary>Simple single-condition rule: required event_type match on app_install_failed,
|
||||
/// base 50 / threshold 40 — fires whenever the event exists.</summary>
|
||||
private static AnalyzeRule MakeRule() => new()
|
||||
{
|
||||
RuleId = "ANALYZE-TEST-001",
|
||||
Title = "Test rule",
|
||||
Severity = "warning",
|
||||
Category = "apps",
|
||||
// Drafts behave like tenant custom rules; also keeps the parity path clear of the
|
||||
// live-catalog sunset filter in GetAllRulesForTenantAsync (built-in ids only).
|
||||
IsBuiltIn = false,
|
||||
BaseConfidence = 50,
|
||||
ConfidenceThreshold = 40,
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Signal = "app_failure", Source = "event_type", EventType = "app_install_failed",
|
||||
Operator = "exists", Value = "", Required = true,
|
||||
},
|
||||
},
|
||||
Explanation = "App {{appId}} failed with {{errorCode}}.",
|
||||
};
|
||||
|
||||
private static EnrollmentEvent AppFailedEvent(string appId, string errorCode) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "app_install_failed",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 1,
|
||||
Data = new Dictionary<string, object> { ["appId"] = appId, ["errorCode"] = errorCode },
|
||||
};
|
||||
|
||||
private static EnrollmentEvent OsInfoEvent(string edition) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "os_info",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 2,
|
||||
Data = new Dictionary<string, object> { ["edition"] = edition },
|
||||
};
|
||||
|
||||
private static EnrollmentEvent HardwareSpecEvent(string isVirtualMachine) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "hardware_spec",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 3,
|
||||
Data = new Dictionary<string, object> { ["isVirtualMachine"] = isVirtualMachine },
|
||||
};
|
||||
|
||||
private static (RuleEngine engine, Mock<ISessionRepository> sessionRepo, Mock<IRuleRepository> ruleRepo) MakeEngine(List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
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<RuleEngineDryRunTests>.Instance);
|
||||
return (engine, sessionRepo, ruleRepo);
|
||||
}
|
||||
|
||||
private static async Task<(RuleDryRun dry, Mock<ISessionRepository> sessionRepo, Mock<IRuleRepository> ruleRepo)> DryRunAsync(
|
||||
AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var (engine, sessionRepo, ruleRepo) = MakeEngine(events);
|
||||
var dry = await engine.DryRunRuleAsync(TenantId, SessionId, rule);
|
||||
return (dry, sessionRepo, ruleRepo);
|
||||
}
|
||||
|
||||
private static async Task<AnalysisOutcome> RunProductionAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
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<RuleEngineDryRunTests>.Instance);
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the reserved built-in rule-ID namespace: tenant custom rules must never
|
||||
/// occupy (ANALYZE|GATHER)-<CATEGORY>-<NUMBER> — a gap or retired ID there can be
|
||||
/// re-shipped as a built-in later, which would silently shadow the tenant's copy at
|
||||
/// merge time. The CUSTOM category ("ANALYZE-CUSTOM-001") is the sanctioned tenant
|
||||
/// namespace; the platform never ships built-ins in a category named CUSTOM.
|
||||
/// Enforced on BOTH the create path and the custom-branch of the update path
|
||||
/// (updates upsert, so a PUT would otherwise bypass the create check).
|
||||
/// </summary>
|
||||
public class RuleIdReservationTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
[Theory]
|
||||
[InlineData("ANALYZE-SEC-002")] // retired gap — the incident that motivated the policy
|
||||
[InlineData("ANALYZE-APP-999")] // unused number in an existing category
|
||||
[InlineData("GATHER-DEVICE-006")]
|
||||
[InlineData("analyze-sec-002")] // case variants are equally confusing → reserved
|
||||
[InlineData("Gather-Net-001")]
|
||||
[InlineData("ANALYZE-NEWCAT-001")] // future built-in categories are covered too
|
||||
public void Reserved_builtin_ids_are_detected(string ruleId)
|
||||
=> Assert.True(RuleIdPolicy.IsReservedBuiltInId(ruleId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("ANALYZE-CUSTOM-001")] // sanctioned tenant namespace (portal suggestion)
|
||||
[InlineData("GATHER-CUSTOM-042")]
|
||||
[InlineData("ANALYZE-ID-001-CUSTOM")] // template copies end in -CUSTOM, not a number
|
||||
[InlineData("CONTOSO-WIFI-001")] // organization prefix
|
||||
[InlineData("ANALYZE-SIDECAR-PROVIDER")] // no trailing number (existing prod custom rule)
|
||||
[InlineData("dcu-restart-required")] // freeform (existing prod custom rule)
|
||||
[InlineData("")]
|
||||
public void Custom_schemes_are_allowed(string ruleId)
|
||||
=> Assert.False(RuleIdPolicy.IsReservedBuiltInId(ruleId));
|
||||
|
||||
// ---- create path -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_create_rejects_reserved_id_before_touching_storage()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.CreateRuleAsync(TenantId, new AnalyzeRule { RuleId = "ANALYZE-SEC-002" }));
|
||||
|
||||
Assert.Contains("reserved", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
repo.VerifyNoOtherCalls(); // rejected before any storage lookup
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_create_rejects_reserved_id_before_touching_storage()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.CreateRuleAsync(TenantId, new GatherRule { RuleId = "GATHER-NET-099" }));
|
||||
|
||||
Assert.Contains("reserved", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_create_still_accepts_custom_namespace()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), TenantId)).ReturnsAsync(true);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
Assert.True(await service.CreateRuleAsync(TenantId, new AnalyzeRule { RuleId = "ANALYZE-CUSTOM-001" }));
|
||||
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.Is<AnalyzeRule>(x => x.RuleId == "ANALYZE-CUSTOM-001"), TenantId), Times.Once);
|
||||
}
|
||||
|
||||
// ---- update path (upserts — must not be a bypass) ----------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_update_custom_branch_rejects_reserved_id()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
// IsBuiltIn=false routes into the custom-rule upsert branch.
|
||||
var rule = new AnalyzeRule { RuleId = "ANALYZE-SEC-002", IsBuiltIn = false, IsCommunity = false };
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_update_builtin_state_toggle_is_unaffected()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
repo.Setup(r => r.StoreRuleStateAsync(TenantId, "ANALYZE-SEC-001", It.IsAny<RuleState>())).ReturnsAsync(true);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
// Disabling a real built-in stays possible — that flow stores per-tenant state only.
|
||||
var rule = new AnalyzeRule { RuleId = "ANALYZE-SEC-001", IsBuiltIn = true, Enabled = false };
|
||||
|
||||
Assert.True(await service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.Verify(r => r.StoreRuleStateAsync(TenantId, "ANALYZE-SEC-001", It.IsAny<RuleState>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_update_custom_branch_rejects_reserved_id_when_no_global_row_exists()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
// Retired scenario: the ID is reserved but no global row exists anymore.
|
||||
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
|
||||
var rule = new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-DEVICE-099",
|
||||
Title = "squat",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\Microsoft\\Provisioning",
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.Verify(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_update_builtin_state_toggle_is_unaffected()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>
|
||||
{
|
||||
new() { RuleId = "GATHER-DEVICE-001", IsBuiltIn = true },
|
||||
});
|
||||
repo.Setup(r => r.StoreRuleStateAsync(TenantId, "GATHER-DEVICE-001", It.IsAny<RuleState>())).ReturnsAsync(true);
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
|
||||
var rule = new GatherRule { RuleId = "GATHER-DEVICE-001", Enabled = false };
|
||||
|
||||
Assert.True(await service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.Verify(r => r.StoreRuleStateAsync(TenantId, "GATHER-DEVICE-001", It.IsAny<RuleState>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the merge collision guard in <see cref="AnalyzeRuleService.GetAllRulesForTenantAsync"/>
|
||||
/// and <see cref="GatherRuleService.GetAllRulesForTenantAsync"/>: a tenant-partition row whose
|
||||
/// RuleId collides with a merged global rule (legacy debris from the pre-global-partition
|
||||
/// seeding era, e.g. tenant 5ca2b350's ANALYZE-ID-002 copy from 2026-03) must be skipped —
|
||||
/// the global definition wins. Without the guard the same RuleId appears twice in the merged
|
||||
/// list and every ToDictionary(r => r.RuleId) consumer throws (prod: ArgumentException in
|
||||
/// AnalyzeOnEnrollmentEndHandler.SafeNotifyRuleChannelsAsync, killing rule notifications).
|
||||
/// </summary>
|
||||
public class RuleMergeCollisionGuardTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_stale_tenant_copy_of_global_rule_is_skipped()
|
||||
{
|
||||
var collidingId = BuiltInAnalyzeRules.GetAll().First().RuleId;
|
||||
var repo = CreateAnalyzeRepo(tenantRules: new List<AnalyzeRule>
|
||||
{
|
||||
StaleTenantAnalyzeCopy(collidingId),
|
||||
});
|
||||
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
var instances = merged.Where(r => r.RuleId == collidingId).ToList();
|
||||
Assert.Single(instances);
|
||||
Assert.True(instances[0].IsBuiltIn, "The global built-in definition must win over the stale tenant copy.");
|
||||
// The exact downstream consumer that crashed in prod must work again.
|
||||
_ = merged.ToDictionary(r => r.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_non_colliding_custom_rule_still_merges()
|
||||
{
|
||||
var repo = CreateAnalyzeRepo(tenantRules: new List<AnalyzeRule>
|
||||
{
|
||||
StaleTenantAnalyzeCopy("TENANT-CUSTOM-XYZ"),
|
||||
});
|
||||
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
Assert.Contains(merged, r => r.RuleId == "TENANT-CUSTOM-XYZ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_stale_tenant_copy_of_global_rule_is_skipped()
|
||||
{
|
||||
var collidingId = BuiltInGatherRules.GetAll().First().RuleId;
|
||||
var repo = CreateGatherRepo(tenantRules: new List<GatherRule>
|
||||
{
|
||||
StaleTenantGatherCopy(collidingId),
|
||||
});
|
||||
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
var instances = merged.Where(r => r.RuleId == collidingId).ToList();
|
||||
Assert.Single(instances);
|
||||
Assert.True(instances[0].IsBuiltIn, "The global built-in definition must win over the stale tenant copy.");
|
||||
_ = merged.ToDictionary(r => r.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_non_colliding_custom_rule_still_merges()
|
||||
{
|
||||
var repo = CreateGatherRepo(tenantRules: new List<GatherRule>
|
||||
{
|
||||
StaleTenantGatherCopy("TENANT-CUSTOM-XYZ"),
|
||||
});
|
||||
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
var merged = await service.GetAllRulesForTenantAsync(TenantId);
|
||||
|
||||
Assert.Contains(merged, r => r.RuleId == "TENANT-CUSTOM-XYZ");
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
private static AnalyzeRule StaleTenantAnalyzeCopy(string ruleId) => new()
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = "Stale tenant copy",
|
||||
Description = "Legacy per-tenant seeded row",
|
||||
Severity = "warning",
|
||||
Category = "test",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
private static GatherRule StaleTenantGatherCopy(string ruleId) => new()
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Title = "Stale tenant copy",
|
||||
Description = "Legacy per-tenant seeded row",
|
||||
Category = "test",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\X",
|
||||
Trigger = "startup",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Enabled = true,
|
||||
};
|
||||
|
||||
/// <summary>DB state = exactly the shipped catalog (clean seed diff), plus the given tenant rows.</summary>
|
||||
private static Mock<IRuleRepository> CreateAnalyzeRepo(List<AnalyzeRule> tenantRules)
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(BuiltInAnalyzeRules.GetAll().ToList());
|
||||
repo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(tenantRules);
|
||||
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteAnalyzeRuleAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>())).ReturnsAsync((0, 0));
|
||||
return repo;
|
||||
}
|
||||
|
||||
private static Mock<IRuleRepository> CreateGatherRepo(List<GatherRule> tenantRules)
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>();
|
||||
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(BuiltInGatherRules.GetAll().ToList());
|
||||
repo.Setup(r => r.GetGatherRulesAsync(TenantId)).ReturnsAsync(tenantRules);
|
||||
repo.Setup(r => r.GetRuleStatesAsync(TenantId)).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
repo.Setup(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteGatherRuleAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteRuleStatesForRuleIdAcrossTenantsAsync(It.IsAny<string>())).ReturnsAsync((0, 0));
|
||||
return repo;
|
||||
}
|
||||
}
|
||||
@@ -90,4 +90,55 @@ public class SecurityValidatorTests
|
||||
{
|
||||
Assert.False(SecurityValidator.IsValidGuid(payload));
|
||||
}
|
||||
|
||||
// --- Intune device id extraction from the MDM client certificate subject ---
|
||||
// Intune MDM Device CA certs carry the Intune managedDevice id as the Subject CN
|
||||
// (field-verified 2026-08-06 on a W365 Cloud PC). This is the identity anchor for the
|
||||
// CloudPc validator, so the parse must be strict: CN present AND a canonical GUID.
|
||||
|
||||
[Fact]
|
||||
public void TryGetIntuneDeviceId_PlainCnGuid_Extracts()
|
||||
{
|
||||
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"CN=07623d56-1e77-4948-bff5-5bdac8167560", out var id));
|
||||
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetIntuneDeviceId_UppercaseGuid_NormalizedToLower()
|
||||
{
|
||||
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"CN=07623D56-1E77-4948-BFF5-5BDAC8167560", out var id));
|
||||
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetIntuneDeviceId_MultiRdnSubject_FindsCn()
|
||||
{
|
||||
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"O=Contoso, CN=07623d56-1e77-4948-bff5-5bdac8167560, C=DE", out var id));
|
||||
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("O=Contoso, C=DE")] // no CN at all
|
||||
[InlineData("CN=not-a-guid")] // CN present but not a GUID
|
||||
[InlineData("CN=hostname.contoso.com")] // typical non-Intune device cert
|
||||
[InlineData("CN=' or '1'='1")] // injection payload never survives
|
||||
public void TryGetIntuneDeviceId_InvalidSubjects_ReturnFalse(string? subject)
|
||||
{
|
||||
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(subject, out var id));
|
||||
Assert.Null(id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetIntuneDeviceId_NonGuidFirstCn_IsDefinitive()
|
||||
{
|
||||
// A first CN that is not a GUID means "not an Intune MDM device cert shape" — a
|
||||
// later GUID-valued CN must NOT resurrect the parse (no CN-shopping).
|
||||
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"CN=hostname, CN=07623d56-1e77-4948-bff5-5bdac8167560", out _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,18 +483,29 @@ public class SessionDeletionWorkerTests
|
||||
};
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(envelope), dequeueCount: 1);
|
||||
|
||||
// Handler hangs for 350ms so the heartbeat task ticks at least 5×.
|
||||
// De-flake (CI: a fixed 350ms handler window raced the 50ms heartbeat timer on loaded
|
||||
// runners — the handler could finish before 3 heartbeats fired, after which the awaited
|
||||
// condition can never become true). Instead the handler holds the message in-flight
|
||||
// UNTIL the heartbeats have been observed: the heartbeat task ticks for as long as the
|
||||
// handler runs, so the condition is reached deterministically regardless of scheduler
|
||||
// jitter. A genuinely broken heartbeat leaves the loop to the worker's cancellation at
|
||||
// RunUntilAsync's ceiling, and the Times.AtLeast(3) below fails as intended.
|
||||
harness.HandlerMock.Setup(h => h.HandleAsync(
|
||||
It.IsAny<SessionDeletionEnvelope>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionDeletionEnvelope _, CancellationToken ct) =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(350), ct);
|
||||
// Own deadline besides ct: if the heartbeat were broken AND shutdown never
|
||||
// cancelled the handler token, the test must still terminate (and then fail
|
||||
// on the verify) instead of hanging in StopAsync.
|
||||
var handlerDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
|
||||
while (!ct.IsCancellationRequested && harness.HeartbeatCount() < 3 && DateTime.UtcNow < handlerDeadline)
|
||||
await Task.Delay(10, CancellationToken.None);
|
||||
});
|
||||
|
||||
await harness.RunUntilAsync(() => harness.HeartbeatCount() >= 3);
|
||||
|
||||
// Heartbeat called at least 3× while the handler was busy (350ms / 50ms ≈ 7, minus a few
|
||||
// due to scheduling jitter). One call is enough to prove the heartbeat task is wired.
|
||||
// Heartbeat fired at least 3× while the handler was busy — proves the heartbeat task
|
||||
// is wired and keeps extending visibility for the whole duration of a long handler run.
|
||||
harness.MainQueue.Verify(q => q.UpdateMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()),
|
||||
|
||||
@@ -43,6 +43,8 @@ public class SessionIndexProjectionTests
|
||||
["ExcessiveEventsAutoActioned"] = true,
|
||||
// Self-deploying/kiosk profile marker (session 320b3bf7) — search-filterable.
|
||||
["IsSelfDeployingProfile"] = true,
|
||||
// W365 Cloud PC marker (agent-detected, sticky-true) — search-filterable.
|
||||
["IsCloudPc"] = true,
|
||||
// Device-validation path recorded at registration — shown on the session detail page.
|
||||
["ValidatedBy"] = "AutopilotV1",
|
||||
// Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges these).
|
||||
@@ -72,6 +74,7 @@ public class SessionIndexProjectionTests
|
||||
Assert.True(idx.GetBoolean("ExcessiveEventsAlerted"));
|
||||
Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned"));
|
||||
Assert.True(idx.GetBoolean("IsSelfDeployingProfile"));
|
||||
Assert.True(idx.GetBoolean("IsCloudPc"));
|
||||
Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy"));
|
||||
Assert.Equal(513.5, idx.GetDouble("AvgApiLatencyMs"));
|
||||
Assert.Equal(100, idx.GetInt32("ApiRequestCount"));
|
||||
@@ -103,6 +106,7 @@ public class SessionIndexProjectionTests
|
||||
Assert.Equal(0, idx.GetInt32("RebootCount"));
|
||||
Assert.False(idx.GetBoolean("ExcessiveEventsAlerted"));
|
||||
Assert.False(idx.GetBoolean("IsSelfDeployingProfile"));
|
||||
Assert.False(idx.GetBoolean("IsCloudPc"));
|
||||
Assert.Equal(string.Empty, idx.GetString("ImeAgentVersion"));
|
||||
}
|
||||
}
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Plausibility guard for the session anchor (session df1fcf47): ONE do_telemetry event whose
|
||||
/// OccurredUtc inherited a tz-shifted IME log line 8h in the past dragged StartedAt back ~7h
|
||||
/// and inflated the WG Part 1 DurationSeconds from ~1h16m to 8h15m. The earliest-event
|
||||
/// timestamp — whether from the ingest batch or the Events-table probe — may only re-anchor
|
||||
/// StartedAt / the duration start when it sits within
|
||||
/// <see cref="TableStorageService.MaxStartedAtBackwardShift"/> of the current anchor.
|
||||
/// </summary>
|
||||
public class SessionStartedAtBackwardShiftGuardTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static readonly DateTime StartedAt = new(2026, 7, 29, 18, 45, 15, DateTimeKind.Utc);
|
||||
|
||||
// ==================================================== StartedAt alignment ====
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementEventCount_rejects_backward_shift_beyond_window()
|
||||
{
|
||||
var harness = new Harness(SessionRow());
|
||||
|
||||
// The df1fcf47 shape: one event 8h before the session's real start.
|
||||
var skewed = StartedAt.AddHours(-7);
|
||||
var result = await harness.Sut.IncrementSessionEventCountAsync(
|
||||
TenantId, SessionId, increment: 1,
|
||||
earliestEventTimestamp: skewed, latestEventTimestamp: StartedAt.AddMinutes(10));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.False(harness.Written!.ContainsKey("StartedAt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementEventCount_accepts_backward_shift_within_window()
|
||||
{
|
||||
var harness = new Harness(SessionRow());
|
||||
|
||||
// Legitimate pre-registration backlog: minutes, not hours.
|
||||
var earlier = StartedAt.AddMinutes(-30);
|
||||
var result = await harness.Sut.IncrementSessionEventCountAsync(
|
||||
TenantId, SessionId, increment: 1,
|
||||
earliestEventTimestamp: earlier, latestEventTimestamp: StartedAt.AddMinutes(10));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.Equal(new DateTimeOffset(earlier), harness.Written!.GetDateTimeOffset("StartedAt"));
|
||||
}
|
||||
|
||||
// ============================================= Pending (WG Part 1) duration ====
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_duration_ignores_skewed_stored_earliest_event()
|
||||
{
|
||||
// Exact df1fcf47 replica: the poisoned event is already IN the Events table
|
||||
// (probe returns it) AND rides in the batch as earliestEventTimestamp.
|
||||
var poisoned = StartedAt.AddHours(-7);
|
||||
var lastEvent = new DateTime(2026, 7, 29, 20, 1, 9, DateTimeKind.Utc);
|
||||
var harness = new Harness(SessionRow(), earliestStoredEvent: poisoned);
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending,
|
||||
earliestEventTimestamp: poisoned, latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
// Duration anchors on StartedAt (18:45:15 → 20:01:09 = 4554s), not on the skewed
|
||||
// event (which would give 29690s less the 7h offset — the original inflation).
|
||||
Assert.Equal((int)(lastEvent - StartedAt).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
Assert.False(harness.Written.ContainsKey("StartedAt"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pending_duration_uses_plausible_stored_earliest_event()
|
||||
{
|
||||
var earliest = StartedAt.AddMinutes(-5);
|
||||
var lastEvent = StartedAt.AddMinutes(70);
|
||||
var harness = new Harness(SessionRow(), earliestStoredEvent: earliest);
|
||||
|
||||
var ok = await harness.Sut.UpdateSessionStatusAsync(
|
||||
TenantId, SessionId, SessionStatus.Pending,
|
||||
latestEventTimestamp: lastEvent);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(harness.Written);
|
||||
Assert.Equal((int)(lastEvent - earliest).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
|
||||
}
|
||||
|
||||
// ============================================================ Harness ====
|
||||
|
||||
private static TableEntity SessionRow()
|
||||
{
|
||||
var entity = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(StartedAt),
|
||||
["Status"] = "InProgress",
|
||||
["EventCount"] = 10,
|
||||
};
|
||||
entity.ETag = new ETag("0xEXISTING");
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SDK-mock harness (same shape as <see cref="StoreSessionReregistrationPreserveTests"/>):
|
||||
/// Sessions table configured for read + merge-capture; Events table optionally serves the
|
||||
/// earliest-event probe; SessionsIndex stays unconfigured — its sync is swallow-all in the
|
||||
/// SUT and irrelevant here.
|
||||
/// </summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public TableStorageService Sut { get; }
|
||||
public TableEntity? Written { get; private set; }
|
||||
|
||||
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null)
|
||||
{
|
||||
var sessions = new Mock<TableClient>();
|
||||
|
||||
sessions.Setup(t => t.GetEntityAsync<TableEntity>(
|
||||
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(existing, new Mock<Response>().Object));
|
||||
|
||||
var ifExists = new Mock<NullableResponse<TableEntity>>();
|
||||
ifExists.SetupGet(r => r.HasValue).Returns(true);
|
||||
ifExists.SetupGet(r => r.Value).Returns(existing);
|
||||
sessions.Setup(t => t.GetEntityIfExistsAsync<TableEntity>(
|
||||
TenantId, SessionId, It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ifExists.Object);
|
||||
|
||||
sessions.Setup(t => t.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
|
||||
{
|
||||
Written = e;
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
var events = new Mock<TableClient>();
|
||||
var eventRows = earliestStoredEvent.HasValue
|
||||
? new[]
|
||||
{
|
||||
new TableEntity($"{TenantId}_{SessionId}", $"{earliestStoredEvent.Value:yyyyMMddHHmmssfff}_0000000001")
|
||||
{
|
||||
["OccurredUtc"] = new DateTimeOffset(earliestStoredEvent.Value),
|
||||
},
|
||||
}
|
||||
: Array.Empty<TableEntity>();
|
||||
events.Setup(t => t.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[]
|
||||
{
|
||||
Page<TableEntity>.FromValues(eventRows, null, new Mock<Response>().Object),
|
||||
}));
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(sessions.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Events)).Returns(events.Object);
|
||||
// SessionsIndex intentionally unconfigured → null client → SUT swallows.
|
||||
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,16 @@ public class SessionStatusTransitionTests
|
||||
Assert.True(TableStorageService.IsTerminalTransitionAllowed("Succeeded", incoming));
|
||||
}
|
||||
|
||||
// ---- Pending is idempotent: the agent emits whiteglove_complete twice (raw Shell-Core
|
||||
// event + engine timeline event) — the second Pending write must not count as a
|
||||
// transition, it fired duplicate Pre-Provisioning Completed webhooks ----
|
||||
[Fact]
|
||||
public void Pending_over_Pending_is_refused()
|
||||
{
|
||||
Assert.False(TableStorageService.IsTerminalTransitionAllowed("Pending", SessionStatus.Pending));
|
||||
Assert.True(TableStorageService.IsTerminalTransitionAllowed("InProgress", SessionStatus.Pending));
|
||||
}
|
||||
|
||||
// ================= ComputeReconcileReason =================
|
||||
// The reconcile hygiene clears FailureReason/FailureSnapshotJson on every Succeeded write;
|
||||
// ReconcileReason is what keeps a backend-declared success explainable (session 294ab5b4).
|
||||
|
||||
+63
@@ -168,6 +168,69 @@ public class StoreSessionReregistrationPreserveTests
|
||||
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_cloudPc_isStickyTrue_acrossReregistration()
|
||||
{
|
||||
// W365 marker mirrors the self-deploying sticky-true contract: once a boot observed
|
||||
// the Cloud PC markers, a later re-registration whose registry/service probe degraded
|
||||
// (registration.IsCloudPc=false) must NOT clear the fact.
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
["IsCloudPc"] = true,
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var registration = new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
SerialNumber = "SN-1",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Model-X",
|
||||
DeviceName = "PC-1",
|
||||
IsCloudPc = false, // fresh marker read degraded
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_cloudPc_upgradesFalseToTrue_onReregistration()
|
||||
{
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
["IsCloudPc"] = false,
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var registration = new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
SerialNumber = "SN-1",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Model-X",
|
||||
DeviceName = "PC-1",
|
||||
IsCloudPc = true,
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
|
||||
}
|
||||
|
||||
// ============================================================ Harness ====
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
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>
|
||||
/// Unit tests for <see cref="TenantApprovalService.ApproveWithSideEffectsAsync"/> — the
|
||||
/// idempotency contract: the conditional whitelist insert decides which caller "wins" a
|
||||
/// concurrent activation, and only the winner runs the side effects (auto-promote,
|
||||
/// welcome mail). The loser returns false and touches nothing.
|
||||
/// </summary>
|
||||
public class TenantApprovalServiceTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string ApprovedBy = "ga@contoso.com";
|
||||
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<ResendEmailService> _resendMock;
|
||||
private readonly TenantApprovalService _sut;
|
||||
|
||||
public TenantApprovalServiceTests()
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_tenantConfigMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
|
||||
_previewMock = new Mock<PreviewWhitelistService>(
|
||||
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_resendMock = new Mock<ResendEmailService>(
|
||||
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
|
||||
NullLogger<ResendEmailService>.Instance);
|
||||
|
||||
_sut = new TenantApprovalService(
|
||||
NullLogger<TenantApprovalService>.Instance,
|
||||
_previewMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
|
||||
_resendMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlreadyApproved_ReturnsFalse_AndRunsNoSideEffects()
|
||||
{
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(false);
|
||||
|
||||
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
Assert.False(newlyApproved);
|
||||
// The side-effect block (auto-promote + welcome mail) starts with the config read —
|
||||
// it must never run for a lost activation race.
|
||||
_tenantConfigMock.Verify(x => x.GetConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
_previewMock.Verify(x => x.GetNotificationEmailAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_ReturnsTrue()
|
||||
{
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
||||
// Default config has no OnboardedBy/UpdatedBy UPN → auto-promote is skipped by the
|
||||
// shape guard; no notification email → no mail. Only the return value is under test.
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
|
||||
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
Assert.True(newlyApproved);
|
||||
_previewMock.Verify(x => x.GetNotificationEmailAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
// --- TrySendWelcomeEmailAsync: the once-only send shared by the approval path and the
|
||||
// notification-email save path. Ordering contract under test: the sent-marker must be
|
||||
// consumed strictly AFTER the address check (a marker consumed with no address would
|
||||
// permanently suppress the mail), and a lost marker race must not send.
|
||||
|
||||
[Fact]
|
||||
public async Task TrySendWelcomeEmail_NoAddressYet_DefersWithoutConsumingMarker()
|
||||
{
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
||||
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySendWelcomeEmail_AddressPresent_MarkerWon_SendsOnce()
|
||||
{
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
||||
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.DomainName = "contoso.com";
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
|
||||
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.True(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySendWelcomeEmail_MarkerAlreadyConsumed_DoesNotSend()
|
||||
{
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
||||
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(false);
|
||||
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySendWelcomeEmail_MarkerStorageError_ReturnsFalse_WithoutSending()
|
||||
{
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
||||
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId))
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
|
||||
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveWithSideEffects_AddressAlreadySaved_SendsWelcomeMail()
|
||||
{
|
||||
// The user saved their notification email BEFORE activation (manual-approve shape):
|
||||
// the approval path itself must send.
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync("it@contoso.com");
|
||||
_previewMock.Setup(x => x.TryMarkWelcomeEmailSentAsync(TenantId)).ReturnsAsync(true);
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
|
||||
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
Assert.True(newlyApproved);
|
||||
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Activation;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
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>
|
||||
/// Unit tests for <see cref="TenantAutoApproveHandler"/> — the consumer logic of the
|
||||
/// <c>tenant-auto-approve</c> queue. Every early return must DROP (never approve);
|
||||
/// only the happy path may call the shared activation service, and it must use the
|
||||
/// "System (auto-approve)" sentinel that the UPN shape-guard rejects as a promotable user.
|
||||
/// </summary>
|
||||
public class TenantAutoApproveHandlerTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string Upn = "first.user@contoso.com";
|
||||
|
||||
private readonly Mock<AdminConfigurationService> _adminConfigMock;
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<TenantApprovalService> _approvalMock;
|
||||
private readonly List<OpsEventEntry> _savedOpsEvents = new();
|
||||
private readonly TenantAutoApproveHandler _sut;
|
||||
|
||||
public TenantAutoApproveHandlerTests()
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
_adminConfigMock = new Mock<AdminConfigurationService>(
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
|
||||
_tenantConfigMock = new Mock<TenantConfigurationService>(
|
||||
configRepo, NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
|
||||
_previewMock = new Mock<PreviewWhitelistService>(
|
||||
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_approvalMock = new Mock<TenantApprovalService>(
|
||||
NullLogger<TenantApprovalService>.Instance,
|
||||
_previewMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
|
||||
new Mock<ResendEmailService>(
|
||||
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
|
||||
NullLogger<ResendEmailService>.Instance).Object)
|
||||
{ CallBase = false };
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(e => { lock (_savedOpsEvents) _savedOpsEvents.Add(e); })
|
||||
.Returns(Task.CompletedTask);
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
_adminConfigMock.Object,
|
||||
new TelegramNotificationService(new HttpClient(), configRepo,
|
||||
NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(),
|
||||
NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
_sut = new TenantAutoApproveHandler(
|
||||
NullLogger<TenantAutoApproveHandler>.Instance,
|
||||
_adminConfigMock.Object,
|
||||
_previewMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
_approvalMock.Object,
|
||||
opsService);
|
||||
|
||||
// Defaults: flag on, tenant not yet approved, config exists and is enabled. The
|
||||
// handler reads the config via the cache-bypassing GetConfigurationFreshAsync — the
|
||||
// suspension gate must never decide on a stale per-instance cache.
|
||||
_adminConfigMock.Setup(x => x.IsAutoApproveNewTenantsEnabledAsync()).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(false);
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
_approvalMock.Setup(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
}
|
||||
|
||||
private static TenantAutoApproveEnvelope Envelope() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SignupUpn = Upn,
|
||||
EnqueuedAtUtc = new DateTime(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task FlagOff_DropsWithoutApproving()
|
||||
{
|
||||
_adminConfigMock.Setup(x => x.IsAutoApproveNewTenantsEnabledAsync()).ReturnsAsync(false);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlreadyApproved_Drops()
|
||||
{
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(true);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantConfigMissing_Drops()
|
||||
{
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuspendedTenant_Drops()
|
||||
{
|
||||
var suspended = TenantConfiguration.CreateDefault(TenantId);
|
||||
suspended.Disabled = true;
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ReturnsAsync(suspended);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigReadError_Propagates_SoTheMessageRetries()
|
||||
{
|
||||
// A storage blip must NOT silently drop the message (that would permanently
|
||||
// downgrade the tenant to manual approval) — it throws, the queue retries, and
|
||||
// the flag + suspension gates re-run on every attempt.
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId))
|
||||
.ThrowsAsync(new InvalidOperationException("storage blip"));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.HandleAsync(Envelope(), CancellationToken.None));
|
||||
|
||||
_approvalMock.Verify(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HappyPath_ApprovesWithSentinel_AndEmitsOpsEvent()
|
||||
{
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
_approvalMock.Verify(
|
||||
x => x.ApproveWithSideEffectsAsync(TenantId, TenantAutoApproveHandler.AutoApprovedBy),
|
||||
Times.Once);
|
||||
|
||||
var evt = Assert.Single(_savedOpsEvents);
|
||||
Assert.Equal("TenantAutoApproved", evt.EventType);
|
||||
Assert.Equal(TenantId, evt.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivationRaceLost_DropsWithoutOpsEvent()
|
||||
{
|
||||
// Double signup enqueues two envelopes; the conditional whitelist insert lets
|
||||
// exactly one worker win. The loser must not emit a second TenantAutoApproved
|
||||
// ops event (the winner's side effects already ran).
|
||||
_approvalMock.Setup(x => x.ApproveWithSideEffectsAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
await _sut.HandleAsync(Envelope(), CancellationToken.None);
|
||||
|
||||
Assert.Empty(_savedOpsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoApprovedBySentinel_IsRejectedByUpnShapeGuard()
|
||||
{
|
||||
// The sentinel must never pass the promotable-user shape check — that is the
|
||||
// defense against writing a system string into the TenantAdmins table.
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(TenantAutoApproveHandler.AutoApprovedBy));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
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 Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Transactional config patch/revert: field gates, fail-closed backup, CAS retry,
|
||||
/// exactly-these-fields verification with automatic rollback, and the phase-2
|
||||
/// caller-tier deny-lists. The harness emulates real table CAS semantics (versioned
|
||||
/// ETags, conditional replace) so the retry/rollback paths are exercised honestly.
|
||||
/// </summary>
|
||||
public class TenantConfigPatchServiceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Ga = "ga@operator.example";
|
||||
|
||||
/// <summary>In-memory row with real CAS semantics + optional write-time corruption hook.</summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<IConfigRepository> Repo { get; } = new();
|
||||
public Mock<IConfigBackupRepository> Backup { get; } = new();
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public TenantConfigPatchService Sut { get; }
|
||||
|
||||
public TenantConfiguration? Current;
|
||||
public int Version = 1;
|
||||
public List<ConfigBackupEntry> Backups { get; } = new();
|
||||
public int ReplaceCalls;
|
||||
/// <summary>Applied to the stored row on every successful replace — simulates serialization drift.</summary>
|
||||
public Func<TenantConfiguration, TenantConfiguration>? CorruptOnWrite;
|
||||
/// <summary>Bumps the version WITHOUT a replace before the Nth replace call — simulates a concurrent writer.</summary>
|
||||
public int? StealEtagBeforeReplaceCall;
|
||||
|
||||
public Harness(TenantConfiguration? stored)
|
||||
{
|
||||
Current = stored;
|
||||
|
||||
Repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
|
||||
.ReturnsAsync(() => Current == null
|
||||
? ((TenantConfiguration, string)?)null
|
||||
: (Clone(Current), $"etag-{Version}"));
|
||||
|
||||
Repo.Setup(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()))
|
||||
.Returns<TenantConfiguration, string>((config, etag) =>
|
||||
{
|
||||
ReplaceCalls++;
|
||||
if (StealEtagBeforeReplaceCall == ReplaceCalls)
|
||||
{
|
||||
Version++; // the concurrent writer got there first
|
||||
StealEtagBeforeReplaceCall = null;
|
||||
}
|
||||
if (etag != $"etag-{Version}")
|
||||
return Task.FromResult(false);
|
||||
Current = CorruptOnWrite != null ? CorruptOnWrite(Clone(config)) : Clone(config);
|
||||
Version++;
|
||||
return Task.FromResult(true);
|
||||
});
|
||||
|
||||
Backup.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => Backups.Add(e))
|
||||
.Returns(Task.CompletedTask);
|
||||
Backup.Setup(b => b.ListByPartitionAsync(TenantId, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => Backups.AsEnumerable().Reverse().ToList()); // newest first
|
||||
Backup.Setup(b => b.TryGetAsync(TenantId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, CancellationToken>((_, id, _) =>
|
||||
Task.FromResult(Backups.FirstOrDefault(b => b.RowKey == id)));
|
||||
|
||||
Maintenance.Setup(m => m.LogAuditEntryAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var configService = new TenantConfigurationService(
|
||||
Repo.Object, NullLogger<TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
Sut = new TenantConfigPatchService(
|
||||
Repo.Object, Backup.Object, configService, Maintenance.Object,
|
||||
NullLogger<TenantConfigPatchService>.Instance);
|
||||
}
|
||||
|
||||
private static TenantConfiguration Clone(TenantConfiguration c)
|
||||
=> JsonConvert.DeserializeObject<TenantConfiguration>(JsonConvert.SerializeObject(c))!;
|
||||
}
|
||||
|
||||
private static TenantConfiguration Stored()
|
||||
{
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
config.DomainName = "contoso.com";
|
||||
config.UpdatedBy = "admin@contoso.com";
|
||||
config.DataRetentionDays = 30;
|
||||
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
|
||||
return config;
|
||||
}
|
||||
|
||||
private static JObject Fields(params (string Key, object? Value)[] fields)
|
||||
{
|
||||
var o = new JObject();
|
||||
foreach (var (key, value) in fields)
|
||||
o[key] = value == null ? JValue.CreateNull() : JToken.FromObject(value);
|
||||
return o;
|
||||
}
|
||||
|
||||
// ── Happy path ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_SingleField_Applies_BacksUp_Verifies_Audits()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", "retention bump");
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(new[] { "DataRetentionDays" }, outcome.AppliedFields);
|
||||
Assert.Equal(90, harness.Current!.DataRetentionDays);
|
||||
Assert.Equal(Ga, harness.Current.UpdatedBy);
|
||||
Assert.NotNull(outcome.BackupId);
|
||||
var backup = Assert.Single(harness.Backups);
|
||||
Assert.Equal("mcp-patch", backup.Source);
|
||||
Assert.Equal("retention bump", backup.Reason);
|
||||
Assert.Contains("\"DataRetentionDays\":30", backup.EntityJson); // snapshot = PRE-write state
|
||||
Assert.True(outcome.MaskedDiff!.ContainsKey("DataRetentionDays"));
|
||||
harness.Maintenance.Verify(m => m.LogAuditEntryAsync(
|
||||
TenantId, "PATCH", "TenantConfiguration", TenantId, Ga,
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["BackupId"] == outcome.BackupId)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_NullValue_ClearsNullableField()
|
||||
{
|
||||
var stored = Stored();
|
||||
stored.ContactEmail = "ops@contoso.com";
|
||||
var harness = new Harness(stored);
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("contactEmail", null)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Null(harness.Current!.ContactEmail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_NoOp_ValueEqualsStored_NoBackupNoWrite()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 30)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Empty(outcome.AppliedFields);
|
||||
Assert.Null(outcome.BackupId);
|
||||
Assert.Empty(harness.Backups);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
// ── Field gate ──────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("planTier")]
|
||||
[InlineData("trialExpiresUtc")]
|
||||
[InlineData("homedAppClientId")]
|
||||
[InlineData("lastAuthClientId")]
|
||||
[InlineData("onboardedBy")]
|
||||
[InlineData("tenantId")]
|
||||
[InlineData("domainName")]
|
||||
[InlineData("lastUpdated")]
|
||||
[InlineData("updatedBy")]
|
||||
public async Task Patch_DeniedField_Returns400WithFieldName(string field)
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields((field, "x")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
|
||||
Assert.Contains(field, outcome.Error, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(harness.Backups);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_UnknownField_Returns400WithFieldName()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("definitelyNotAField", 1)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
|
||||
Assert.Contains("definitelyNotAField", outcome.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_RedactedPlaceholderValue_Rejected()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("webhookUrl", Constants.RedactedSecretPlaceholder)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
|
||||
Assert.Contains("redacted", outcome.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_GaOnlyField_AllowedForGa_DeniedForTenantAdminTier()
|
||||
{
|
||||
var gaHarness = new Harness(Stored());
|
||||
var gaOutcome = await gaHarness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("bootstrapTokenEnabled", true)), Ga, "mcp-patch", null,
|
||||
TenantConfigCallerTier.GlobalAdmin);
|
||||
Assert.True(gaOutcome.Success);
|
||||
|
||||
foreach (var tier in new[] { TenantConfigCallerTier.TenantAdmin, TenantConfigCallerTier.DelegatedAdmin })
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("bootstrapTokenEnabled", true)), "admin@contoso.com", "api-patch", null, tier);
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.InvalidField, outcome.Failure);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_InvalidWebhookUrl_ValidationFails_NoWrite()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("webhookUrl", "http://169.254.169.254/latest")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
Assert.Empty(harness.Backups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_BadNotificationChannelsJson_ValidationFails()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("notificationChannelsJson", "{not json")), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_UnrestrictedModeWithoutGate_ExplicitError_NotSilentFlip()
|
||||
{
|
||||
var harness = new Harness(Stored()); // gate (UnrestrictedModeEnabled) is false by default
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("unrestrictedMode", true)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
|
||||
Assert.Contains("UnrestrictedModeEnabled", outcome.Error);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
}
|
||||
|
||||
// ── Storage edges ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_NoConfigRow_NotFound()
|
||||
{
|
||||
var harness = new Harness(stored: null);
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.NotFound, outcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_BackupFailure_IsFailClosed_NoWrite()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
harness.Backup.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("backup table down"));
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.BackupFailed, outcome.Failure);
|
||||
Assert.Equal(0, harness.ReplaceCalls);
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays); // untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_CasRace_RetriesAndSucceeds()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
harness.StealEtagBeforeReplaceCall = 1; // first replace loses, retry wins
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(2, harness.ReplaceCalls);
|
||||
Assert.Equal(90, harness.Current!.DataRetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_CasRaceEveryAttempt_ReturnsConflict()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
harness.Repo.Setup(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false); // permanent race
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.WriteConflict, outcome.Failure);
|
||||
}
|
||||
|
||||
// ── Verify + rollback ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_DriftOnWrite_RollsBackToInitial_AndReportsDriftNames()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
// Simulated serialization drift: every write silently zeroes SessionTimeoutHours.
|
||||
// The verify must catch the unexpected field and roll the row back. (The rollback
|
||||
// write itself is also corrupted by the hook, but rollback success is judged by the
|
||||
// CAS result — clear the hook after the first corruption so the rollback restores.)
|
||||
harness.CorruptOnWrite = written =>
|
||||
{
|
||||
harness.CorruptOnWrite = null;
|
||||
written.SessionTimeoutHours = 0;
|
||||
return written;
|
||||
};
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.DriftRolledBack, outcome.Failure);
|
||||
Assert.Contains(outcome.Drift!, d => d.Contains("SessionTimeoutHours"));
|
||||
Assert.NotNull(outcome.BackupId); // pre-write snapshot exists for manual inspection
|
||||
// The row is back at the initial state: retention 30, timeout restored.
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays);
|
||||
Assert.Equal(Stored().SessionTimeoutHours, harness.Current.SessionTimeoutHours);
|
||||
// No success audit for a rolled-back write.
|
||||
harness.Maintenance.Verify(m => m.LogAuditEntryAsync(
|
||||
It.IsAny<string>(), "PATCH", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<Dictionary<string, string>?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patch_DriftAndRollbackLosesRace_ReportsManualRestoreWithBackupId()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
harness.CorruptOnWrite = written => { written.SessionTimeoutHours = 0; return written; };
|
||||
// Every write corrupts — including the rollback attempt — but the decisive part:
|
||||
// steal the ETag before the rollback replace so the rollback itself loses the race.
|
||||
harness.StealEtagBeforeReplaceCall = 2;
|
||||
|
||||
var outcome = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.DriftRollbackFailed, outcome.Failure);
|
||||
Assert.Contains(outcome.BackupId!, outcome.Error);
|
||||
}
|
||||
|
||||
// ── Revert ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(Harness Harness, string BackupId)> PatchedHarnessAsync()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
var patch = await harness.Sut.ApplyFieldPatchAsync(
|
||||
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", "bump");
|
||||
Assert.True(patch.Success);
|
||||
return (harness, patch.BackupId!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_Latest_RestoresPatchedField_AndBacksUpCurrentFirst()
|
||||
{
|
||||
var (harness, _) = await PatchedHarnessAsync();
|
||||
Assert.Equal(90, harness.Current!.DataRetentionDays);
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays); // back to the pre-patch value
|
||||
Assert.Equal(2, harness.Backups.Count); // revert snapshotted the 90-state first
|
||||
Assert.Contains("\"DataRetentionDays\":90", harness.Backups[1].EntityJson);
|
||||
harness.Maintenance.Verify(m => m.LogAuditEntryAsync(
|
||||
TenantId, "REVERT", "TenantConfiguration", TenantId, Ga,
|
||||
It.IsAny<Dictionary<string, string>?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_ByBackupId_UsesThatSnapshot()
|
||||
{
|
||||
var (harness, backupId) = await PatchedHarnessAsync();
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId, includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_PreservesProtectedFields_ByDefault()
|
||||
{
|
||||
var (harness, backupId) = await PatchedHarnessAsync();
|
||||
// Plan + homing changed AFTER the snapshot (via their dedicated flows).
|
||||
harness.Current!.PlanTier = "enterprise";
|
||||
harness.Current.HomedAppClientId = "886ab5e2-6144-442c-80cc-9b28e0667731";
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId, includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(30, harness.Current!.DataRetentionDays); // reverted
|
||||
Assert.Equal("enterprise", harness.Current.PlanTier); // protected — kept current
|
||||
Assert.Equal("886ab5e2-6144-442c-80cc-9b28e0667731", harness.Current.HomedAppClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_IncludeProtectedFields_RestoresThemToo()
|
||||
{
|
||||
var (harness, backupId) = await PatchedHarnessAsync();
|
||||
harness.Current!.PlanTier = "enterprise";
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId, includeProtectedFields: true, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(Stored().PlanTier, harness.Current!.PlanTier); // snapshot value restored
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_NoBackups_NotFound()
|
||||
{
|
||||
var harness = new Harness(Stored());
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.NotFound, outcome.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revert_UnknownBackupId_NotFound()
|
||||
{
|
||||
var (harness, _) = await PatchedHarnessAsync();
|
||||
|
||||
var outcome = await harness.Sut.RevertAsync(
|
||||
TenantId, "does-not-exist", includeProtectedFields: false, Ga, "mcp-revert", null);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PatchFailure.NotFound, outcome.Failure);
|
||||
}
|
||||
|
||||
// ── Rehydration roundtrip (serialization-drift canary) ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void RehydrateEntity_Roundtrip_EveryPropertyNonNull_ProducesIdenticalModel()
|
||||
{
|
||||
// Reflection filler: EVERY writable property gets a non-default value so no column
|
||||
// escapes the fidelity check. Whole-number decimals (95m) are the deliberate trap —
|
||||
// JSON serializes 95.0 as "95", and prod finding 2026-08-03 was exactly a whole-valued
|
||||
// SLA rate column rehydrating as Int32 and blowing up TableEntity.GetDouble.
|
||||
var original = TenantConfiguration.CreateDefault(TenantId);
|
||||
foreach (var prop in typeof(TenantConfiguration).GetProperties()
|
||||
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0))
|
||||
{
|
||||
var t = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
|
||||
object? value =
|
||||
t == typeof(string) ? (prop.Name is "PartitionKey" or "RowKey" or "TenantId"
|
||||
? prop.GetValue(original) : $"x-{prop.Name}")
|
||||
: t == typeof(bool) ? true
|
||||
: t == typeof(int) ? 7
|
||||
: t == typeof(decimal) ? 95m // whole number on purpose — the Int32/Double trap
|
||||
: t == typeof(double) ? 95d
|
||||
: t == typeof(DateTime) ? new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc)
|
||||
: prop.GetValue(original);
|
||||
prop.SetValue(original, value);
|
||||
}
|
||||
|
||||
// Canonical baseline = one Store/Map roundtrip (isolates REHYDRATION fidelity from any
|
||||
// pre-existing converter quirks, which TenantConfigTableSerializationTests own).
|
||||
var baseline = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(original));
|
||||
|
||||
var snapshot = TableConfigRepository.BuildBackupEntry(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(baseline),
|
||||
TenantId, Ga, "test", null, null);
|
||||
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
|
||||
|
||||
var diff = AutopilotMonitor.Functions.Helpers.ConfigPropertyComparer
|
||||
.GetChangedPropertyNames(baseline, rehydrated);
|
||||
Assert.True(diff.Count == 0, "Rehydration drift: " + string.Join(", ", diff));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RehydrateEntity_WholeNumberDecimalColumn_SurvivesAsDouble()
|
||||
{
|
||||
// Focused pin on the prod failure: SlaTargetSuccessRate 95 (whole) must come back as
|
||||
// a readable double column, not an Int32 that makes GetDouble throw.
|
||||
var original = Stored();
|
||||
original.SlaTargetSuccessRate = 95m;
|
||||
|
||||
var snapshot = TableConfigRepository.BuildBackupEntry(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(original),
|
||||
TenantId, Ga, "test", null, null);
|
||||
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
|
||||
|
||||
Assert.Equal(95m, rehydrated.SlaTargetSuccessRate);
|
||||
}
|
||||
|
||||
// ── Fields schema (MCP steering surface) ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FieldsSchema_CoversEveryModelProperty_ExactlyOnce()
|
||||
{
|
||||
// Drift canary: the schema is reflection-generated, so this pins that EVERY writable
|
||||
// model property surfaces exactly once under its camelCase name — a new config field
|
||||
// is automatically schema-visible, and a rename cannot leave a stale entry.
|
||||
var schema = TenantConfigPatchService.BuildFieldsSchema();
|
||||
var expected = typeof(TenantConfiguration).GetProperties()
|
||||
.Where(p => p.CanWrite && p.GetIndexParameters().Length == 0)
|
||||
.Select(p => char.ToLowerInvariant(p.Name[0]) + p.Name.Substring(1))
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(expected, schema.Select(f => f.Name).ToList());
|
||||
Assert.Equal(schema.Count, schema.Select(f => f.Name).Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldsSchema_DeniedFields_MatchThePatchDenyList()
|
||||
{
|
||||
var schema = TenantConfigPatchService.BuildFieldsSchema()
|
||||
.ToDictionary(f => f.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var denied in TenantConfigPatchService.BaseDeniedFields
|
||||
.Where(n => n is not ("PartitionKey" or "RowKey" or "Timestamp" or "ETag")))
|
||||
{
|
||||
Assert.False(schema[denied].Writable, $"{denied} must be marked non-writable");
|
||||
Assert.False(string.IsNullOrWhiteSpace(schema[denied].Reason), $"{denied} needs a deny reason");
|
||||
}
|
||||
foreach (var gaOnly in TenantConfigPatchService.GaOnlyFields)
|
||||
{
|
||||
Assert.True(schema[gaOnly].Writable, $"{gaOnly} is writable for GA");
|
||||
Assert.True(schema[gaOnly].GaOnly, $"{gaOnly} must carry the GA-only marker");
|
||||
}
|
||||
foreach (var protectedField in TenantConfigPatchService.RevertProtectedFields)
|
||||
{
|
||||
Assert.True(schema[protectedField].RevertProtected, $"{protectedField} must carry the revert-protected marker");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FieldsSchema_TypeExemplars_AreCorrect()
|
||||
{
|
||||
var schema = TenantConfigPatchService.BuildFieldsSchema()
|
||||
.ToDictionary(f => f.Name, StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal(("integer", false, true), (schema["dataRetentionDays"].Type, schema["dataRetentionDays"].Nullable, schema["dataRetentionDays"].Writable));
|
||||
// The prod-finding column class: decimal? → JSON "number", nullable.
|
||||
Assert.Equal(("number", true), (schema["slaTargetSuccessRate"].Type, schema["slaTargetSuccessRate"].Nullable));
|
||||
Assert.Equal(("boolean", true), (schema["disabled"].Type, schema["disabled"].GaOnly));
|
||||
Assert.Equal(("string", true), (schema["contactEmail"].Type, schema["contactEmail"].Nullable));
|
||||
Assert.Equal("json", schema["notificationChannelsJson"].Format);
|
||||
Assert.Equal("date-time", schema["trialExpiresUtc"].Format);
|
||||
Assert.False(schema["planTier"].Writable);
|
||||
Assert.Contains("plan/trial", schema["planTier"].Reason);
|
||||
Assert.False(schema["lastUpdated"].Writable);
|
||||
Assert.False(schema["homedAppClientId"].Writable);
|
||||
Assert.True(schema["homedAppClientId"].RevertProtected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RehydrateEntity_DateLookingStringField_StaysString()
|
||||
{
|
||||
var original = Stored();
|
||||
original.DisabledReason = "2026-08-03T12:00:00+00:00"; // adversarial: a string that parses as a date
|
||||
|
||||
var snapshot = TableConfigRepository.BuildBackupEntry(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(original),
|
||||
TenantId, Ga, "test", null, null);
|
||||
var rehydrated = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TenantConfigPatchService.RehydrateTenantConfigEntity(snapshot.EntityJson, TenantId));
|
||||
|
||||
Assert.Equal(original.DisabledReason, rehydrated.DisabledReason);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user