mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
1c5f69a9fe
* 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>
113 lines
5.7 KiB
C#
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"));
|
|
}
|
|
}
|