diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs new file mode 100644 index 00000000..98a3f2f5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration +{ + /// + /// Completeness tripwire for : the merger hand-maps + /// fields onto AgentConfiguration, and + /// historically new tenant knobs were forgotten there and silently ignored. Every public + /// property of must be classified in exactly one of the + /// two lists below. A new property fails this test until a human either wires it through + /// or documents where else it is consumed. + /// + public sealed class RemoteConfigMergerCompletenessTests + { + /// + /// Fields maps onto AgentConfiguration. + /// Keep in sync with the Merge body — the test only proves classification coverage, + /// the value-level mapping itself is covered by . + /// + private static readonly string[] MergedByMerger = + { + nameof(AgentConfigResponse.SelfDestructOnComplete), + nameof(AgentConfigResponse.KeepLogFile), + nameof(AgentConfigResponse.RebootOnComplete), + nameof(AgentConfigResponse.EnableGeoLocation), + nameof(AgentConfigResponse.LogLevel), + nameof(AgentConfigResponse.RebootDelaySeconds), + nameof(AgentConfigResponse.ShowEnrollmentSummary), + nameof(AgentConfigResponse.EnrollmentSummaryTimeoutSeconds), + nameof(AgentConfigResponse.EnrollmentSummaryBrandingImageUrl), + nameof(AgentConfigResponse.EnrollmentSummaryLaunchRetrySeconds), + nameof(AgentConfigResponse.NtpServer), + nameof(AgentConfigResponse.EnableTimezoneAutoSet), + nameof(AgentConfigResponse.DiagnosticsUploadEnabled), + nameof(AgentConfigResponse.DiagnosticsUploadMode), + nameof(AgentConfigResponse.DiagnosticsLogPaths), + nameof(AgentConfigResponse.SendTraceEvents), + nameof(AgentConfigResponse.EnableEspContinueAnywayObservation), + nameof(AgentConfigResponse.UnrestrictedMode), + nameof(AgentConfigResponse.EnableImeMatchLog), + nameof(AgentConfigResponse.EnableGatherRuleDebugLog), + nameof(AgentConfigResponse.UploadIntervalSeconds), + nameof(AgentConfigResponse.MaxBatchSize), + nameof(AgentConfigResponse.MaxAuthFailures), + nameof(AgentConfigResponse.AuthFailureTimeoutMinutes), + // Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds); + // the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory. + nameof(AgentConfigResponse.Collectors), + }; + + /// + /// Fields deliberately NOT mapped by the merger because another component consumes them + /// directly from the remote config. One-line justification per entry — verify the + /// consumer before classifying a new field here. + /// + private static readonly string[] IntentionallyNotMerged = + { + // Schema/version metadata — logged and cached by RemoteConfigService, not a runtime knob. + nameof(AgentConfigResponse.ConfigVersion), + // Consumed by DefaultComponentFactory → GatherRuleExecutorHost (rules run against the raw remote list). + nameof(AgentConfigResponse.GatherRules), + // Consumed by DefaultComponentFactory → ImeLogHost (pattern list handed to ImeLogTracker). + nameof(AgentConfigResponse.ImeLogPatterns), + // Passed by AgentRuntimeHost into IComponentFactory.CreateCollectorHosts → ImeLogHost sealing re-emit. + nameof(AgentConfigResponse.WhiteGloveSealingPatternIds), + // Consumed by DefaultComponentFactory (RealmJoin/KeepAwake/ConsoleBypass host gates) + AgentAnalyzerManager. + nameof(AgentConfigResponse.Analyzers), + // Self-update ZIP integrity second trust channel — BinaryIntegrityVerifier / Program update path. + nameof(AgentConfigResponse.LatestAgentSha256), + // Running-EXE integrity verification — BinaryIntegrityVerifier post-config check. + nameof(AgentConfigResponse.LatestAgentExeSha256), + // Self-updater force-update downgrade gate — BinaryIntegrityVerifier / Program update path. + nameof(AgentConfigResponse.AllowAgentDowngrade), + // Control-channel block signal — RemoteConfigService/ServerControlPlane; stripped from the disk cache. + nameof(AgentConfigResponse.DeviceBlocked), + // Control-channel kill signal — ServerControlPlane / Program.Guards; live fetch only. + nameof(AgentConfigResponse.DeviceKillSignal), + // Temporary-block expiry accompanying DeviceBlocked — ServerControlPlane. + nameof(AgentConfigResponse.UnblockAt), + // Endpoint migration — EndpointMigration/RemoteConfigService re-run the fetch, live fetch only. + nameof(AgentConfigResponse.MigrateToApiBaseUrl), + }; + + [Fact] + public void Every_AgentConfigResponse_property_is_classified_exactly_once() + { + var properties = typeof(AgentConfigResponse) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + var merged = new HashSet(MergedByMerger, StringComparer.Ordinal); + var notMerged = new HashSet(IntentionallyNotMerged, StringComparer.Ordinal); + + var inBoth = merged.Intersect(notMerged, StringComparer.Ordinal).ToArray(); + Assert.True( + inBoth.Length == 0, + $"Fields listed in BOTH classification lists: [{string.Join(", ", inBoth)}]."); + + var unclassified = properties + .Where(p => !merged.Contains(p) && !notMerged.Contains(p)) + .ToArray(); + Assert.True( + unclassified.Length == 0, + "New AgentConfigResponse field(s) without a classification: " + + $"[{string.Join(", ", unclassified)}]. Either map them in RemoteConfigMerger.Merge " + + "(+ MergedByMerger here) or verify the consuming component and add them to " + + "IntentionallyNotMerged with a justification comment."); + + // Stale entries (renamed/removed properties) must be cleaned up too. + var known = new HashSet(properties, StringComparer.Ordinal); + var stale = merged.Concat(notMerged).Where(n => !known.Contains(n)).ToArray(); + Assert.True( + stale.Length == 0, + $"Classification lists reference non-existent AgentConfigResponse properties: [{string.Join(", ", stale)}]."); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DefaultComponentFactoryOrderingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DefaultComponentFactoryOrderingTests.cs new file mode 100644 index 00000000..70b94174 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DefaultComponentFactoryOrderingTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Enforces the comment-only ordering invariants of by + /// building the REAL host list (construction only — no host is started) and asserting the + /// relative positions of the order-sensitive host types. The orchestrator starts hosts in + /// list order, so list order IS start order: + /// + /// DeviceInfoHost before EspAndHelloHost — L9 (delta review 2026-07-02): DeviceInfoHost + /// must subscribe to SignalPosted before EspAndHelloHost.Start can post the registry-backfill + /// EspPhaseChanged(DeviceSetup), or the enrollment-start re-collect never runs. + /// OsBuildChangeHost before WindowsUpdateWatcherHost — the WU channel census reads + /// OsBuildChangeHost.BuildChanged at its own start. + /// DeliveryOptimizationHost before OfficeInstallDetectorHost — the DO host must have + /// subscribed to the Office process-start signal before the watcher starts, or an install + /// already in flight misses its wake. + /// DesktopArrivalHost before AadJoinHost — the AadJoinHost ctor takes the desktop + /// host's RequestResetForRealUserSwitch callback, so the target must exist first. + /// + /// + public sealed class DefaultComponentFactoryOrderingTests : IDisposable + { + private readonly TempDirectory _tmp = new TempDirectory(); + + public void Dispose() => _tmp.Dispose(); + + /// + /// Build the production host list with default remote config (all order-sensitive hosts + /// enabled by default) and system-boundary fakes only: temp state dir, fake ingress, + /// virtual clock, no network metrics, no telemetry spool. + /// + private CollectorSurfaces CreateSurfaces() + { + var factory = new DefaultComponentFactory( + agentConfig: new AgentConfiguration + { + ApiBaseUrl = "https://example", + TenantId = "t", + SessionId = "s", + }, + remoteConfig: new AgentConfigResponse + { + Collectors = CollectorConfiguration.CreateDefault(), + }, + networkMetrics: null, + agentVersion: "test", + stateDirectory: _tmp.Path, + startupEventGate: null); + + return factory.CreateCollectorHosts( + sessionId: "s", + tenantId: "t", + logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info), + whiteGloveSealingPatternIds: Array.Empty(), + ingress: new FakeSignalIngressSink(), + clock: new VirtualClock(new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc)), + telemetrySpool: null, + timelineEvents: null); + } + + private static int IndexOf(IReadOnlyList hosts) where THost : ICollectorHost + { + var indices = hosts + .Select((host, index) => (host, index)) + .Where(t => t.host is THost) + .Select(t => t.index) + .ToArray(); + Assert.True( + indices.Length == 1, + $"Expected exactly one {typeof(THost).Name} in the host list, found {indices.Length}. " + + $"Hosts: [{string.Join(", ", hosts.Select(h => h.GetType().Name))}]"); + return indices[0]; + } + + private static void AssertBefore(IReadOnlyList hosts, string reason) + where TFirst : ICollectorHost + where TSecond : ICollectorHost + { + var first = IndexOf(hosts); + var second = IndexOf(hosts); + Assert.True( + first < second, + $"{typeof(TFirst).Name} (index {first}) must come before {typeof(TSecond).Name} (index {second}): {reason}"); + } + + [Fact] + public void Order_sensitive_hosts_keep_their_documented_relative_order() + { + var surfaces = CreateSurfaces(); + var hosts = surfaces.Hosts; + try + { + AssertBefore( + hosts, + "L9 — DeviceInfoHost must subscribe to SignalPosted before EspAndHelloHost's " + + "registry backfill can post EspPhaseChanged(DeviceSetup)."); + + AssertBefore( + hosts, + "the Windows Update channel census reads OsBuildChangeHost.BuildChanged at its own start."); + + AssertBefore( + hosts, + "the DO host must subscribe to the Office process-start signal before the watcher starts."); + + AssertBefore( + hosts, + "AadJoinHost wires its onRealUserJoined callback to the already-constructed DesktopArrivalHost."); + + // Closure-latch sanity (factory lines ~139/297): the EspAndHelloHost probes close + // over the ImeLogHost reference, which is assigned during the same factory call — + // the host must therefore exist in the returned list. + Assert.Equal(1, hosts.Count(h => h is ImeLogHost)); + } + finally + { + foreach (var host in hosts) + { + try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ } + } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SignalKindFixtureCoverageTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SignalKindFixtureCoverageTests.cs new file mode 100644 index 00000000..bac9727e --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SignalKindFixtureCoverageTests.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Enforces the fixture convention documented on : + /// "New kinds or version bumps require a replay fixture in + /// tests/fixtures/signal-kinds/{kind}-v{n}.json — missing fixture = merge block." + /// The comment alone enforced nothing; this test does. Kinds without a fixture today are + /// carried in an explicit debt set that is only allowed to shrink. + /// + public sealed class SignalKindFixtureCoverageTests + { + // DEBT: backfill — new kinds must NOT be added here. Every entry predates this test; + // when a fixture is added for one of these kinds, the test forces its removal. + private static readonly HashSet KnownMissingFixtures = new HashSet + { + DecisionSignalKind.EspPhaseChanged, + DecisionSignalKind.EspExiting, + DecisionSignalKind.EspResumed, + DecisionSignalKind.EspTerminalFailure, + DecisionSignalKind.DesktopArrived, + DecisionSignalKind.HelloResolved, + DecisionSignalKind.ImeUserSessionCompleted, + DecisionSignalKind.DeviceSetupProvisioningComplete, + DecisionSignalKind.WhiteGloveShellCoreSuccess, + DecisionSignalKind.WhiteGloveSealingPatternDetected, + DecisionSignalKind.AadUserJoinedLate, + DecisionSignalKind.DeviceInfoCollected, + DecisionSignalKind.AutopilotProfileRead, + DecisionSignalKind.EspConfigDetected, + DecisionSignalKind.HelloPolicyDetected, + DecisionSignalKind.ClassifierVerdictIssued, + DecisionSignalKind.SessionStarted, + DecisionSignalKind.SessionAborted, + }; + + /// + /// Kinds that exist in the enum but are never posted as their own signal: they are + /// dispatched over the rail (the reducer's + /// DeadlineFiredV1 switch routes on the deadline payload). Their fixture is named + /// after the kind, but its declared Kind is DeadlineFired — e.g. + /// realmjoin-timeout-v1.json carries Payload.deadline = "realmjoin_timeout". + /// + private static readonly HashSet DeadlineDispatchedKinds = new HashSet + { + DecisionSignalKind.RealmJoinTimeout, + }; + + /// Locate tests/fixtures/signal-kinds by walking up to the repo root (AutopilotMonitor.sln). + private static string FixtureRoot() + { + var dir = AppContext.BaseDirectory; + for (int i = 0; i < 12; i++) + { + if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) + { + return Path.Combine(dir, "tests", "fixtures", "signal-kinds"); + } + var parent = Directory.GetParent(dir)?.FullName; + if (parent == null || parent == dir) break; + dir = parent; + } + throw new DirectoryNotFoundException( + "Could not locate repo root (AutopilotMonitor.sln) walking up from " + AppContext.BaseDirectory); + } + + private static IReadOnlyList FixtureFiles() + { + var root = FixtureRoot(); + Assert.True(Directory.Exists(root), $"Fixture directory not found: {root}"); + var files = Directory.GetFiles(root, "*.json").OrderBy(f => f, StringComparer.Ordinal).ToArray(); + Assert.NotEmpty(files); + return files; + } + + /// Signal JSONL lines of one fixture ('#' comments and blank lines stripped). + private static IReadOnlyList SignalLines(string path) + { + var lines = File.ReadAllLines(path) + .Select(l => l.Trim()) + .Where(l => l.Length > 0 && l[0] != '#') + .ToArray(); + Assert.True(lines.Length > 0, $"Fixture contains no signal lines: {path}"); + return lines; + } + + /// + /// Strictly parse the raw Kind of a fixture line. Deliberately NOT via + /// alone: its unknown-kind fallback maps typos to + /// , which would silently mis-count coverage. + /// + private static DecisionSignalKind StrictKind(string line, string path) + { + var raw = JObject.Parse(line).Value("Kind"); + Assert.False(string.IsNullOrEmpty(raw), $"Fixture line has no Kind: {path}"); + Assert.True( + Enum.TryParse(raw, ignoreCase: false, out var kind) + && Enum.IsDefined(typeof(DecisionSignalKind), kind), + $"Fixture {Path.GetFileName(path)} declares unknown Kind '{raw}'."); + return kind; + } + + /// Dash-insensitive lowercase — "realmjoin-detected-v1" and "RealmJoinDetected" both normalize consistently. + private static string Normalize(string value) => value.Replace("-", string.Empty).ToLowerInvariant(); + + /// + /// Map a fixture file name to the kind it is named after: the longest enum member whose + /// normalized name prefixes the normalized file name, followed by v{n}[-variant] + /// (dash placement per the existing corpus, e.g. realmjoin-detected-v1.json / + /// admin-preemption-detected-v1-failed.json). + /// + private static DecisionSignalKind KindFromFileName(string fileNameWithoutExtension) + { + var normalized = Normalize(fileNameWithoutExtension); + var match = ((DecisionSignalKind[])Enum.GetValues(typeof(DecisionSignalKind))) + .Where(k => + { + var prefix = Normalize(k.ToString()); + return normalized.StartsWith(prefix, StringComparison.Ordinal) + && Regex.IsMatch(normalized.Substring(prefix.Length), "^v[0-9]+"); + }) + .OrderByDescending(k => k.ToString().Length) + .Cast() + .FirstOrDefault(); + Assert.True( + match.HasValue, + $"Fixture file name '{fileNameWithoutExtension}.json' does not follow " + + "'{kind}-v{n}[-variant].json' for any DecisionSignalKind."); + return match!.Value; + } + + /// + /// Load every fixture through the REAL deserialization path and return the set of kinds + /// the corpus covers. Also enforces per-file consistency: the declared Kind must match + /// the file name (or be the DeadlineFired dispatch shape for deadline-dispatched kinds). + /// + private static ISet LoadAndVerifyFixtures() + { + var covered = new HashSet(); + foreach (var file in FixtureFiles()) + { + var fileName = Path.GetFileNameWithoutExtension(file); + var namedKind = KindFromFileName(fileName); + + foreach (var line in SignalLines(file)) + { + var declaredKind = StrictKind(line, file); + + // Real deserialization path — the same call FixtureLoader/ReplayHarness use. + var signal = SignalSerializer.Deserialize(line); + Assert.Equal(declaredKind, signal.Kind); + Assert.True(signal.KindSchemaVersion >= 1, $"KindSchemaVersion must be >= 1 in {fileName}."); + + if (declaredKind == namedKind) + { + covered.Add(declaredKind); + } + else + { + // Only the documented dispatch exception is allowed: a fixture named after + // a deadline-dispatched kind carries a DeadlineFired signal with the + // deadline name in its payload. + string? deadlineName = null; + signal.Payload?.TryGetValue("deadline", out deadlineName); + Assert.True( + DeadlineDispatchedKinds.Contains(namedKind) + && declaredKind == DecisionSignalKind.DeadlineFired + && !string.IsNullOrEmpty(deadlineName), + $"Fixture '{fileName}.json' is named after {namedKind} but declares Kind={declaredKind}. " + + "Either the file is misnamed, or a new deadline-dispatched kind must be " + + "registered in DeadlineDispatchedKinds (with a payload.deadline entry)."); + covered.Add(namedKind); + covered.Add(declaredKind); // it IS also a genuine DeadlineFired fixture + } + } + } + return covered; + } + + [Fact] + public void Every_fixture_deserializes_through_the_real_signal_path_and_matches_its_filename() + { + LoadAndVerifyFixtures(); + } + + [Fact] + public void Every_kind_is_either_fixtured_or_registered_as_known_debt() + { + var fixtured = LoadAndVerifyFixtures(); + var allKinds = (DecisionSignalKind[])Enum.GetValues(typeof(DecisionSignalKind)); + + var unfixturedAndUntracked = allKinds + .Where(k => !fixtured.Contains(k) && !KnownMissingFixtures.Contains(k)) + .ToArray(); + Assert.True( + unfixturedAndUntracked.Length == 0, + "DecisionSignalKind values without a replay fixture in tests/fixtures/signal-kinds/: " + + $"[{string.Join(", ", unfixturedAndUntracked)}]. The convention on DecisionSignalKind is " + + "'new kind => fixture' — add a {kind}-v{n}.json fixture (do NOT extend KnownMissingFixtures)."); + + // Debt only shrinks: once a fixture exists, the kind must leave the debt set. + var fixturedButStillListedAsDebt = KnownMissingFixtures.Where(fixtured.Contains).ToArray(); + Assert.True( + fixturedButStillListedAsDebt.Length == 0, + "Kinds now have fixtures but are still listed in KnownMissingFixtures — remove them: " + + $"[{string.Join(", ", fixturedButStillListedAsDebt)}]."); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/DecisionStateBuilderCompletenessTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/DecisionStateBuilderCompletenessTests.cs new file mode 100644 index 00000000..1e85d56c --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/DecisionStateBuilderCompletenessTests.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.State +{ + /// + /// Drift tripwire for the 4-site sync required when adding a field to + /// : ctor parameter + + /// copy-ctor line + builder property + Build() argument. A forgotten copy-ctor + /// line silently resets the fact on every reducer step; a forgotten Build() + /// argument resets it on the first copy-with-changes. These tests are fully + /// reflection-driven so a new field fails them until all four sites are wired + /// (and until a value factory for any brand-new property type is added below). + /// + public sealed class DecisionStateBuilderCompletenessTests + { + private static readonly DateTime BaseUtc = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private static PropertyInfo[] StateProperties() => + typeof(DecisionState) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .ToArray(); + + private static PropertyInfo[] BuilderProperties() => + typeof(DecisionStateBuilder) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.SetMethod != null && p.SetMethod.IsPublic) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .ToArray(); + + /// + /// Produce a distinct, non-default value for a builder property. + /// is folded into every value so no two properties of the same type ever share an + /// instance — a swapped Build() argument (e.g. espFinalExitUtc passed into + /// desktopArrivedUtc) is therefore detected, not masked. A property type without a + /// factory entry fails loudly: extend the factory when a new field type appears. + /// + private static object CreateDistinctValue(Type type, int seed) + { + long ordinal = 100 + seed; + if (type == typeof(string)) return "test-value-" + seed; + if (type == typeof(int)) return 7 + seed; + if (type == typeof(long)) return 7L + seed; + if (type == typeof(DateTime?)) return BaseUtc.AddMinutes(seed); + if (type == typeof(SessionStage)) return SessionStage.AwaitingHello; + if (type == typeof(SessionOutcome?)) return SessionOutcome.EnrollmentComplete; + if (type == typeof(SignalFact)) return new SignalFact(BaseUtc.AddMinutes(seed), ordinal); + if (type == typeof(SignalFact)) return new SignalFact("test-value-" + seed, ordinal); + if (type == typeof(SignalFact)) return new SignalFact(true, ordinal); + if (type == typeof(SignalFact)) return new SignalFact(EnrollmentPhase.AccountSetup, ordinal); + if (type == typeof(List)) + { + return new List + { + new ActiveDeadline( + name: "deadline-" + seed, + dueAtUtc: BaseUtc.AddMinutes(seed), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary { ["k"] = "v" + seed }), + }; + } + // Immutable fact aggregates — built through their public APIs so the instances are + // reference-distinct from their Empty defaults AND fully serializable (a dropped + // copy-ctor line or Build() argument falls back to Empty → reference inequality). + if (type == typeof(AppInstallFacts)) return AppInstallFacts.Empty.WithCompleted("Installed").WithFailed("app-" + seed); + if (type == typeof(EnrollmentScenarioProfile)) + { + return EnrollmentScenarioProfile.Empty.With( + mode: EnrollmentMode.WhiteGlove, + confidence: ProfileConfidence.High, + evidenceOrdinal: ordinal, + reason: "test-value-" + seed); + } + if (type == typeof(EnrollmentScenarioObservations)) return EnrollmentScenarioObservations.Empty.WithSkipUserEsp(true, ordinal); + if (type == typeof(ClassifierOutcomes)) + { + return ClassifierOutcomes.Empty.WithWhiteGloveSealing(new Hypothesis( + level: HypothesisLevel.Strong, + reason: "test-value-" + seed, + score: 50, + lastUpdatedUtc: BaseUtc.AddMinutes(seed), + lastClassifierVerdictId: "verdict-" + seed)); + } + if (type == typeof(RealmJoinFacts)) return RealmJoinFacts.Empty.WithDetected(BaseUtc.AddMinutes(seed), ordinal); + + throw new Xunit.Sdk.XunitException( + $"No value factory for builder property type {type}. A new DecisionState field " + + "introduced a new type — add a distinct-value factory entry so the completeness " + + "tests can populate it."); + } + + /// Populate every settable builder property with a distinct non-default value. + private static Dictionary PopulateBuilder(DecisionStateBuilder builder) + { + var assigned = new Dictionary(StringComparer.Ordinal); + int seed = 0; + foreach (var prop in BuilderProperties()) + { + var value = CreateDistinctValue(prop.PropertyType, seed++); + prop.SetValue(builder, value); + assigned[prop.Name] = value; + } + return assigned; + } + + private static void AssertMatches(string propertyName, object expected, object? actual) + { + // Deadlines: Build() copies the List into an array and the copy-ctor re-wraps it — + // the container is a new instance by design, but the elements must be the SAME refs. + if (expected is List expectedDeadlines) + { + var actualDeadlines = Assert.IsAssignableFrom>(actual).ToList(); + Assert.Equal(expectedDeadlines.Count, actualDeadlines.Count); + for (int i = 0; i < expectedDeadlines.Count; i++) + { + Assert.Same(expectedDeadlines[i], actualDeadlines[i]); + } + return; + } + + // Value types (enums, int, long, DateTime?) and strings compare by value; every + // reference-typed fact must be the identical instance (a re-created equal-looking + // value would still hide a dropped pass-through). + if (expected is string || expected.GetType().IsValueType) + { + Assert.True( + expected.Equals(actual), + $"{propertyName}: expected {expected}, got {actual ?? "(null)"}."); + } + else + { + Assert.Same(expected, actual); + } + } + + // ============================================================= (a) name-set parity + + [Fact] + public void Builder_property_set_matches_DecisionState_property_set() + { + var stateNames = StateProperties().Select(p => p.Name).ToArray(); + var builderNames = BuilderProperties().Select(p => p.Name).ToArray(); + + var missingOnBuilder = stateNames.Except(builderNames, StringComparer.Ordinal).ToArray(); + var missingOnState = builderNames.Except(stateNames, StringComparer.Ordinal).ToArray(); + + Assert.True( + missingOnBuilder.Length == 0 && missingOnState.Length == 0, + "DecisionState and DecisionStateBuilder property sets drifted apart. " + + $"State-only: [{string.Join(", ", missingOnBuilder)}]; " + + $"Builder-only: [{string.Join(", ", missingOnState)}]. " + + "Adding a DecisionState field requires: ctor parameter, builder copy-ctor line, " + + "builder property, and Build() argument."); + } + + // ============================================================= (b) Build() completeness + + [Fact] + public void Build_carries_every_builder_property_onto_the_state() + { + var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder(); + var assigned = PopulateBuilder(builder); + + var state = builder.Build(); + + foreach (var stateProp in StateProperties()) + { + Assert.True( + assigned.TryGetValue(stateProp.Name, out var expected), + $"DecisionState.{stateProp.Name} has no builder counterpart — see the name-set parity test."); + AssertMatches($"DecisionState.{stateProp.Name}", expected!, stateProp.GetValue(state)); + } + } + + // ============================================================= (c) copy-ctor completeness + + [Fact] + public void ToBuilder_copies_every_property_back_from_the_state() + { + var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder(); + var assigned = PopulateBuilder(builder); + var state = builder.Build(); + + var roundtrippedBuilder = state.ToBuilder(); + + foreach (var builderProp in BuilderProperties()) + { + var expected = assigned[builderProp.Name]; + AssertMatches($"DecisionStateBuilder.{builderProp.Name}", expected, builderProp.GetValue(roundtrippedBuilder)); + } + } + + // ============================================================= (d) serializer roundtrip + + [Fact] + public void StateSerializer_roundtrip_of_fully_populated_state_is_lossless() + { + // Catches Newtonsoft ctor-binding mismatches: deserialization binds JSON properties + // onto the single public ctor by (case-insensitive) parameter name, so a renamed + // ctor parameter silently drops the field back to its default. Serializing the + // deserialized state must therefore reproduce the exact same JSON. + var builder = DecisionState.CreateInitial("seed-session", "seed-tenant").ToBuilder(); + PopulateBuilder(builder); + var state = builder.Build(); + + var json = StateSerializer.Serialize(state); + var roundtrippedJson = StateSerializer.Serialize(StateSerializer.Deserialize(json)); + + Assert.Equal(json, roundtrippedJson); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs new file mode 100644 index 00000000..f49d98e0 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SharedManifestParityTests.cs @@ -0,0 +1,146 @@ +using System.Reflection; +using System.Text.Json; +using AutopilotMonitor.Functions.Functions.Rules; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Notifications; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Cross-language parity anchor: reflects the wire-relevant catalogs out of +/// AutopilotMonitor.Shared (models, enums, string vocabularies) into a canonical JSON +/// manifest committed at src/Web/autopilot-monitor-web/utils/shared-manifests.json. +/// The web's vitest suite and compile-time checks compare their hand-written TS mirrors +/// against that manifest — so a C# change that would silently drift a TS mirror first +/// fails HERE, and the TS side fails until its mirror follows. +/// +/// Regenerate after intentional changes: +/// AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests +/// then run the web codegen: node scripts/generate-shared-manifest-types.js +/// +public sealed class SharedManifestParityTests +{ + private const string ManifestRepoPath = "src/Web/autopilot-monitor-web/utils/shared-manifests.json"; + private const string WriteEnvVar = "AM_WRITE_SHARED_MANIFESTS"; + + [Fact] + public void Committed_manifest_matches_shared_assembly() + { + var expected = BuildManifestJson(); + var path = Path.Combine(FindRepoRoot(), ManifestRepoPath.Replace('/', Path.DirectorySeparatorChar)); + + if (Environment.GetEnvironmentVariable(WriteEnvVar) == "1") + { + File.WriteAllText(path, expected); + return; + } + + Assert.True(File.Exists(path), + $"Manifest missing at {ManifestRepoPath}. Regenerate: {WriteEnvVar}=1 dotnet test --filter SharedManifestParityTests"); + + var committed = File.ReadAllText(path).Replace("\r\n", "\n"); + Assert.True(string.Equals(expected, committed, StringComparison.Ordinal), + $"shared-manifests.json is out of date with AutopilotMonitor.Shared. " + + $"Regenerate: {WriteEnvVar}=1 dotnet test --filter SharedManifestParityTests " + + $"(then: node scripts/generate-shared-manifest-types.js in the web folder)."); + } + + // ── manifest construction ─────────────────────────────────────────────── + + private static string BuildManifestJson() + { + var manifest = new Dictionary + { + ["$comment"] = "GENERATED from AutopilotMonitor.Shared by SharedManifestParityTests — do not edit by hand. " + + "Regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, " + + "then node scripts/generate-shared-manifest-types.js.", + ["adminConfiguration"] = new Dictionary + { + ["fields"] = WireFieldNames(typeof(AdminConfiguration)), + }, + ["tenantConfiguration"] = new Dictionary + { + ["fields"] = WireFieldNames(typeof(TenantConfiguration)), + }, + ["sessionSummary"] = new Dictionary + { + ["fields"] = WireFieldsWithOptionality(typeof(SessionSummary)), + }, + // Enum member order is load-bearing (append-only ordinals) — declaration order kept. + ["sessionStatuses"] = Enum.GetNames(typeof(SessionStatus)), + ["enrollmentPhases"] = EnumMap(), + ["eventSeverities"] = EnumMap(), + ["webhookProviderTypes"] = EnumMap(), + ["annotationLanes"] = AnnotationLanes.All, + ["annotationVerdicts"] = AnnotationVerdicts.All, + ["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)), + ["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)), + ["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)), + ["analyzeRuleSources"] = DryRunAnalyzeRuleFunction.KnownSources.OrderBy(s => s, StringComparer.Ordinal).ToArray(), + ["analyzeRuleOperators"] = DryRunAnalyzeRuleFunction.KnownOperators.OrderBy(s => s, StringComparer.Ordinal).ToArray(), + ["eventTypes"] = ConstStrings(typeof(Constants.EventTypes)).OrderBy(s => s, StringComparer.Ordinal).ToArray(), + }; + + var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + // Normalized to LF + trailing newline so the file is byte-stable across OSes. + return json.Replace("\r\n", "\n") + "\n"; + } + + /// Wire (camelCase) names of the public instance properties, declaration order. + private static string[] WireFieldNames(Type type) + => WireProperties(type).Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).ToArray(); + + /// + /// Wire names plus whether the field can be ABSENT from the JSON: nullable fields are + /// omitted under WhenWritingNull, so nullable ⇒ the TS mirror must type it optional. + /// + private static object[] WireFieldsWithOptionality(Type type) + { + var nullability = new NullabilityInfoContext(); + return WireProperties(type) + .Select(p => (object)new Dictionary + { + ["name"] = JsonNamingPolicy.CamelCase.ConvertName(p.Name), + ["optional"] = IsNullable(p, nullability), + }) + .ToArray(); + } + + private static IEnumerable WireProperties(Type type) + => type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.GetMethod != null && p.GetIndexParameters().Length == 0); + + private static bool IsNullable(PropertyInfo p, NullabilityInfoContext ctx) + { + if (Nullable.GetUnderlyingType(p.PropertyType) != null) return true; + if (p.PropertyType.IsValueType) return false; + return ctx.Create(p).ReadState != NullabilityState.NotNull; + } + + private static Dictionary EnumMap() where TEnum : struct, Enum + => Enum.GetValues() + .ToDictionary(v => v.ToString(), v => Convert.ToInt32(v)); + + private static string[] ConstStrings(Type constClass) + => constClass.GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()!) + .ToArray(); + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln"))) + { + dir = dir.Parent; + } + Assert.NotNull(dir); + return dir!.FullName; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs index 48c501b9..2188b80c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs @@ -131,13 +131,14 @@ namespace AutopilotMonitor.Functions.Functions.Rules // switch is case-sensitive ("event_type", not "Event_Type"), and an unknown source/operator // silently evaluates to false in production — for a dry-run that silence is exactly the // confusion we want to catch up front. - private static readonly HashSet KnownSources = new(StringComparer.Ordinal) + // internal: SharedManifestParityTests exports these into the cross-language manifest. + internal static readonly HashSet KnownSources = new(StringComparer.Ordinal) { "event_type", "event_data", "event_data_array", "event_count", "phase_duration", "app_install_duration", "event_correlation", }; - private static readonly HashSet KnownOperators = new(StringComparer.OrdinalIgnoreCase) + internal static readonly HashSet KnownOperators = new(StringComparer.OrdinalIgnoreCase) { "equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", diff --git a/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js b/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js new file mode 100644 index 00000000..6efbb99f --- /dev/null +++ b/src/Web/autopilot-monitor-web/scripts/generate-shared-manifest-types.js @@ -0,0 +1,48 @@ +/** + * Generate utils/shared-manifests.generated.ts from utils/shared-manifests.json. + * + * The JSON is produced by the backend reflection guard (SharedManifestParityTests, + * regenerated via AM_WRITE_SHARED_MANIFESTS=1) — this script only lifts it into an + * `as const` TS module so the manifest's entries exist as LITERAL types, enabling the + * compile-time drift checks in utils/sharedManifestChecks.ts. + * + * Run: node scripts/generate-shared-manifest-types.js + * Freshness is pinned by utils/__tests__/sharedManifestParity.test.ts. + */ + +const fs = require("fs"); +const path = require("path"); + +const WEB_ROOT = path.resolve(__dirname, ".."); +const SOURCE = path.join(WEB_ROOT, "utils", "shared-manifests.json"); +const DEST = path.join(WEB_ROOT, "utils", "shared-manifests.generated.ts"); + +/** Pure builder so the vitest freshness check can reuse it. */ +function buildGeneratedSource(manifestJsonText) { + const manifest = JSON.parse(manifestJsonText); + return ( + "// GENERATED from shared-manifests.json — do not edit by hand.\n" + + "// Regenerate: node scripts/generate-shared-manifest-types.js\n" + + "// (after AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests)\n" + + "\n" + + "export const SHARED_MANIFEST = " + + JSON.stringify(manifest, null, 2) + + " as const;\n" + ); +} + +function main() { + if (!fs.existsSync(SOURCE)) { + console.error(`[shared-manifests] Source not found: ${SOURCE}`); + process.exit(1); + } + const out = buildGeneratedSource(fs.readFileSync(SOURCE, "utf8")); + fs.writeFileSync(DEST, out, "utf8"); + console.log(`[shared-manifests] Wrote ${path.relative(WEB_ROOT, DEST)}`); +} + +if (require.main === module) { + main(); +} + +module.exports = { buildGeneratedSource }; diff --git a/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts b/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts new file mode 100644 index 00000000..5f15a3b8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/__tests__/sharedManifestParity.test.ts @@ -0,0 +1,115 @@ +/** + * Runtime parity between hand-written TS mirrors and the C#-generated shared manifest. + * The manifest is the single cross-language anchor: the backend reflection guard + * (SharedManifestParityTests) pins JSON ↔ C#, these tests pin TS ↔ JSON. + * Compile-time (key-level) checks live in utils/sharedManifestChecks.ts. + */ +import { describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; +import { SHARED_MANIFEST } from "../shared-manifests.generated"; +import { SEVERITY_INT } from "../sessionExportUtils"; +import { isTerminalStatus } from "../sessionStatus"; +import { OPERATORS, PRECONDITION_OPERATORS, SOURCES } from "@/app/analyze-rules/types"; +import { + ANNOTATION_LANES, + ANNOTATION_VERDICTS, +} from "@/app/sessions/components/sessionAnnotationLogic"; +import { V1_PHASE_NAMES, V2_PHASE_NAMES } from "@/app/sessions/utils/phaseConstants"; +import { KNOWN_EVENT_TYPES } from "@/app/gather-rules/eventTypes"; + +const require = createRequire(import.meta.url); +const { buildGeneratedSource } = require("../../scripts/generate-shared-manifest-types.js"); + +const utilsDir = path.resolve(__dirname, ".."); + +describe("shared-manifests.generated.ts freshness", () => { + it("matches a fresh run of the codegen over shared-manifests.json", () => { + const json = fs.readFileSync(path.join(utilsDir, "shared-manifests.json"), "utf8"); + const committed = fs.readFileSync( + path.join(utilsDir, "shared-manifests.generated.ts"), + "utf8" + ); + expect(committed.replace(/\r\n/g, "\n")).toBe(buildGeneratedSource(json)); + }); +}); + +describe("analyze-rule builder catalogs", () => { + it("SOURCES equals the backend's KnownSources", () => { + expect([...SOURCES].sort()).toEqual([...SHARED_MANIFEST.analyzeRuleSources].sort()); + }); + + it("OPERATORS equals the backend's KnownOperators", () => { + expect([...OPERATORS].sort()).toEqual([...SHARED_MANIFEST.analyzeRuleOperators].sort()); + }); + + it("PRECONDITION_OPERATORS is a subset of the backend's KnownOperators", () => { + const known = new Set(SHARED_MANIFEST.analyzeRuleOperators); + for (const op of PRECONDITION_OPERATORS) expect(known).toContain(op); + }); +}); + +describe("annotation lanes and verdicts", () => { + it("lanes match", () => { + expect([...ANNOTATION_LANES]).toEqual([...SHARED_MANIFEST.annotationLanes]); + }); + + it("verdicts match", () => { + expect([...ANNOTATION_VERDICTS]).toEqual([...SHARED_MANIFEST.annotationVerdicts]); + }); +}); + +describe("event severities", () => { + it("SEVERITY_INT mirrors the C# EventSeverity enum exactly (names and ordinals)", () => { + expect(SEVERITY_INT).toEqual(SHARED_MANIFEST.eventSeverities); + }); +}); + +describe("enrollment phases", () => { + it("every numeric phase key used by the UI exists on the C# enum", () => { + // Display STRINGS are deliberately surface-specific (V1 vs V2 wording); the + // numeric ordinals are the wire contract. + const enumValues = new Set(Object.values(SHARED_MANIFEST.enrollmentPhases)); + for (const key of [...Object.keys(V1_PHASE_NAMES), ...Object.keys(V2_PHASE_NAMES)]) { + expect(enumValues).toContain(Number(key)); + } + }); + + it("every C# enum ordinal has a UI name in both version maps", () => { + for (const value of Object.values(SHARED_MANIFEST.enrollmentPhases)) { + expect(V1_PHASE_NAMES[value], `V1 name for phase ${value}`).toBeTruthy(); + expect(V2_PHASE_NAMES[value], `V2 name for phase ${value}`).toBeTruthy(); + } + }); +}); + +describe("session statuses", () => { + it("the terminal trio exists on the C# enum and is classified terminal", () => { + for (const status of ["Succeeded", "Failed", "Incomplete"]) { + expect(SHARED_MANIFEST.sessionStatuses).toContain(status); + expect(isTerminalStatus(status)).toBe(true); + } + }); + + it("non-terminal enum members are not classified terminal", () => { + for (const status of SHARED_MANIFEST.sessionStatuses) { + if (["Succeeded", "Failed", "Incomplete"].includes(status)) continue; + expect(isTerminalStatus(status), `${status} must not be terminal`).toBe(false); + } + }); +}); + +describe("gather-rule event-type autocomplete", () => { + it("contains no event type the agent/backend does not know", () => { + // Typo guard: every autocomplete entry must be a real Constants.EventTypes value. + // (Completeness in the other direction is a deliberate curation question — the + // catalog carries ~3x more types than the rule builder surfaces today.) + const known = new Set(SHARED_MANIFEST.eventTypes); + for (const entry of KNOWN_EVENT_TYPES) { + expect(known, `unknown event type '${entry.value}' in KNOWN_EVENT_TYPES`).toContain( + entry.value + ); + } + }); +}); diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts new file mode 100644 index 00000000..04889a06 --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -0,0 +1,643 @@ +// GENERATED from shared-manifests.json — do not edit by hand. +// Regenerate: node scripts/generate-shared-manifest-types.js +// (after AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests) + +export const SHARED_MANIFEST = { + "$comment": "GENERATED from AutopilotMonitor.Shared by SharedManifestParityTests — do not edit by hand. Regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, then node scripts/generate-shared-manifest-types.js.", + "adminConfiguration": { + "fields": [ + "partitionKey", + "rowKey", + "lastUpdated", + "updatedBy", + "globalRateLimitRequestsPerMinute", + "userRateLimitRequestsPerMinute", + "globalAdminRateLimitRequestsPerMinute", + "planTierDefinitionsJson", + "platformStatsBlobSasUrl", + "collectorIdleTimeoutMinutes", + "desktopDetectorNoCandidateTimeoutMinutes", + "excessiveEventCountThreshold", + "excessiveEventAutoActionMode", + "excessiveEventAutoActionThreshold", + "excessiveEventAutoActionDurationHours", + "opsEventRetentionDays", + "slaNotificationCooldownHours", + "feedbackEnabled", + "feedbackMinTenantAgeDays", + "feedbackCooldownDays", + "diagnosticsGlobalLogPathsJson", + "maxDiagnosticsDownloadSizeMB", + "diagnosticsDownloadTimeoutSeconds", + "mcpAccessPolicy", + "nvdApiKey", + "opsAlertRulesJson", + "opsAlertTelegramEnabled", + "opsAlertTelegramChatId", + "opsAlertTeamsEnabled", + "opsAlertTeamsWebhookUrl", + "opsAlertSlackEnabled", + "opsAlertSlackWebhookUrl", + "latestAgentV2Version", + "latestAgentV2Sha256", + "latestAgentV2ExeSha256", + "latestBootstrapV2ScriptVersion", + "allowAgentDowngrade", + "agentMigrateApiBaseUrl", + "agentMigrateTenantOverridesJson", + "modernDeploymentHarmlessEventIdsJson", + "whiteGloveSealingPatternIdsJson", + "vulnerabilityCorrelationEnabled", + "enableIndexDualWrite", + "autoApproveNewTenants", + "selfServiceAppHomingEnabled", + "sessionDeletionKillSwitch", + "vulnerabilityDataLastSyncUtc", + "msrcLastSyncUtc" + ] + }, + "tenantConfiguration": { + "fields": [ + "tenantId", + "domainName", + "lastUpdated", + "updatedBy", + "onboardedBy", + "contactEmail", + "onboardedAt", + "homedAppClientId", + "lastAuthClientId", + "lastAuthClientIdSince", + "disabled", + "disabledReason", + "disabledUntil", + "customRateLimitRequestsPerMinute", + "customUserRateLimitRequestsPerMinute", + "planTier", + "trialExpiresUtc", + "trialStartedUtc", + "trialConsumed", + "trialGrantedBy", + "manufacturerWhitelist", + "modelWhitelist", + "validateAutopilotDevice", + "validateCorporateIdentifier", + "validateDeviceAssociation", + "validateCloudPcDevice", + "allowInsecureAgentRequests", + "dataRetentionDays", + "sessionTimeoutHours", + "sessionGraceHours", + "maxNdjsonPayloadSizeMB", + "enablePerformanceCollector", + "performanceCollectorIntervalSeconds", + "helloWaitTimeoutSeconds", + "maxAuthFailures", + "authFailureTimeoutMinutes", + "agentMaxLifetimeMinutes", + "absoluteMaxSessionHours", + "selfDestructOnComplete", + "keepLogFile", + "rebootOnComplete", + "rebootDelaySeconds", + "enableGeoLocation", + "ntpServer", + "enableTimezoneAutoSet", + "enableImeMatchLog", + "enableGatherRuleDebugLog", + "enableEspContinueAnywayObservation", + "logLevel", + "maxBatchSize", + "showEnrollmentSummary", + "enrollmentSummaryTimeoutSeconds", + "enrollmentSummaryBrandingImageUrl", + "enrollmentSummaryLaunchRetrySeconds", + "showScriptOutput", + "enableLocalAdminAnalyzer", + "enableSoftwareInventoryAnalyzer", + "enableIntegrityBypassAnalyzer", + "enableRealmJoinWatcher", + "keepAwakeDuringUserEsp", + "enableConsoleBypassDetection", + "localAdminAllowedAccountsJson", + "bootstrapTokenEnabled", + "unrestrictedModeEnabled", + "unrestrictedMode", + "entraAppRolesEnabled", + "diagnosticsLogPathsJson", + "diagnosticsBlobSasUrl", + "diagnosticsUploadMode", + "diagnosticsUploadDestination", + "sendTraceEvents", + "teamsWebhookUrl", + "teamsNotifyOnSuccess", + "teamsNotifyOnFailure", + "teamsNotifyOnStart", + "webhookProviderType", + "webhookUrl", + "webhookNotifyOnSuccess", + "webhookNotifyOnFailure", + "webhookNotifyOnHardwareRejection", + "webhookNotifyOnStart", + "webhookCustomHeadersJson", + "notificationChannelsJson", + "slaTargetSuccessRate", + "slaTargetMaxDurationMinutes", + "slaTargetAppInstallSuccessRate", + "slaNotifyOnSuccessRateBreach", + "slaSuccessRateNotifyThreshold", + "slaNotifyOnDurationBreach", + "slaNotifyOnAppInstallBreach", + "slaNotifyOnConsecutiveFailures", + "slaConsecutiveFailureThreshold" + ] + }, + "sessionSummary": { + "fields": [ + { + "name": "sessionId", + "optional": false + }, + { + "name": "tenantId", + "optional": false + }, + { + "name": "serialNumber", + "optional": false + }, + { + "name": "deviceName", + "optional": false + }, + { + "name": "manufacturer", + "optional": false + }, + { + "name": "model", + "optional": false + }, + { + "name": "startedAt", + "optional": false + }, + { + "name": "completedAt", + "optional": true + }, + { + "name": "currentPhase", + "optional": false + }, + { + "name": "currentPhaseDetail", + "optional": false + }, + { + "name": "status", + "optional": false + }, + { + "name": "failureReason", + "optional": false + }, + { + "name": "failureSource", + "optional": false + }, + { + "name": "reconcileReason", + "optional": false + }, + { + "name": "espSoftFailure", + "optional": false + }, + { + "name": "completionSource", + "optional": false + }, + { + "name": "adminMarkedAction", + "optional": true + }, + { + "name": "validatedBy", + "optional": false + }, + { + "name": "eventCount", + "optional": false + }, + { + "name": "durationSeconds", + "optional": true + }, + { + "name": "avgApiLatencyMs", + "optional": true + }, + { + "name": "apiRequestCount", + "optional": true + }, + { + "name": "connectionType", + "optional": true + }, + { + "name": "enrollmentType", + "optional": false + }, + { + "name": "diagnosticsBlobName", + "optional": false + }, + { + "name": "diagnosticsBlobDestination", + "optional": true + }, + { + "name": "lastEventAt", + "optional": true + }, + { + "name": "isPreProvisioned", + "optional": false + }, + { + "name": "resumedAt", + "optional": true + }, + { + "name": "stalledAt", + "optional": true + }, + { + "name": "isHybridJoin", + "optional": false + }, + { + "name": "isSelfDeployingProfile", + "optional": false + }, + { + "name": "isCloudPc", + "optional": false + }, + { + "name": "osName", + "optional": false + }, + { + "name": "osBuild", + "optional": false + }, + { + "name": "osDisplayVersion", + "optional": false + }, + { + "name": "osEdition", + "optional": false + }, + { + "name": "osLanguage", + "optional": false + }, + { + "name": "isUserDriven", + "optional": false + }, + { + "name": "agentVersion", + "optional": false + }, + { + "name": "imeAgentVersion", + "optional": false + }, + { + "name": "geoCountry", + "optional": false + }, + { + "name": "geoRegion", + "optional": false + }, + { + "name": "geoCity", + "optional": false + }, + { + "name": "geoLoc", + "optional": false + }, + { + "name": "platformScriptCount", + "optional": false + }, + { + "name": "remediationScriptCount", + "optional": false + }, + { + "name": "rebootCount", + "optional": false + }, + { + "name": "excessiveEventsAlerted", + "optional": false + }, + { + "name": "excessiveEventsAutoActioned", + "optional": false + }, + { + "name": "pendingActionsJson", + "optional": false + }, + { + "name": "pendingActionsQueuedAt", + "optional": true + }, + { + "name": "failureSnapshotJson", + "optional": false + }, + { + "name": "deletionState", + "optional": false + }, + { + "name": "pendingDeletionManifestId", + "optional": true + } + ] + }, + "sessionStatuses": [ + "InProgress", + "Pending", + "Stalled", + "Succeeded", + "Failed", + "Unknown", + "AwaitingUser", + "Incomplete" + ], + "enrollmentPhases": { + "Start": 0, + "DevicePreparation": 1, + "DeviceSetup": 2, + "AppsDevice": 3, + "AccountSetup": 4, + "AppsUser": 5, + "FinalizingSetup": 6, + "Complete": 7, + "Failed": 99, + "Unknown": -1 + }, + "eventSeverities": { + "Debug": 0, + "Info": 1, + "Warning": 2, + "Error": 3, + "Critical": 4, + "Trace": -1 + }, + "webhookProviderTypes": { + "None": 0, + "TeamsLegacyConnector": 1, + "TeamsWorkflowWebhook": 2, + "Slack": 10, + "GenericJson": 20 + }, + "annotationLanes": [ + "operator", + "tenantadmin", + "globaladmin" + ], + "annotationVerdicts": [ + "root_cause_confirmed", + "analysis_wrong", + "different_problem", + "inconclusive" + ], + "tenantRoles": [ + "Admin", + "Operator", + "Viewer" + ], + "globalRoles": [ + "GlobalAdmin", + "GlobalReader" + ], + "delegatedRoles": [ + "DelegatedReader", + "DelegatedAdmin" + ], + "analyzeRuleSources": [ + "app_install_duration", + "event_correlation", + "event_count", + "event_data", + "event_data_array", + "event_type", + "phase_duration" + ], + "analyzeRuleOperators": [ + "contains", + "count_gte", + "count_per_group_gte", + "equals", + "exists", + "gt", + "gte", + "in", + "lt", + "lte", + "not_contains", + "not_equals", + "not_exists", + "not_in", + "not_regex", + "regex" + ], + "eventTypes": [ + "aad_join_status", + "aad_placeholder_user_detected", + "aad_user_joined_observed", + "admin_marked_session", + "agent_emergency_break", + "agent_late_start", + "agent_metrics_collector_stopped", + "agent_metrics_snapshot", + "agent_shutdown", + "agent_shutting_down", + "agent_started", + "agent_trace", + "agent_unrestricted_mode_changed", + "agent_version_check", + "all_apps_completed", + "app_download_started", + "app_install_completed", + "app_install_failed", + "app_install_skipped", + "app_install_started", + "app_install_starved", + "app_tracking_summary", + "autologon_analysis", + "autopilot_profile", + "autopilot_profile_missing", + "bitlocker_status", + "boot_time", + "cert_validation", + "collector_degraded", + "completion_check", + "completion_waiting", + "configmgr_client_detected", + "console_prefetch_detected", + "decision_process_completion", + "desktop_arrived", + "desktop_detector_first_poll", + "desktop_detector_no_candidate", + "desktop_detector_started", + "device_location", + "diagnostics_collecting", + "diagnostics_upload_failed", + "diagnostics_uploaded", + "disk_space_low", + "dns_configuration", + "do_telemetry", + "download_progress", + "enrollment_complete", + "enrollment_failed", + "enrollment_summary_shown", + "enrollment_type_detected", + "enrollment_type_mismatch", + "error_detected", + "esp_apps_failure_correlation", + "esp_appx_failure_analysis", + "esp_config_detected", + "esp_exiting", + "esp_failure", + "esp_failure_advisory", + "esp_failure_advisory_resolved", + "esp_failure_recovered", + "esp_failure_retry_detected", + "esp_failure_settle_recovered", + "esp_failure_settle_started", + "esp_phase_changed", + "esp_policy_provider_stalled", + "esp_provisioning_raw", + "esp_provisioning_settle_started", + "esp_provisioning_status", + "esp_resumed", + "esp_state_change", + "esp_ui_state", + "gather_result", + "gather_rules_collection_completed", + "gather_rules_collection_started", + "hardware_spec", + "hello_completion_timeout", + "hello_pin_status", + "hello_policy_detected", + "hello_policy_detection_mismatch", + "hello_processing_started", + "hello_processing_stopped", + "hello_provisioning_blocked", + "hello_provisioning_completed", + "hello_provisioning_failed", + "hello_skipped", + "hello_wait_timeout", + "hello_wizard_started", + "historic_ime_replay_detected", + "hybrid_login_pending", + "ime_agent_version", + "ime_process_exited", + "ime_session_change", + "ime_user_session_completed", + "ingress_backpressure", + "integrity_bypass_analysis", + "keep_awake_engaged", + "keep_awake_released", + "local_admin_analysis", + "log_entry", + "mdm_policy_reboot_required", + "modern_deployment_error", + "modern_deployment_log", + "modern_deployment_warning", + "network_adapters", + "network_bandwidth_estimate", + "network_connectivity_check", + "network_interface_info", + "network_state_change", + "ntp_time_check", + "office_install_completed", + "office_install_failed", + "office_install_progress", + "office_install_started", + "office_preinstalled_detected", + "oobe_console_spawned", + "oobe_state_completed", + "os_build_changed", + "os_info", + "outbound_ip", + "performance_collector_stopped", + "performance_snapshot", + "phase_transition", + "power_state_check", + "previous_crash_detected", + "prior_run_died_with_state", + "provisioning_package_scan", + "proxy_configuration", + "realmjoin_detected", + "realmjoin_first_deployment_incomplete", + "realmjoin_package_completed", + "realmjoin_package_started", + "realmjoin_phase_changed", + "realmjoin_resolved", + "realmjoin_timeout", + "reboot_triggered", + "remote_config_fetch_failed", + "script_completed", + "script_failed", + "script_started", + "script_timeout_suspected", + "secureboot_status", + "security_audit", + "security_warning", + "server_action_executed", + "server_action_failed", + "server_action_received", + "session_parked_without_deadline", + "session_stalled", + "session_timeout", + "shadow_discrepancy", + "software_inventory_analysis", + "spool_pressure_detected", + "stall_probe_check", + "stall_probe_result", + "state_quarantine_recovered", + "system_reboot_detected", + "telemetry_upload_blocked", + "telemetry_upload_poisoned", + "timezone_auto_set", + "tpm_status", + "vulnerability_report", + "waiting_for_hello", + "whiteglove_classification", + "whiteglove_complete", + "whiteglove_part1_complete", + "whiteglove_resumed", + "whiteglove_started", + "wifi_signal_info", + "windows_update_channel_census", + "windows_update_failed", + "windows_update_history", + "windows_update_reboot_pending", + "windows_update_started", + "windows_update_succeeded" + ] +} as const; diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json new file mode 100644 index 00000000..d0aeb77f --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -0,0 +1,639 @@ +{ + "$comment": "GENERATED from AutopilotMonitor.Shared by SharedManifestParityTests — do not edit by hand. Regenerate: AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, then node scripts/generate-shared-manifest-types.js.", + "adminConfiguration": { + "fields": [ + "partitionKey", + "rowKey", + "lastUpdated", + "updatedBy", + "globalRateLimitRequestsPerMinute", + "userRateLimitRequestsPerMinute", + "globalAdminRateLimitRequestsPerMinute", + "planTierDefinitionsJson", + "platformStatsBlobSasUrl", + "collectorIdleTimeoutMinutes", + "desktopDetectorNoCandidateTimeoutMinutes", + "excessiveEventCountThreshold", + "excessiveEventAutoActionMode", + "excessiveEventAutoActionThreshold", + "excessiveEventAutoActionDurationHours", + "opsEventRetentionDays", + "slaNotificationCooldownHours", + "feedbackEnabled", + "feedbackMinTenantAgeDays", + "feedbackCooldownDays", + "diagnosticsGlobalLogPathsJson", + "maxDiagnosticsDownloadSizeMB", + "diagnosticsDownloadTimeoutSeconds", + "mcpAccessPolicy", + "nvdApiKey", + "opsAlertRulesJson", + "opsAlertTelegramEnabled", + "opsAlertTelegramChatId", + "opsAlertTeamsEnabled", + "opsAlertTeamsWebhookUrl", + "opsAlertSlackEnabled", + "opsAlertSlackWebhookUrl", + "latestAgentV2Version", + "latestAgentV2Sha256", + "latestAgentV2ExeSha256", + "latestBootstrapV2ScriptVersion", + "allowAgentDowngrade", + "agentMigrateApiBaseUrl", + "agentMigrateTenantOverridesJson", + "modernDeploymentHarmlessEventIdsJson", + "whiteGloveSealingPatternIdsJson", + "vulnerabilityCorrelationEnabled", + "enableIndexDualWrite", + "autoApproveNewTenants", + "selfServiceAppHomingEnabled", + "sessionDeletionKillSwitch", + "vulnerabilityDataLastSyncUtc", + "msrcLastSyncUtc" + ] + }, + "tenantConfiguration": { + "fields": [ + "tenantId", + "domainName", + "lastUpdated", + "updatedBy", + "onboardedBy", + "contactEmail", + "onboardedAt", + "homedAppClientId", + "lastAuthClientId", + "lastAuthClientIdSince", + "disabled", + "disabledReason", + "disabledUntil", + "customRateLimitRequestsPerMinute", + "customUserRateLimitRequestsPerMinute", + "planTier", + "trialExpiresUtc", + "trialStartedUtc", + "trialConsumed", + "trialGrantedBy", + "manufacturerWhitelist", + "modelWhitelist", + "validateAutopilotDevice", + "validateCorporateIdentifier", + "validateDeviceAssociation", + "validateCloudPcDevice", + "allowInsecureAgentRequests", + "dataRetentionDays", + "sessionTimeoutHours", + "sessionGraceHours", + "maxNdjsonPayloadSizeMB", + "enablePerformanceCollector", + "performanceCollectorIntervalSeconds", + "helloWaitTimeoutSeconds", + "maxAuthFailures", + "authFailureTimeoutMinutes", + "agentMaxLifetimeMinutes", + "absoluteMaxSessionHours", + "selfDestructOnComplete", + "keepLogFile", + "rebootOnComplete", + "rebootDelaySeconds", + "enableGeoLocation", + "ntpServer", + "enableTimezoneAutoSet", + "enableImeMatchLog", + "enableGatherRuleDebugLog", + "enableEspContinueAnywayObservation", + "logLevel", + "maxBatchSize", + "showEnrollmentSummary", + "enrollmentSummaryTimeoutSeconds", + "enrollmentSummaryBrandingImageUrl", + "enrollmentSummaryLaunchRetrySeconds", + "showScriptOutput", + "enableLocalAdminAnalyzer", + "enableSoftwareInventoryAnalyzer", + "enableIntegrityBypassAnalyzer", + "enableRealmJoinWatcher", + "keepAwakeDuringUserEsp", + "enableConsoleBypassDetection", + "localAdminAllowedAccountsJson", + "bootstrapTokenEnabled", + "unrestrictedModeEnabled", + "unrestrictedMode", + "entraAppRolesEnabled", + "diagnosticsLogPathsJson", + "diagnosticsBlobSasUrl", + "diagnosticsUploadMode", + "diagnosticsUploadDestination", + "sendTraceEvents", + "teamsWebhookUrl", + "teamsNotifyOnSuccess", + "teamsNotifyOnFailure", + "teamsNotifyOnStart", + "webhookProviderType", + "webhookUrl", + "webhookNotifyOnSuccess", + "webhookNotifyOnFailure", + "webhookNotifyOnHardwareRejection", + "webhookNotifyOnStart", + "webhookCustomHeadersJson", + "notificationChannelsJson", + "slaTargetSuccessRate", + "slaTargetMaxDurationMinutes", + "slaTargetAppInstallSuccessRate", + "slaNotifyOnSuccessRateBreach", + "slaSuccessRateNotifyThreshold", + "slaNotifyOnDurationBreach", + "slaNotifyOnAppInstallBreach", + "slaNotifyOnConsecutiveFailures", + "slaConsecutiveFailureThreshold" + ] + }, + "sessionSummary": { + "fields": [ + { + "name": "sessionId", + "optional": false + }, + { + "name": "tenantId", + "optional": false + }, + { + "name": "serialNumber", + "optional": false + }, + { + "name": "deviceName", + "optional": false + }, + { + "name": "manufacturer", + "optional": false + }, + { + "name": "model", + "optional": false + }, + { + "name": "startedAt", + "optional": false + }, + { + "name": "completedAt", + "optional": true + }, + { + "name": "currentPhase", + "optional": false + }, + { + "name": "currentPhaseDetail", + "optional": false + }, + { + "name": "status", + "optional": false + }, + { + "name": "failureReason", + "optional": false + }, + { + "name": "failureSource", + "optional": false + }, + { + "name": "reconcileReason", + "optional": false + }, + { + "name": "espSoftFailure", + "optional": false + }, + { + "name": "completionSource", + "optional": false + }, + { + "name": "adminMarkedAction", + "optional": true + }, + { + "name": "validatedBy", + "optional": false + }, + { + "name": "eventCount", + "optional": false + }, + { + "name": "durationSeconds", + "optional": true + }, + { + "name": "avgApiLatencyMs", + "optional": true + }, + { + "name": "apiRequestCount", + "optional": true + }, + { + "name": "connectionType", + "optional": true + }, + { + "name": "enrollmentType", + "optional": false + }, + { + "name": "diagnosticsBlobName", + "optional": false + }, + { + "name": "diagnosticsBlobDestination", + "optional": true + }, + { + "name": "lastEventAt", + "optional": true + }, + { + "name": "isPreProvisioned", + "optional": false + }, + { + "name": "resumedAt", + "optional": true + }, + { + "name": "stalledAt", + "optional": true + }, + { + "name": "isHybridJoin", + "optional": false + }, + { + "name": "isSelfDeployingProfile", + "optional": false + }, + { + "name": "isCloudPc", + "optional": false + }, + { + "name": "osName", + "optional": false + }, + { + "name": "osBuild", + "optional": false + }, + { + "name": "osDisplayVersion", + "optional": false + }, + { + "name": "osEdition", + "optional": false + }, + { + "name": "osLanguage", + "optional": false + }, + { + "name": "isUserDriven", + "optional": false + }, + { + "name": "agentVersion", + "optional": false + }, + { + "name": "imeAgentVersion", + "optional": false + }, + { + "name": "geoCountry", + "optional": false + }, + { + "name": "geoRegion", + "optional": false + }, + { + "name": "geoCity", + "optional": false + }, + { + "name": "geoLoc", + "optional": false + }, + { + "name": "platformScriptCount", + "optional": false + }, + { + "name": "remediationScriptCount", + "optional": false + }, + { + "name": "rebootCount", + "optional": false + }, + { + "name": "excessiveEventsAlerted", + "optional": false + }, + { + "name": "excessiveEventsAutoActioned", + "optional": false + }, + { + "name": "pendingActionsJson", + "optional": false + }, + { + "name": "pendingActionsQueuedAt", + "optional": true + }, + { + "name": "failureSnapshotJson", + "optional": false + }, + { + "name": "deletionState", + "optional": false + }, + { + "name": "pendingDeletionManifestId", + "optional": true + } + ] + }, + "sessionStatuses": [ + "InProgress", + "Pending", + "Stalled", + "Succeeded", + "Failed", + "Unknown", + "AwaitingUser", + "Incomplete" + ], + "enrollmentPhases": { + "Start": 0, + "DevicePreparation": 1, + "DeviceSetup": 2, + "AppsDevice": 3, + "AccountSetup": 4, + "AppsUser": 5, + "FinalizingSetup": 6, + "Complete": 7, + "Failed": 99, + "Unknown": -1 + }, + "eventSeverities": { + "Debug": 0, + "Info": 1, + "Warning": 2, + "Error": 3, + "Critical": 4, + "Trace": -1 + }, + "webhookProviderTypes": { + "None": 0, + "TeamsLegacyConnector": 1, + "TeamsWorkflowWebhook": 2, + "Slack": 10, + "GenericJson": 20 + }, + "annotationLanes": [ + "operator", + "tenantadmin", + "globaladmin" + ], + "annotationVerdicts": [ + "root_cause_confirmed", + "analysis_wrong", + "different_problem", + "inconclusive" + ], + "tenantRoles": [ + "Admin", + "Operator", + "Viewer" + ], + "globalRoles": [ + "GlobalAdmin", + "GlobalReader" + ], + "delegatedRoles": [ + "DelegatedReader", + "DelegatedAdmin" + ], + "analyzeRuleSources": [ + "app_install_duration", + "event_correlation", + "event_count", + "event_data", + "event_data_array", + "event_type", + "phase_duration" + ], + "analyzeRuleOperators": [ + "contains", + "count_gte", + "count_per_group_gte", + "equals", + "exists", + "gt", + "gte", + "in", + "lt", + "lte", + "not_contains", + "not_equals", + "not_exists", + "not_in", + "not_regex", + "regex" + ], + "eventTypes": [ + "aad_join_status", + "aad_placeholder_user_detected", + "aad_user_joined_observed", + "admin_marked_session", + "agent_emergency_break", + "agent_late_start", + "agent_metrics_collector_stopped", + "agent_metrics_snapshot", + "agent_shutdown", + "agent_shutting_down", + "agent_started", + "agent_trace", + "agent_unrestricted_mode_changed", + "agent_version_check", + "all_apps_completed", + "app_download_started", + "app_install_completed", + "app_install_failed", + "app_install_skipped", + "app_install_started", + "app_install_starved", + "app_tracking_summary", + "autologon_analysis", + "autopilot_profile", + "autopilot_profile_missing", + "bitlocker_status", + "boot_time", + "cert_validation", + "collector_degraded", + "completion_check", + "completion_waiting", + "configmgr_client_detected", + "console_prefetch_detected", + "decision_process_completion", + "desktop_arrived", + "desktop_detector_first_poll", + "desktop_detector_no_candidate", + "desktop_detector_started", + "device_location", + "diagnostics_collecting", + "diagnostics_upload_failed", + "diagnostics_uploaded", + "disk_space_low", + "dns_configuration", + "do_telemetry", + "download_progress", + "enrollment_complete", + "enrollment_failed", + "enrollment_summary_shown", + "enrollment_type_detected", + "enrollment_type_mismatch", + "error_detected", + "esp_apps_failure_correlation", + "esp_appx_failure_analysis", + "esp_config_detected", + "esp_exiting", + "esp_failure", + "esp_failure_advisory", + "esp_failure_advisory_resolved", + "esp_failure_recovered", + "esp_failure_retry_detected", + "esp_failure_settle_recovered", + "esp_failure_settle_started", + "esp_phase_changed", + "esp_policy_provider_stalled", + "esp_provisioning_raw", + "esp_provisioning_settle_started", + "esp_provisioning_status", + "esp_resumed", + "esp_state_change", + "esp_ui_state", + "gather_result", + "gather_rules_collection_completed", + "gather_rules_collection_started", + "hardware_spec", + "hello_completion_timeout", + "hello_pin_status", + "hello_policy_detected", + "hello_policy_detection_mismatch", + "hello_processing_started", + "hello_processing_stopped", + "hello_provisioning_blocked", + "hello_provisioning_completed", + "hello_provisioning_failed", + "hello_skipped", + "hello_wait_timeout", + "hello_wizard_started", + "historic_ime_replay_detected", + "hybrid_login_pending", + "ime_agent_version", + "ime_process_exited", + "ime_session_change", + "ime_user_session_completed", + "ingress_backpressure", + "integrity_bypass_analysis", + "keep_awake_engaged", + "keep_awake_released", + "local_admin_analysis", + "log_entry", + "mdm_policy_reboot_required", + "modern_deployment_error", + "modern_deployment_log", + "modern_deployment_warning", + "network_adapters", + "network_bandwidth_estimate", + "network_connectivity_check", + "network_interface_info", + "network_state_change", + "ntp_time_check", + "office_install_completed", + "office_install_failed", + "office_install_progress", + "office_install_started", + "office_preinstalled_detected", + "oobe_console_spawned", + "oobe_state_completed", + "os_build_changed", + "os_info", + "outbound_ip", + "performance_collector_stopped", + "performance_snapshot", + "phase_transition", + "power_state_check", + "previous_crash_detected", + "prior_run_died_with_state", + "provisioning_package_scan", + "proxy_configuration", + "realmjoin_detected", + "realmjoin_first_deployment_incomplete", + "realmjoin_package_completed", + "realmjoin_package_started", + "realmjoin_phase_changed", + "realmjoin_resolved", + "realmjoin_timeout", + "reboot_triggered", + "remote_config_fetch_failed", + "script_completed", + "script_failed", + "script_started", + "script_timeout_suspected", + "secureboot_status", + "security_audit", + "security_warning", + "server_action_executed", + "server_action_failed", + "server_action_received", + "session_parked_without_deadline", + "session_stalled", + "session_timeout", + "shadow_discrepancy", + "software_inventory_analysis", + "spool_pressure_detected", + "stall_probe_check", + "stall_probe_result", + "state_quarantine_recovered", + "system_reboot_detected", + "telemetry_upload_blocked", + "telemetry_upload_poisoned", + "timezone_auto_set", + "tpm_status", + "vulnerability_report", + "waiting_for_hello", + "whiteglove_classification", + "whiteglove_complete", + "whiteglove_part1_complete", + "whiteglove_resumed", + "whiteglove_started", + "wifi_signal_info", + "windows_update_channel_census", + "windows_update_failed", + "windows_update_history", + "windows_update_reboot_pending", + "windows_update_started", + "windows_update_succeeded" + ] +} diff --git a/src/Web/autopilot-monitor-web/utils/sharedManifestChecks.ts b/src/Web/autopilot-monitor-web/utils/sharedManifestChecks.ts new file mode 100644 index 00000000..c367a0ce --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/sharedManifestChecks.ts @@ -0,0 +1,28 @@ +/** + * COMPILE-TIME drift checks between hand-written TS mirrors and the C#-generated + * shared manifest (utils/shared-manifests.json → shared-manifests.generated.ts). + * + * A red squiggle / failing `tsc --noEmit` here means one side changed without the + * other: either add the field to the TS mirror, or regenerate the manifest + * (AM_WRITE_SHARED_MANIFESTS=1 dotnet test --filter SharedManifestParityTests, + * then node scripts/generate-shared-manifest-types.js). + * + * Runtime (value-level) parity lives in utils/__tests__/sharedManifestParity.test.ts. + */ +import type { AdminConfiguration } from "@/types/adminConfig"; +import { SHARED_MANIFEST } from "./shared-manifests.generated"; + +/** Resolves only when T is empty — a non-empty union here IS the drift report. */ +type AssertNever = T; + +type AdminWireKey = (typeof SHARED_MANIFEST)["adminConfiguration"]["fields"][number]; + +/** C# fields the TS interface is missing. Must stay `never`. */ +export type AdminConfigFieldsMissingInTs = AssertNever< + Exclude +>; + +/** TS fields the C# model no longer has (ghost fields). Must stay `never`. */ +export type AdminConfigGhostFieldsInTs = AssertNever< + Exclude +>; diff --git a/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json b/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json index fda41e44..606a5c97 100644 --- a/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json +++ b/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json @@ -5,4 +5,8 @@ # Reducer: HandleRealmJoinTimeoutDeadlineFired → sets RealmJoinFacts.Outcome="Timeout", # emits a realmjoin_timeout timeline event, and (if Hello+Desktop already resolved OR # SelfDeployingDeferredCompletion is set) routes through CompleteIfDeferredOrBookkeep. -{"SessionSignalOrdinal":99,"SessionTraceOrdinal":99,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T11:00:00.0000000Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Derived","Identifier":"deadline-realmjoin-timeout","Summary":"RealmJoin hard timeout fired"},"Payload":{"deadline":"realmjoin_timeout"}} +# Evidence is Synthetic — production DeadlineFired posts come from +# EnrollmentOrchestrator.OnDeadlineFired with EvidenceKind.Synthetic (Derived evidence would +# require DerivationInputs and fail SignalSerializer.Deserialize; caught by +# SignalKindFixtureCoverageTests 2026-08-13 — this fixture previously could not replay at all). +{"SessionSignalOrdinal":99,"SessionTraceOrdinal":99,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T11:00:00.0000000Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"realmjoin_timeout","Summary":"RealmJoin hard timeout fired"},"Payload":{"deadline":"realmjoin_timeout"}}