mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
test(decisioncore,agent/v2): RealmJoin gate, adapter, SID resolver tests + signal-kind fixtures (Phase D)
- DecisionCore.Tests/RealmJoinGateTests.cs (9 tests): baseline regression guard,
RealmJoinDetected arms 60-min timeout, gate defers Finalizing on Classic AND-gate,
RealmJoinResolved + Timeout both release through CompleteIfDeferredOrBookkeep,
SelfDeploying terminal defers + direct-complete via SelfDeployingDeferredCompletion,
per-package facts mutation, and audit-trail attaches realmjoin* fields.
- V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs (6 tests): each of the
5 watcher events maps to the correct DecisionSignalKind + dual InformationalEvent;
phase-changed is timeline-only; DisplayName clamped to 256 chars.
- V2.Core.Tests/Monitoring/Runtime/UserSidResolverTests.cs (4 tests): well-known
BUILTIN\Administrators + NT AUTHORITY\SYSTEM SID round-trips, invalid/empty inputs
return false.
- tests/fixtures/signal-kinds/realmjoin-{detected,resolved,timeout,package_started,
package_completed}-v1.json: replay fixtures fulfilling the merge-block contract
declared on DecisionSignalKind.cs:8.
Verified: 9/9 reducer + 10/10 adapter/SID tests green; no regressions in adjacent
suites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
@@ -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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="DecisionSignalKind.RealmJoinResolved"/>
|
||||
/// arrives or the <see cref="DeadlineNames.RealmJoinTimeout"/> deadline fires.
|
||||
/// </summary>
|
||||
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<string, string>? 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<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [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<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [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<string, string> { [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<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState;
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinPackageStarted, T0.AddMinutes(2),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [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<string, string> { [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<Dictionary<string, object>>(completeEffect.TypedPayload);
|
||||
Assert.Equal("Resolved", data["realmjoinOutcome"]);
|
||||
Assert.Equal(110, data["realmjoinLastPhase"]);
|
||||
Assert.True(data.ContainsKey("realmjoinDetectedUtc"));
|
||||
Assert.True(data.ContainsKey("realmjoinResolvedUtc"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}}
|
||||
@@ -0,0 +1,7 @@
|
||||
# RealmJoinPackageCompleted v1 — terminal per-package event.
|
||||
# Posted by RealmJoinWatcherAdapter when the matching <id> 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"}}
|
||||
@@ -0,0 +1,9 @@
|
||||
# RealmJoinPackageStarted v1 — observation-only per-package event.
|
||||
# Posted by RealmJoinWatcherAdapter when a HKLM\SOFTWARE\RealmJoin\Packages\<id> sub-key
|
||||
# (or its HKU\<sid>\... 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"}}
|
||||
@@ -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"}}
|
||||
@@ -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"}}
|
||||
Reference in New Issue
Block a user