Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexProjectionTests.cs
T
Oliver Kieselbach 1c5f69a9fe w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs (#127)
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs

Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup
(and the Corporate Identifier fallback) structurally reject them and Cloud PC
first-connect enrollment could not be monitored. New stage-3 fallback in the
device-validation chain: extract the Intune device id from the chain-validated
MDM client certificate's Subject CN (field-verified: CN == managedDevice id ==
cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed
tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq).
Only service-provisioned Cloud PCs have such an object — a regular enrolled
non-Autopilot device still ends in the 403, and local W365 markers are never
consulted for auth.

- Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice
  (default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll,
  GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All
- Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract:
  30/5 min cache, 2 attempts, transient->503; GUID gate before the OData
  filter; exact-match guard; Graph 403 = definitive "grant missing", not
  transient), SecurityValidator CN extraction + fallback stage + hard gate,
  wiring through the 5 agent endpoints, TableConfigRepository persistence
- Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW
  GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously
  comment-only lock-step)
- Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the
  Cloud-PC relax is active — Cloud PCs run headless for days before first
  connect; the fresh-profile window is the time anchor there (+ Pester tests)
- Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label,
  privacy-page validation claim generalized (Last updated bumped)
- Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log

Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet
cap, none added). .NET build/tests and Pester run in CI — SDK download and
pwsh are unavailable in this sandbox's network policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv

* w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase"

Companion to the CloudPc validator: the agent now classifies Windows 365
Cloud PCs locally with the bootstrap's field-verified marker AND
(HKLM\SOFTWARE\Microsoft\Windows365 key + installed
CloudManagedDesktopExtension service, probed via its Services registry key
so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves
false. Never an auth input - backend admission stays exclusively the
cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are
independent and may disagree.

Two transport rails:

- Session metadata: SessionRegistration.IsCloudPc -> Sessions table
  (sticky-true OR across re-registrations, IsSelfDeployingProfile pattern)
  -> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search
  filter end to end (SearchSessions + QueryRawSessions + pagination
  fingerprint + all three in-memory predicates; MCP search_sessions /
  query_raw_sessions params + summary projection; web type, Cloud PC pill,
  SessionInfoCard enrollment label, CSV export appended column).
- Decision engine: isCloudPc rides the EnrollmentFactsObserved payload,
  recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc
  and annotates the scenario profile reason
  cloud_pc_first_connect:no_device_esp_expected - the engine-recorded
  expectation that Device-ESP already ran headless at provisioning and the
  session starts at Account Setup. Deliberately reason+observation only:
  no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-
  classified (Classic), EspConfig keeps configured-FirstSync semantics.
  Positive marker surfaces as cloud_pc_marker in the audit-trail census.
  Updater idempotency: the cloudPcAnnotates gate keeps the generic v1
  reason branch from re-claiming the slot on repeat posts (would bump
  EvidenceOrdinal every post). Additive-nullable observation - no state
  schema bump (v4 convention), serializer roundtrip pinned.

Tests: CloudPcDetector resolver (marker AND, throwing probe), registration
body, facts-signal payload shape, updater annotation/idempotency/
self-deploying interplay, reducer observation both values + census fold,
serializer roundtrip, index projection superset, sticky-true both
directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154,
vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99).

Docs: cloudpc-device-validation.md follow-up section replaced with the
shipped mechanism + log entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 12:31:08 +02:00

113 lines
5.7 KiB
C#

using System;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Audit #3 / Phase 1: <c>BuildSessionIndexEntity</c> is the single SessionsIndex projection manifest.
/// It must be a SUPERSET of every field any <c>MergeSessionIndexAsync</c> call site writes, otherwise a
/// StartedAt-shift full upsert (which rebuilds the index row from this projection) silently DROPS a
/// merged field until the next merge — the recurring drift bug (e.g. ab90423b). These tests lock the
/// projection so a future field added to a merge site but forgotten here is caught.
/// </summary>
public class SessionIndexProjectionTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private const string IndexRowKey = "2516000000000000000_22222222-2222-2222-2222-222222222222";
private static readonly DateTime StartedAt = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
[Fact]
public void BuildSessionIndexEntity_carries_all_merge_union_fields()
{
// A fully-populated session row (the primary Sessions truth) — every field a merge site can write.
var session = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(StartedAt),
["Status"] = "Failed",
["DeviceName"] = "PC-1",
["EventCount"] = 42,
// The fields that previously drifted (merged but absent from the upsert projection):
["AdminMarkedAction"] = "failed",
["ImeAgentVersion"] = "1.2.3.4",
["FailureSource"] = "ime",
["FailureSnapshotJson"] = "{\"x\":1}",
["StalledAt"] = new DateTimeOffset(StartedAt.AddMinutes(5)),
["PlatformScriptCount"] = 2,
["RemediationScriptCount"] = 3,
["RebootCount"] = 4,
["ExcessiveEventsAlerted"] = true,
["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).
["AvgApiLatencyMs"] = 513.5,
["ApiRequestCount"] = 100,
// Connection-type projection (UpdateSessionConnectionTypeAsync merges this) —
// search-filterable (connectionType= pushes an OData eq on the index).
["ConnectionType"] = "Ethernet",
};
var idx = TableStorageService.BuildSessionIndexEntity(session, IndexRowKey, StartedAt);
Assert.Equal(SessionId, idx.GetString("SessionId"));
Assert.Equal("Failed", idx.GetString("Status"));
Assert.Equal(42, idx.GetInt32("EventCount"));
// Previously-dropped fields are now part of the projection (StartedAt-shift upsert preserves them):
Assert.Equal("failed", idx.GetString("AdminMarkedAction"));
Assert.Equal("1.2.3.4", idx.GetString("ImeAgentVersion"));
Assert.Equal("ime", idx.GetString("FailureSource"));
Assert.Equal("{\"x\":1}", idx.GetString("FailureSnapshotJson"));
Assert.True(idx.GetDateTimeOffset("StalledAt").HasValue);
Assert.Equal(2, idx.GetInt32("PlatformScriptCount"));
Assert.Equal(3, idx.GetInt32("RemediationScriptCount"));
// RebootCount is a search-filterable column (rebootCountMin/Max push an OData filter on the
// index), so it MUST survive a StartedAt-shift full upsert.
Assert.Equal(4, idx.GetInt32("RebootCount"));
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"));
Assert.Equal("Ethernet", idx.GetString("ConnectionType"));
}
[Fact]
public void BuildSessionIndexEntity_defaults_absent_optional_fields()
{
// A minimal new session: optional strings/timestamps absent, counts/flags default.
var session = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(StartedAt),
["Status"] = "InProgress",
};
var idx = TableStorageService.BuildSessionIndexEntity(session, IndexRowKey, StartedAt);
// Nullable-if-present fields are simply absent (mapper reads them as defaults).
Assert.False(idx.ContainsKey("AdminMarkedAction"));
Assert.False(idx.ContainsKey("FailureSource"));
Assert.False(idx.ContainsKey("StalledAt"));
Assert.False(idx.ContainsKey("ValidatedBy"));
Assert.False(idx.ContainsKey("AvgApiLatencyMs"));
Assert.False(idx.ContainsKey("ConnectionType"));
// Always-present counts/flags mirror the Sessions defaults.
Assert.Equal(0, idx.GetInt32("PlatformScriptCount"));
Assert.Equal(0, idx.GetInt32("RemediationScriptCount"));
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"));
}
}