diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/UserSidResolverTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/UserSidResolverTests.cs new file mode 100644 index 00000000..aea0ecd0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Runtime/UserSidResolverTests.cs @@ -0,0 +1,41 @@ +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime +{ + public sealed class UserSidResolverTests + { + [Fact] + public void Resolves_well_known_builtin_administrators_to_canonical_sid() + { + // BUILTIN\Administrators is a well-known local group with a deterministic SID on + // every Windows installation. Using it as the round-trip oracle avoids any + // environment-specific user-database dependency. + Assert.True(UserSidResolver.TryResolveSid("BUILTIN\\Administrators", out var sid)); + Assert.Equal("S-1-5-32-544", sid); + } + + [Fact] + public void Resolves_bare_user_form_for_well_known_account() + { + // SYSTEM ("NT AUTHORITY\SYSTEM") is well-known with SID S-1-5-18. The resolver + // must accept the qualified form on every Windows installation. + Assert.True(UserSidResolver.TryResolveSid("NT AUTHORITY\\SYSTEM", out var sid)); + Assert.Equal("S-1-5-18", sid); + } + + [Fact] + public void Returns_false_for_obviously_invalid_account() + { + Assert.False(UserSidResolver.TryResolveSid("CONTOSO\\definitely-not-a-real-user-7f3e2c9a", out var sid)); + Assert.Null(sid); + } + + [Fact] + public void Returns_false_for_empty_input() + { + Assert.False(UserSidResolver.TryResolveSid(string.Empty, out var sid)); + Assert.Null(sid); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs new file mode 100644 index 00000000..2c23887f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs @@ -0,0 +1,190 @@ +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class RealmJoinWatcherAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 5, 21, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public RealmJoinWatcher Watcher { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Watcher = new RealmJoinWatcher(Logger); + } + + public void Dispose() + { + Watcher.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void TriggerDetectedFromTest_emits_RealmJoinDetected_signal_and_informational_event() + { + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerDetectedFromTest(phase: 100); + + // 1) Decision signal that mutates engine state. + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinDetected); + Assert.Equal("RealmJoinWatcher", decision.SourceOrigin); + Assert.Equal("100", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]); + + // 2) Dual-emit informational event so the timeline shows realmjoin_detected. + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinDetected); + Assert.Equal("RealmJoinWatcher", info.Payload![SignalPayloadKeys.Source]); + Assert.Equal("true", info.Payload[SignalPayloadKeys.ImmediateUpload]); + Assert.Equal("100", info.Payload["deploymentPhase"]); + Assert.Contains("HKLM", info.Payload["registryKey"]); + } + + [Fact] + public void TriggerResolvedFromTest_emits_RealmJoinResolved_signal_and_informational_event() + { + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerResolvedFromTest(phase: 110); + + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinResolved); + Assert.Equal("110", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase]); + + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinResolved); + Assert.Equal("RealmJoinWatcher", info.Payload![SignalPayloadKeys.Source]); + Assert.Equal("110", info.Payload["deploymentPhase"]); + } + + [Fact] + public void TriggerPhaseChangedFromTest_emits_informational_only_no_decision_signal() + { + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerPhaseChangedFromTest(prev: 100, curr: 200); + + // Phase change is observability-only. There is no DecisionSignalKind.RealmJoinPhaseChanged. + Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinDetected); + Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinResolved); + + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinPhaseChanged); + Assert.Equal("200", info.Payload!["deploymentPhase"]); + Assert.Equal("100", info.Payload["previousPhase"]); + } + + [Fact] + public void TriggerPackageStartedFromTest_emits_signal_and_event_with_packageId_scope() + { + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + var snap = new RealmJoinPackageSnapshot( + packageId: "generic-vlc", + displayName: "VLC media player", + version: "3.0.21.0", + success: null, + lastExitCode: null); + adapter.TriggerPackageStartedFromTest(scope: RealmJoinPackageFact.ScopeMachine, snap: snap); + + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinPackageStarted); + Assert.Equal("generic-vlc", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.PackageId]); + Assert.Equal("VLC media player", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.DisplayName]); + Assert.Equal("3.0.21.0", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.Version]); + Assert.Equal("machine", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.Scope]); + + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinPackageStarted); + Assert.Equal("generic-vlc", info.Payload!["packageId"]); + Assert.Equal("machine", info.Payload["scope"]); + } + + [Fact] + public void TriggerPackageCompletedFromTest_emits_signal_and_event_with_success_and_exit_code() + { + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + var snap = new RealmJoinPackageSnapshot( + packageId: "generic-vlc", + displayName: "VLC media player", + version: "3.0.21.0", + success: true, + lastExitCode: 0); + adapter.TriggerPackageCompletedFromTest(scope: RealmJoinPackageFact.ScopeMachine, snap: snap); + + var decision = f.Ingress.Posted.Single(p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted); + Assert.Equal("true", decision.Payload![DecisionEngine.RealmJoinPayloadKeys.Success]); + Assert.Equal("0", decision.Payload[DecisionEngine.RealmJoinPayloadKeys.LastExitCode]); + + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinPackageCompleted); + Assert.Equal("true", info.Payload!["success"]); + Assert.Equal("0", info.Payload["lastExitCode"]); + } + + [Fact] + public void Overlong_display_name_is_truncated_to_256_characters() + { + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + var longName = new string('x', 1000); + var snap = new RealmJoinPackageSnapshot( + packageId: "generic-bigname", + displayName: longName, + version: null, + success: null, + lastExitCode: null); + adapter.TriggerPackageStartedFromTest(scope: RealmJoinPackageFact.ScopeUser, snap: snap); + + var info = f.Ingress.Posted.Single(p => + p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == SharedEventTypes.RealmJoinPackageStarted); + // Adapter must clamp DisplayName before posting (PII / payload-size guard mirror of + // RealmJoinPackageFact.MaxDisplayNameLength = 256). The packageId stays intact. + Assert.Equal(256, info.Payload!["displayName"].Length); + Assert.Equal("generic-bigname", info.Payload["packageId"]); + Assert.Equal("user", info.Payload["scope"]); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs new file mode 100644 index 00000000..7433f8d4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Reducer integration tests for the RealmJoin (RJ) completion-gate. The Classic and + /// SelfDeploying completion paths must defer terminal transitions while RJ is detected + /// and unresolved, and release them once + /// arrives or the deadline fires. + /// + public sealed class RealmJoinGateTests + { + private static readonly DateTime T0 = new DateTime(2026, 5, 21, 10, 0, 0, DateTimeKind.Utc); + + private static DecisionSignal MakeSignal( + long ordinal, + DecisionSignalKind kind, + DateTime occurredAtUtc, + IReadOnlyDictionary? payload = null) + { + return new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: 1, + occurredAtUtc: occurredAtUtc, + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"), + payload: payload); + } + + private static DecisionState PrimeClassicAwaitingDesktop(DecisionEngine engine) + { + var state = DecisionState.CreateInitial("rj-sess", "rj-tenant", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspExiting, T0.AddMinutes(3))).NewState; + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.HelloResolved, T0.AddMinutes(4), + new Dictionary { [SignalPayloadKeys.HelloOutcome] = "Success" })).NewState; + Assert.Equal(SessionStage.AwaitingDesktop, state.Stage); + return state; + } + + // ============================================================== Classic flow + + [Fact] + public void Baseline_without_realmjoin_detected_completes_normally() + { + // Regression guard — on devices where RJ is not installed (DetectedUtc stays null), + // the gate is open and the Hello+Desktop AND-gate must reach Finalizing exactly as + // before Phase A. + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + Assert.Null(state.RealmJoinFacts.DetectedUtc); + + var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.DesktopArrived, T0.AddMinutes(5))); + + Assert.Equal(SessionStage.Finalizing, step.NewState.Stage); + Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace); + } + + [Fact] + public void RealmJoinDetected_arms_the_60_min_hard_timeout_deadline() + { + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + + var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })); + + Assert.NotNull(step.NewState.RealmJoinFacts.DetectedUtc); + Assert.Equal(100, step.NewState.RealmJoinFacts.LastDeploymentPhase!.Value); + + var deadline = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout); + // 60-min hard timeout from the post-PrimeClassic AgentBoot anchor (T0) — within rounding. + Assert.InRange(deadline.DueAtUtc, T0.AddMinutes(55), T0.AddMinutes(70)); + + // ScheduleDeadline effect emitted exactly once. + Assert.Single(step.Effects, e => + e.Kind == DecisionEffectKind.ScheduleDeadline && + e.Deadline != null && + e.Deadline.Name == DeadlineNames.RealmJoinTimeout); + } + + [Fact] + public void RealmJoinGate_closed_defers_finalizing_when_DesktopArrived_arrives_with_hello_already_resolved() + { + // Sequence: Hello + RealmJoinDetected before Desktop, then DesktopArrived. The AND-gate + // would normally TransitionToFinalizing — but the closed gate must keep the session + // out of Finalizing until RJ resolves. + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + + var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))); + + Assert.NotEqual(SessionStage.Finalizing, step.NewState.Stage); + Assert.Null(step.NewState.Outcome); + Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace); + Assert.NotNull(step.NewState.DesktopArrivedUtc); // fact still recorded + } + + [Fact] + public void RealmJoinResolved_after_hello_and_desktop_triggers_finalizing_via_classic_path() + { + // Same setup as the previous test, then RealmJoinResolved (phase 110) lands. The + // resolved handler routes through CompleteIfDeferredOrBookkeep → TransitionToFinalizing. + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState; + Assert.NotEqual(SessionStage.Finalizing, state.Stage); + + var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(7), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" })); + + Assert.Equal(SessionStage.Finalizing, step.NewState.Stage); + Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace); + // RealmJoinTimeout deadline got cancelled both in state and as a scheduler effect. + Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout); + Assert.Contains(step.Effects, e => + e.Kind == DecisionEffectKind.CancelDeadline && + e.CancelDeadlineName == DeadlineNames.RealmJoinTimeout); + Assert.Equal("Resolved", step.NewState.RealmJoinFacts.Outcome!.Value); + } + + [Fact] + public void RealmJoinTimeout_with_hello_and_desktop_in_completes_with_timeout_outcome() + { + // Hard 60-min timeout fires while RJ is still incomplete but Hello+Desktop already + // landed. Handler routes through CompleteIfDeferredOrBookkeep → TransitionToFinalizing + // and records realmjoinOutcome="Timeout". + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState; + + var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DeadlineFired, T0.AddMinutes(65), + new Dictionary { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout })); + + Assert.Equal(SessionStage.Finalizing, step.NewState.Stage); + Assert.Equal("Timeout", step.NewState.RealmJoinFacts.Outcome!.Value); + + // The reducer emitted a realmjoin_timeout timeline entry alongside the + // Finalizing-transition effects. + Assert.Contains(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry && + e.Parameters != null && + e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout"); + } + + // ============================================================== SelfDeploying flow + + [Fact] + public void SelfDeploying_DeviceSetupProvisioningComplete_with_realmjoin_open_completes_immediately() + { + // Baseline — RJ never detected → SelfDeploying terminal path completes directly. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("rj-sd", "rj-tenant", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + + var step = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(2))); + + Assert.Equal(SessionStage.Completed, step.NewState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome); + } + + [Fact] + public void SelfDeploying_with_realmjoin_detected_defers_terminal_until_resolved() + { + // SelfDeploying terminal handler observes the gate is closed → marks + // SelfDeployingDeferredCompletion on RealmJoinFacts, stays non-terminal, lets the + // RealmJoinResolved handler complete directly to Completed (not via Finalizing). + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("rj-sd-2", "rj-tenant", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + + var deferred = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + Assert.NotEqual(SessionStage.Completed, deferred.Stage); + Assert.True(deferred.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value); + + var step = engine.Reduce(deferred, MakeSignal(4, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(4), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" })); + + Assert.Equal(SessionStage.Completed, step.NewState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome); + // Direct Completed path emits enrollment_complete inline; no FinalizingGrace deadline + // needed because the SelfDeploying handler clears deadlines. + Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace); + Assert.Contains(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry && + e.Parameters != null && + e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete"); + } + + // ============================================================== Per-package tracking + + [Fact] + public void Per_package_started_and_completed_signals_update_RealmJoinFacts_packages() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("rj-pkg", "rj-tenant", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(1), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinPackageStarted, T0.AddMinutes(2), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.PackageId] = "generic-vlc", + [DecisionEngine.RealmJoinPayloadKeys.DisplayName] = "VLC media player", + [DecisionEngine.RealmJoinPayloadKeys.Version] = "3.0.21.0", + [DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine, + })).NewState; + Assert.Single(state.RealmJoinFacts.Packages, p => p.PackageId == "generic-vlc" && p.CompletedUtc == null); + + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.RealmJoinPackageCompleted, T0.AddMinutes(3), + new Dictionary + { + [DecisionEngine.RealmJoinPayloadKeys.PackageId] = "generic-vlc", + [DecisionEngine.RealmJoinPayloadKeys.DisplayName] = "VLC media player", + [DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine, + [DecisionEngine.RealmJoinPayloadKeys.Success] = "true", + [DecisionEngine.RealmJoinPayloadKeys.LastExitCode] = "0", + })).NewState; + var completed = Assert.Single(state.RealmJoinFacts.Packages, p => p.PackageId == "generic-vlc"); + Assert.True(completed.Success); + Assert.Equal(0, completed.LastExitCode); + Assert.NotNull(completed.CompletedUtc); + } + + // ============================================================== Audit trail + + [Fact] + public void Audit_trail_attaches_realmjoin_fields_when_enrollment_completes_after_resolved() + { + // After SelfDeploying + RealmJoinResolved, the enrollment_complete effect must carry + // the realmjoin* audit-trail fields built by DecisionAuditTrailBuilder. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("rj-audit", "rj-tenant", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(3))).NewState; + + var step = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(4), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" })); + + var completeEffect = Assert.Single(step.Effects, e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry && + e.Parameters != null && + e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete"); + + Assert.NotNull(completeEffect.TypedPayload); + var data = Assert.IsType>(completeEffect.TypedPayload); + Assert.Equal("Resolved", data["realmjoinOutcome"]); + Assert.Equal(110, data["realmjoinLastPhase"]); + Assert.True(data.ContainsKey("realmjoinDetectedUtc")); + Assert.True(data.ContainsKey("realmjoinResolvedUtc")); + } + } +} diff --git a/tests/fixtures/signal-kinds/realmjoin-detected-v1.json b/tests/fixtures/signal-kinds/realmjoin-detected-v1.json new file mode 100644 index 00000000..d406fabd --- /dev/null +++ b/tests/fixtures/signal-kinds/realmjoin-detected-v1.json @@ -0,0 +1,8 @@ +# RealmJoinDetected v1 — RealmJoin deployment tracking. +# Posted by RealmJoinWatcherAdapter when HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\ +# Parameters is first observed. Payload carries the current DeploymentPhase DWORD. +# Reducer: HandleRealmJoinDetectedV1 → records RealmJoinFacts.DetectedUtc + LastDeploymentPhase, +# arms the 60-min RealmJoinTimeout deadline. The V2 completion AND-gate now reads +# RealmJoinFacts.DetectedUtc != null and defers Finalizing until RealmJoinResolved / +# RealmJoinTimeout fires. +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"RealmJoinDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:00:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-detected-v1","Summary":"RealmJoin Parameters key observed (phase=100)","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"deploymentPhase":"100"}} diff --git a/tests/fixtures/signal-kinds/realmjoin-package-completed-v1.json b/tests/fixtures/signal-kinds/realmjoin-package-completed-v1.json new file mode 100644 index 00000000..322692e5 --- /dev/null +++ b/tests/fixtures/signal-kinds/realmjoin-package-completed-v1.json @@ -0,0 +1,7 @@ +# RealmJoinPackageCompleted v1 — terminal per-package event. +# Posted by RealmJoinWatcherAdapter when the matching sub-key gains both Success and +# LastExitCode values. Payload extends the started shape with success ("true"|"false") + +# lastExitCode (decimal string of the DWORD). +# Reducer: HandleRealmJoinPackageCompletedV1 → updates the matching RealmJoinPackageFact +# row with CompletedUtc / Success / LastExitCode. Stage unchanged. +{"SessionSignalOrdinal":12,"SessionTraceOrdinal":12,"Kind":"RealmJoinPackageCompleted","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:08:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-package-completed:machine:generic-vlc","Summary":"RealmJoin package install completed (scope=machine, id=generic-vlc, success=True, exitCode=0)","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"packageId":"generic-vlc","displayName":"VLC media player","version":"3.0.21.0","scope":"machine","success":"true","lastExitCode":"0"}} diff --git a/tests/fixtures/signal-kinds/realmjoin-package-started-v1.json b/tests/fixtures/signal-kinds/realmjoin-package-started-v1.json new file mode 100644 index 00000000..4fb96ee6 --- /dev/null +++ b/tests/fixtures/signal-kinds/realmjoin-package-started-v1.json @@ -0,0 +1,9 @@ +# RealmJoinPackageStarted v1 — observation-only per-package event. +# Posted by RealmJoinWatcherAdapter when a HKLM\SOFTWARE\RealmJoin\Packages\ sub-key +# (or its HKU\\... counterpart) is first observed with a DisplayName value. +# Payload: packageId (mandatory), displayName (truncated to 256 chars), optional version, +# scope ("machine"|"user"). +# Reducer: HandleRealmJoinPackageStartedV1 → appends a RealmJoinPackageFact row. Stage +# unchanged. Dual emission as InformationalEvent realmjoin_package_started handles UI +# timeline visibility. +{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"RealmJoinPackageStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:05:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-package-started:machine:generic-vlc","Summary":"RealmJoin package install started (scope=machine, id=generic-vlc)","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"packageId":"generic-vlc","displayName":"VLC media player","version":"3.0.21.0","scope":"machine"}} diff --git a/tests/fixtures/signal-kinds/realmjoin-resolved-v1.json b/tests/fixtures/signal-kinds/realmjoin-resolved-v1.json new file mode 100644 index 00000000..f6a4896f --- /dev/null +++ b/tests/fixtures/signal-kinds/realmjoin-resolved-v1.json @@ -0,0 +1,8 @@ +# RealmJoinResolved v1 — RealmJoin first deployment completed. +# Posted by RealmJoinWatcherAdapter when DeploymentPhase first reaches +# CompletedFirstDeployment (110). Payload carries the observed phase. +# Reducer: HandleRealmJoinResolvedV1 → records RealmJoinFacts.ResolvedUtc/LastPhase/Outcome, +# cancels the RealmJoinTimeout deadline (scheduler + reducer view), and (if Hello + Desktop +# already resolved OR SelfDeployingDeferredCompletion is set) routes through +# CompleteIfDeferredOrBookkeep to TransitionToFinalizing / direct Completed. +{"SessionSignalOrdinal":42,"SessionTraceOrdinal":42,"Kind":"RealmJoinResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-21T10:25:00.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-resolved-v1","Summary":"RealmJoin reached phase 110","DerivationInputs":{"registryKey":"HKLM\\SYSTEM\\CurrentControlSet\\Services\\realmjoin\\Parameters"}},"Payload":{"deploymentPhase":"110"}} diff --git a/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json b/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json new file mode 100644 index 00000000..fda41e44 --- /dev/null +++ b/tests/fixtures/signal-kinds/realmjoin-timeout-v1.json @@ -0,0 +1,8 @@ +# RealmJoin hard-timeout (60-min) deadline fire. +# This kind is NOT posted by any adapter — it is dispatched by the DeadlineFiredV1 switch +# (DecisionEngine.Shared.cs) when the RealmJoinTimeout ActiveDeadline armed by +# HandleRealmJoinDetectedV1 elapses without DeploymentPhase reaching 110. +# 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"}}