diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs index 1185ecc3..b1d0ed6c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerCompletenessTests.cs @@ -50,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration nameof(AgentConfigResponse.MaxBatchSize), nameof(AgentConfigResponse.MaxAuthFailures), nameof(AgentConfigResponse.AuthFailureTimeoutMinutes), + nameof(AgentConfigResponse.InterceptedRegistrationRetryBoots), // Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds); // the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory. nameof(AgentConfigResponse.Collectors), diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs index 8fee05d6..5aae2acf 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs @@ -55,6 +55,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration UnrestrictedMode = true, MaxAuthFailures = 7, AuthFailureTimeoutMinutes = 15, + InterceptedRegistrationRetryBoots = 2, EnableImeMatchLog = true, Collectors = new CollectorConfiguration { @@ -98,6 +99,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration Assert.Equal(45, agent.HelloWaitTimeoutSeconds); Assert.Equal(7, agent.MaxAuthFailures); Assert.Equal(15, agent.AuthFailureTimeoutMinutes); + Assert.Equal(2, agent.InterceptedRegistrationRetryBoots); Assert.NotNull(result); Assert.True(result.UnrestrictedModeChanged); @@ -468,6 +470,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration Assert.Equal(0, agent.AgentMaxLifetimeMinutes); } + // ============================================================= Intercepted-registration buffer + + [Theory] + [InlineData(0)] // clean up at the first intercepted registration + [InlineData(3)] + public void Merge_copies_intercepted_registration_retry_boots(int remoteValue) + { + var agent = NewAgentConfig(); + var remote = FullRemote(); + remote.InterceptedRegistrationRetryBoots = remoteValue; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Equal(remoteValue, agent.InterceptedRegistrationRetryBoots); + } + + [Fact] + public void Merge_keeps_the_agent_default_when_the_backend_sends_no_intercepted_registration_retry_boots() + { + var agent = NewAgentConfig(); + var remote = FullRemote(); + remote.InterceptedRegistrationRetryBoots = null; // backend that predates the field + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Equal(1, agent.InterceptedRegistrationRetryBoots); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(int.MinValue, 0)] + [InlineData(6, 5)] + [InlineData(int.MaxValue, 5)] + public void Merge_clamps_intercepted_registration_retry_boots_to_zero_through_five(int remoteValue, int expected) + { + var agent = NewAgentConfig(); + var remote = FullRemote(); + remote.InterceptedRegistrationRetryBoots = remoteValue; + + RemoteConfigMerger.Merge(agent, remote); + + Assert.Equal(expected, agent.InterceptedRegistrationRetryBoots); + } + // ============================================================= Guard [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs index d2f08227..9b043874 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs @@ -214,6 +214,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration Assert.Null(loaded.LatestAgentSha256); } + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(null)] // backend that predates the field — the agent keeps its default + public void RoundTrip_preserves_intercepted_registration_retry_boots(int? value) + { + // An intercepted start gets no live config, so the registration buffer it decides on + // is the cached value: it must survive the write + read strip untouched. + var probe = NewProbe(); + + probe.DoCache(new AgentConfigResponse { InterceptedRegistrationRetryBoots = value }); + var loaded = probe.DoLoad(); + + Assert.NotNull(loaded); + Assert.Equal(value, loaded.InterceptedRegistrationRetryBoots); + } + // ── Helpers ───────────────────────────────────────────────────────── private CacheProbe NewProbe() diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceRetryTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceRetryTests.cs index 7ffc6894..74fd4c11 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceRetryTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceRetryTests.cs @@ -7,6 +7,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; using AutopilotMonitor.Shared.Models; using Xunit; @@ -101,6 +102,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration Assert.Equal(401, svc.LastFetchAuthStatusCode); } + [Fact] + public async Task FetchConfig_InterceptedAnswer_FallsBack_WithoutCounting_AndReportsOnce() + { + // A 403 something on the network path answered (TLS inspection): same fallback as an + // auth failure, but the tracker does not count it — it sends ClientCertificateNotReceived. + var distress = new FakeDistressReporter(); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, + AutopilotMonitor.DecisionCore.Engine.SystemClock.Instance, _logger, distress); + var svc = NewService(BackendBehaviour.AlwaysIntercepted403, tracker); + + var result = await svc.FetchConfigAsync(retryOnTransientErrors: true); + + Assert.NotNull(result); + Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome); + Assert.Equal(1, svc.LastFetchAttempts); + Assert.Equal(403, svc.LastFetchAuthStatusCode); + Assert.Equal(0, tracker.ConsecutiveFailures); + var call = Assert.Single(distress.Calls); + Assert.Equal(DistressErrorType.ClientCertificateNotReceived, call.ErrorType); + Assert.StartsWith("agent/config http 403 client_certificate_required", call.Message); + } + [Fact] public async Task FetchConfig_RetryDisabled_OneAttemptOnly() { @@ -252,10 +275,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration /// All 4 initial attempts fail; the NEXT call (the recovery fetch) succeeds. FailAllThenSucceed, AlwaysAuthFailure401, + AlwaysIntercepted403, AlwaysReturnNull, } - private FakeRemoteConfigService NewService(BackendBehaviour behaviour) + private FakeRemoteConfigService NewService(BackendBehaviour behaviour, AuthFailureTracker? tracker = null) { // Pass a throwaway BackendApiClient — the fake overrides CallBackendAsync // before it ever hits the wire. @@ -269,7 +293,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration bootstrapToken: null, agentVersion: "0.0.0", logger: _logger); - return new FakeRemoteConfigService(apiClient, _logger, behaviour); + return new FakeRemoteConfigService(apiClient, _logger, behaviour, tracker); } private sealed class FakeRemoteConfigService : RemoteConfigService @@ -277,8 +301,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration private readonly BackendBehaviour _behaviour; private int _calls; - public FakeRemoteConfigService(BackendApiClient apiClient, AgentLogger logger, BackendBehaviour behaviour) - : base(apiClient, "00000000-0000-0000-0000-000000000001", logger) + public FakeRemoteConfigService(BackendApiClient apiClient, AgentLogger logger, BackendBehaviour behaviour, AuthFailureTracker? tracker) + : base(apiClient, "00000000-0000-0000-0000-000000000001", logger, authFailureTracker: tracker) { _behaviour = behaviour; } @@ -305,6 +329,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration throw new HttpRequestException("simulated network failure"); case BackendBehaviour.AlwaysAuthFailure401: throw new BackendAuthException("Unauthorized", 401); + case BackendBehaviour.AlwaysIntercepted403: + throw new BackendAuthException("The device certificate did not reach the backend.", 403, + classification: InterceptionFixtures.CertificateRequired()); case BackendBehaviour.AlwaysReturnNull: return Task.FromResult(null!); default: diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/FakeDistressReporter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/FakeDistressReporter.cs new file mode 100644 index 00000000..2e7e9642 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/FakeDistressReporter.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness +{ + /// + /// Records every distress dispatch instead of sending it. The real anti-flood rules of + /// are bypassed on purpose: tests assert what the caller dispatched. + /// + internal sealed class FakeDistressReporter : DistressReporter + { + public sealed class Call + { + public DistressErrorType ErrorType { get; set; } + public string Message { get; set; } = default!; + public int? HttpStatusCode { get; set; } + } + + public List Calls { get; } = new List(); + + /// + /// The send each dispatch returns; null = already completed. Lets a test keep a send in + /// flight to prove a caller waits for it (or stops waiting at its bound). + /// + public Func? Send { get; set; } + + public FakeDistressReporter() + : base(baseUrl: "https://example.test", tenantId: "t", manufacturer: "m", model: "d", serialNumber: "s", agentVersion: "0.0.0", logger: null!) + { + } + + public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null) + { + Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode }); + return Send?.Invoke() ?? Task.CompletedTask; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/NetworkFingerprintTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/NetworkFingerprintTests.cs new file mode 100644 index 00000000..fb8a83e2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/NetworkFingerprintTests.cs @@ -0,0 +1,85 @@ +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Monitoring; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring +{ + /// + /// The local network fingerprint a failed registration is stored with: SHA-256 over the + /// connection type and gateway captures, cut to 16 hex + /// characters; unknown (null) whenever either input is missing. Gateways are documentation + /// addresses (RFC 5737). + /// + public sealed class NetworkFingerprintTests + { + private static NetworkChangeDetector.NetworkStateSnapshot State(string? connectionType, string? gateway, bool hasNetwork = true) => + new NetworkChangeDetector.NetworkStateSnapshot + { + HasNetwork = hasNetwork, + ConnectionType = connectionType, + Gateway = gateway, + }; + + [Fact] + public void Same_connection_type_and_gateway_give_the_same_short_hex_fingerprint() + { + var first = NetworkFingerprint.Of(State("WiFi", "192.0.2.1")); + var second = NetworkFingerprint.Of(State("WiFi", "192.0.2.1")); + + Assert.Equal(first, second); + Assert.Equal(NetworkFingerprint.Length, first!.Length); + Assert.True(first.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')), first); + } + + [Theory] + [InlineData("WiFi", "198.51.100.1")] // another gateway + [InlineData("Ethernet", "192.0.2.1")] // another connection type + public void Another_gateway_or_connection_type_is_another_network(string connectionType, string gateway) + { + Assert.NotEqual( + NetworkFingerprint.Of(State("WiFi", "192.0.2.1")), + NetworkFingerprint.Of(State(connectionType, gateway))); + } + + [Fact] + public void Unknown_network_has_no_fingerprint() + { + Assert.Null(NetworkFingerprint.Of(null)); + Assert.Null(NetworkFingerprint.Of(State("WiFi", "192.0.2.1", hasNetwork: false))); + Assert.Null(NetworkFingerprint.Of(State("WiFi", null))); + Assert.Null(NetworkFingerprint.Of(State(null, "192.0.2.1"))); + } + + [Theory] + [InlineData("aaaa", "aaaa", false)] + [InlineData("aaaa", "bbbb", true)] + public void Differs_compares_known_fingerprints(string before, string after, bool expected) + { + Assert.Equal(expected, NetworkFingerprint.Differs(before, after)); + } + + [Theory] + [InlineData(null, "aaaa")] + [InlineData("aaaa", null)] + [InlineData(null, null)] + public void Differs_is_unknown_when_either_side_is(string? before, string? after) + { + Assert.Null(NetworkFingerprint.Differs(before, after)); + } + + [Fact] + public void TryCapture_reads_this_machine_without_throwing() + { + // Machine-dependent: either no active interface (null) or a well-formed fingerprint + // that matches the detector's own capture. + var captured = NetworkFingerprint.TryCapture(); + + if (captured != null) + { + Assert.Equal(NetworkFingerprint.Length, captured.Length); + Assert.Equal(NetworkFingerprint.Of(NetworkChangeDetector.CaptureInterfaceState()), captured); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorInterceptionTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorInterceptionTests.cs new file mode 100644 index 00000000..39c3e827 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorInterceptionTests.cs @@ -0,0 +1,132 @@ +#nullable enable +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// The orchestrator side of an intercepted upload: one telemetry_upload_blocked with the + /// evidence (queued behind the intercepted batch, never uploaded while paused), and the one + /// terminal attempt — requested by DrainSpool or made by . + /// + [Collection("SerialThreading")] + public sealed class EnrollmentOrchestratorInterceptionTests + { + private const int WaitMs = 10_000; + + private static TelemetryItemDraft Draft() => + new TelemetryItemDraft( + kind: TelemetryItemKind.Event, + partitionKey: "T1_S1", + rowKey: "0000000001", + payloadJson: "{}", + isSessionScoped: true, + requiresImmediateFlush: false); + + // The signal and the decision transition of the same post mention the event type too. + private static bool IsBlockedEvent(TelemetryItem item) => + item.Kind == TelemetryItemKind.Event + && item.PayloadJson.Contains(AutopilotMonitor.Shared.Constants.EventTypes.TelemetryUploadBlocked); + + private static TelemetryItem? FindBlockedEvent(EnrollmentOrchestrator sut) => + sut.SpoolForTests!.Peek(1000).FirstOrDefault(IsBlockedEvent); + + // Dates stay strings: the wire format of firstBlockedAtUtc is what is under test. + private static JObject Parse(string json) + { + using var reader = new JsonTextReader(new StringReader(json)) { DateParseHandling = DateParseHandling.None }; + return JObject.Load(reader); + } + + /// Starts the orchestrator and drives the first upload into an interception. + private static async Task StartInterceptedAsync(EnrollmentOrchestratorRig rig) + { + rig.Uploader.QueueIntercepted(5); + var sut = rig.Build(); + sut.Start(); + sut.SpoolForTests!.Enqueue(Draft()); + await sut.DrainAsync(); + Assert.True(sut.Transport.IsInterceptionPaused); + return sut; + } + + [Fact] + public async Task Interception_queues_one_telemetry_upload_blocked_with_the_evidence_and_wakeups_stay_offline() + { + using var rig = new EnrollmentOrchestratorRig(); + var sut = await StartInterceptedAsync(rig); + try + { + TelemetryItem? blocked = null; + Assert.True(SpinWait.SpinUntil(() => (blocked = FindBlockedEvent(sut)) != null, WaitMs), + "telemetry_upload_blocked never reached the spool."); + var calls = rig.Uploader.CallCount; + + var data = (JObject)Parse(blocked!.PayloadJson)["Data"]!; + Assert.Equal( + new[] { "firstBlockedAtUtc", "operation", "pageTitle", "reason", "serverCertIssuer", "statusCode" }, + data.Properties().Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal)); + Assert.Equal("client_certificate_required", (string?)data["reason"]); + Assert.Equal("agent/telemetry", (string?)data["operation"]); + Assert.Equal(JTokenType.Integer, data["statusCode"]!.Type); + Assert.Equal(403, (int)data["statusCode"]!); + Assert.Equal(InterceptionFixtures.PlatformPageTitle, (string?)data["pageTitle"]); + Assert.Equal(InterceptionFixtures.InspectionIssuer, (string?)data["serverCertIssuer"]); + Assert.Equal( + EnrollmentOrchestratorRig.DefaultClockStart.ToString("o", CultureInfo.InvariantCulture), + (string?)data["firstBlockedAtUtc"]); + + // The marker was an immediate-upload item; neither its wakeup nor a further drain + // touches the network while the pause holds. + var drain = await sut.DrainAsync(); + Assert.Contains("upload intercepted", drain.LastErrorReason); + Assert.Equal(calls, rig.Uploader.CallCount); + Assert.Single(sut.SpoolForTests!.Peek(1000), IsBlockedEvent); + } + finally + { + sut.Stop(); + } + } + + [Fact] + public async Task Terminal_attempt_requested_by_the_termination_runs_once_and_Stop_adds_none() + { + using var rig = new EnrollmentOrchestratorRig(); + var sut = await StartInterceptedAsync(rig); + Assert.True(SpinWait.SpinUntil(() => FindBlockedEvent(sut) != null, WaitMs)); + var calls = rig.Uploader.CallCount; + + sut.RequestTerminalUploadAttempt(); + Assert.True(SpinWait.SpinUntil(() => rig.Uploader.CallCount == calls + 1, WaitMs), + "the woken drain loop did not make the terminal attempt."); + + sut.Stop(); + Assert.Equal(calls + 1, rig.Uploader.CallCount); + } + + [Fact] + public async Task Stop_makes_the_one_terminal_attempt_when_the_termination_did_not() + { + using var rig = new EnrollmentOrchestratorRig(); + var sut = await StartInterceptedAsync(rig); + var calls = rig.Uploader.CallCount; + + sut.Stop(); + + Assert.Equal(calls + 1, rig.Uploader.CallCount); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs index c58746e9..6930a5be 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Security; using System.Reflection; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -10,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Transport; using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration @@ -96,6 +98,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration Assert.Equal(TimeSpan.FromSeconds(5), client.Timeout); } + [Fact] + public void CreateHandler_without_observer_keeps_the_default_validation() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + + using var handler = MtlsHttpClientFactory.CreateHandler(new FakeCertificateResolver(cert), logger); + + Assert.Null(handler.ServerCertificateCustomValidationCallback); + } + + [Fact] + public void CreateHandler_with_observer_installs_an_observing_callback_that_never_weakens_validation() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + var observer = new ServerCertificateObserver(); + + using var handler = MtlsHttpClientFactory.CreateHandler(new FakeCertificateResolver(cert), logger, observer); + var callback = handler.ServerCertificateCustomValidationCallback; + + Assert.NotNull(callback); + Assert.False(callback!(null!, cert, null!, SslPolicyErrors.RemoteCertificateChainErrors)); + Assert.True(callback(null!, cert, null!, SslPolicyErrors.None)); + Assert.Equal(cert.Issuer, observer.LastIssuer); + } + [Fact] public void CreateHandler_throws_when_resolver_returns_null() { @@ -161,7 +192,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration /// we scan the type hierarchy for the first -typed /// instance field to stay version-tolerant. /// - private static HttpMessageHandler GetPipelineHead(HttpClient client) + internal static HttpMessageHandler GetPipelineHead(HttpClient client) { const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; for (var t = (Type?)client.GetType(); t != null; t = t.BaseType) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs index fe3fcb6d..8e4d8e27 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs @@ -1,7 +1,13 @@ using System.IO; +using System.Net.Http; +using System.Net.Security; using AutopilotMonitor.Agent.V2.Core.Configuration; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.Agent.V2.Core.Transport; using AutopilotMonitor.Agent.V2.Runtime; using Xunit; @@ -68,6 +74,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program Assert.False(bundle.HasClientCertificate); } + [Fact] + public void BuildAuthClients_carries_the_server_certificate_observer_for_both_stacks() + { + using var tmp = new TempDirectory(); + var bundle = BackendClientFactory.BuildAuthClients(MinimalBootstrapTokenConfig(), agentVersion: "1.2.3.4", logger: NewLogger(tmp.Path)); + + Assert.NotNull(bundle.ServerCertificates); + } + + [Fact] + public void BuildBackendApiHttpClient_installs_the_observing_callback_on_the_inner_handler() + { + var observer = new ServerCertificateObserver(); + using var cert = ServerCertificateObserverTests.SelfSigned("CN=Example Inspection CA"); + + using var client = BackendClientFactory.BuildBackendApiHttpClient(new NetworkMetrics(), clientCertificate: null, agentVersion: "1.2.3.4", observer); + var inner = InnerHandler(client); + var callback = inner.ServerCertificateCustomValidationCallback; + + Assert.NotNull(callback); + Assert.False(callback!(null!, cert, null!, SslPolicyErrors.RemoteCertificateNameMismatch)); + Assert.True(callback(null!, cert, null!, SslPolicyErrors.None)); + Assert.Equal(cert.Issuer, observer.LastIssuer); + } + + [Fact] + public void BuildBackendApiHttpClient_without_observer_keeps_the_default_validation() + { + using var client = BackendClientFactory.BuildBackendApiHttpClient(new NetworkMetrics(), clientCertificate: null, agentVersion: "1.2.3.4"); + + Assert.Null(InnerHandler(client).ServerCertificateCustomValidationCallback); + } + + // Pipeline: NetworkMetricsRecordingHandler → HttpClientHandler. + private static HttpClientHandler InnerHandler(HttpClient client) => + (HttpClientHandler)((DelegatingHandler)MtlsHttpClientFactoryTests.GetPipelineHead(client)).InnerHandler; + // =========================================================== TelemetryClientResult [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendSessionRegistrationTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendSessionRegistrationTests.cs index b9fc22d9..937009a6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendSessionRegistrationTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendSessionRegistrationTests.cs @@ -122,20 +122,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program // ============================================================ failed-registration record [Fact] - public void RecordRegistrationRun_records_a_failed_run_with_its_window_and_bounded_error() + public void RecordFailedRun_records_a_failed_run_with_its_window_and_bounded_error() { using var tmp = new TempDirectory(); var persistence = new SessionIdPersistence(tmp.Path); var started = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc); - BackendSessionRegistration.RecordRegistrationRun( + var returned = BackendSessionRegistration.RecordFailedRun( persistence, SessionRegistrationResult.Failed(new string('e', 1000)), started, started.AddSeconds(1204.46), linkUpAtStart: false, linkUpAtEnd: true, - configFetchOutcome: "UsedDefaults", logger: NewLogger(tmp.Path)); + configFetchOutcome: "UsedDefaults", networkFingerprint: null, logger: NewLogger(tmp.Path)); var record = persistence.LoadRegistrationFailure(); Assert.NotNull(record); Assert.Equal(1, record!.FailedRuns); + Assert.Equal(0, record.InterceptedRuns); Assert.Equal("Failed", record.Outcome); Assert.Equal(BackendSessionRegistration.MaxRecordedErrorLength, record.LastError!.Length); Assert.False(record.NetworkLinkUpAtStart); @@ -143,33 +144,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program Assert.Equal(1204.5, record.AttemptWindowSeconds); Assert.Equal("UsedDefaults", record.ConfigFetchOutcome); Assert.Equal(started.AddSeconds(1204.46), record.LastFailedAtUtc); + // The caller decides on the record as written, including this run. + Assert.Equal(1, returned!.FailedRuns); } [Fact] - public void RecordRegistrationRun_records_auth_failures_and_clears_on_success() + public void RecordFailedRun_records_auth_failures_and_a_success_clears_them() { using var tmp = new TempDirectory(); var persistence = new SessionIdPersistence(tmp.Path); var at = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc); - BackendSessionRegistration.RecordRegistrationRun( + BackendSessionRegistration.RecordFailedRun( persistence, SessionRegistrationResult.AuthFailed(401, "unauthorized"), - at, at.AddSeconds(3), true, true, "Succeeded", NewLogger(tmp.Path)); + at, at.AddSeconds(3), true, true, "Succeeded", null, NewLogger(tmp.Path)); Assert.Equal("AuthFailed", persistence.LoadRegistrationFailure()!.Outcome); - BackendSessionRegistration.RecordRegistrationRun( - persistence, SessionRegistrationResult.Succeeded(new RegisterSessionResponse { Success = true }), - at, at.AddSeconds(1), true, true, "Succeeded", NewLogger(tmp.Path)); + BackendSessionRegistration.ClearAfterSuccess(persistence, () => null, NewLogger(tmp.Path)); Assert.Null(persistence.LoadRegistrationFailure()); } [Fact] - public void RecordRegistrationRun_without_persistence_is_a_no_op() + public void Recording_without_persistence_is_a_no_op() { using var tmp = new TempDirectory(); - BackendSessionRegistration.RecordRegistrationRun( + Assert.Null(BackendSessionRegistration.RecordFailedRun( null, SessionRegistrationResult.Failed("x"), DateTime.UtcNow, DateTime.UtcNow, - null, null, null, NewLogger(tmp.Path)); + null, null, null, null, NewLogger(tmp.Path))); + Assert.Null(BackendSessionRegistration.ClearAfterSuccess(null, () => null, NewLogger(tmp.Path))); } // ============================================================ emergency-break message diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/InterceptedRegistrationTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/InterceptedRegistrationTests.cs new file mode 100644 index 00000000..c9666a55 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/InterceptedRegistrationTests.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.Agent.V2.Runtime; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using Xunit; +using ProgramAlias = AutopilotMonitor.Agent.V2.Program; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Program +{ + /// + /// A registration that something on the network path answered at process start (client + /// certificate interception plan, K4/K5): a buffer of further boots, then the emergency break's + /// cleanup; the pending ClientCertificateNotReceived distress leaves the machine first; a later + /// successful registration reports the failed starts on agent_started. Each start is + /// ended the way ends it: record the failed + /// run, then conclude it. + /// + public sealed class InterceptedRegistrationTests : IDisposable + { + private static readonly DateTime At = new DateTime(2026, 10, 12, 9, 0, 0, DateTimeKind.Utc); + + private readonly TempDirectory _tmp = new TempDirectory(); + private readonly AgentLogger _logger; + private readonly SessionIdPersistence _persistence; + private readonly string _stateDir; + private int _cleanupCalls; + + public InterceptedRegistrationTests() + { + _logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info); + _persistence = new SessionIdPersistence(_tmp.Path); + _persistence.GetOrCreate(_logger); + _stateDir = Path.Combine(_tmp.Path, "State"); + } + + public void Dispose() => _tmp.Dispose(); + + // ============================================================ K4: buffer, then clean up + + [Fact] + public void Agent_default_buffer_allows_one_further_boot_then_cleans_up() + { + var config = new AgentConfiguration(); + Assert.Equal(1, config.InterceptedRegistrationRetryBoots); + + Assert.Equal(6, Boot(Intercepted(), config)); + + Assert.False(GaveUp); + Assert.True(_persistence.SessionExists()); + Assert.Equal(0, _cleanupCalls); + var record = _persistence.LoadRegistrationFailure()!; + Assert.Equal(1, record.FailedRuns); + Assert.Equal(1, record.InterceptedRuns); + Assert.Equal("AuthFailed", record.Outcome); + + Assert.Equal(6, Boot(Intercepted(), config)); // the give-up keeps exit 6 + + AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls: 1); + } + + [Theory] + [InlineData(0)] // clean up at the first intercepted start + [InlineData(2)] + [InlineData(5)] + public void Gives_up_on_the_first_intercepted_start_beyond_the_buffer(int buffer) + { + var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = buffer }; + + for (var start = 1; start <= buffer; start++) + { + Assert.Equal(6, Boot(Intercepted(), config)); + Assert.False(GaveUp, $"gave up on start {start} with a buffer of {buffer}"); + } + + Assert.Equal(6, Boot(Intercepted(), config)); + + AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls: 1); + } + + [Theory] + [InlineData(true, 1)] + [InlineData(false, 0)] + public void Give_up_runs_the_cleanup_only_with_SelfDestructOnComplete_like_the_emergency_break( + bool selfDestructOnComplete, int expectedCleanupCalls) + { + var config = new AgentConfiguration + { + InterceptedRegistrationRetryBoots = 0, + SelfDestructOnComplete = selfDestructOnComplete, + }; + + Assert.Equal(6, Boot(Intercepted(), config)); + + AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls); + } + + [Fact] + public async Task An_intercepted_start_takes_the_buffer_from_the_cached_config() + { + // The config fetch of an intercepted start is intercepted too: it falls back to the + // cache, and the registration decides on the cached buffer, not the agent default. + var config = new AgentConfiguration(); + var service = new InterceptedConfigService(_logger, cached: new AgentConfigResponse + { + InterceptedRegistrationRetryBoots = 0, + SelfDestructOnComplete = true, + }); + + var fetched = await service.FetchConfigAsync(retryOnTransientErrors: true); + Assert.Equal(RemoteConfigFetchOutcome.FromCache, service.LastFetchOutcome); + RemoteConfigMerger.Merge(config, fetched, _logger); + Assert.Equal(0, config.InterceptedRegistrationRetryBoots); + + Assert.Equal(6, Boot(Intercepted(), config)); + + AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls: 1); + } + + [Fact] + public void Backend_rejection_and_network_failure_keep_their_exit_codes_and_never_give_up() + { + var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = 0 }; + + Assert.Equal(6, Boot(BackendRejected(), config)); + Assert.Equal(7, Boot(NetworkFailure(), config)); + + Assert.False(GaveUp); + Assert.True(_persistence.SessionExists()); + Assert.Equal(0, _cleanupCalls); + var record = _persistence.LoadRegistrationFailure()!; + Assert.Equal(2, record.FailedRuns); + Assert.Equal(0, record.InterceptedRuns); + } + + [Fact] + public void Only_an_intercepted_start_gives_up_even_when_the_count_already_exceeds_the_buffer() + { + var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = 5 }; + Boot(Intercepted(), config); + Boot(Intercepted(), config); + config.InterceptedRegistrationRetryBoots = 0; // e.g. a later cached config lowered it + + Assert.Equal(6, Boot(BackendRejected(), config)); + Assert.Equal(7, Boot(NetworkFailure(), config)); + + Assert.False(GaveUp); + Assert.Equal(0, _cleanupCalls); + var record = _persistence.LoadRegistrationFailure()!; + Assert.Equal(4, record.FailedRuns); + Assert.Equal(2, record.InterceptedRuns); // unchanged by the other failures + Assert.Equal("Failed", record.Outcome); + } + + [Fact] + public void Failures_of_another_kind_between_interceptions_do_not_reset_the_count() + { + var config = new AgentConfiguration(); // buffer 1 + + Assert.Equal(6, Boot(Intercepted(), config)); + Assert.Equal(7, Boot(NetworkFailure(), config)); + Assert.Equal(6, Boot(BackendRejected(), config)); + Assert.False(GaveUp); + + Assert.Equal(6, Boot(Intercepted(), config)); // second interception since the last success + + AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls: 1); + } + + [Fact] + public void Only_a_successful_registration_resets_the_count() + { + var config = new AgentConfiguration(); // buffer 1 + Boot(Intercepted(), config); + BackendSessionRegistration.ClearAfterSuccess(_persistence, () => "fp-office", _logger); + + Assert.Equal(6, Boot(Intercepted(), config)); + + Assert.False(GaveUp); + Assert.Equal(1, _persistence.LoadRegistrationFailure()!.InterceptedRuns); + } + + [Fact] + public void Without_a_written_record_the_buffer_cannot_be_enforced_and_the_next_boot_retries() + { + var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = 0 }; + + var exit = BackendSessionRegistration.ConcludeFailedRun( + Intercepted(), record: null, config, authFailureTracker: null, + _persistence, _stateDir, CleanupFactory, _logger); + + Assert.Equal(6, exit); + Assert.False(GaveUp); + Assert.True(_persistence.SessionExists()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task The_registration_answer_decides_whether_the_start_counts_as_intercepted(bool foreign) + { + // Through the real client: no correlation echo + the platform's "Client Certificate + // Required" page is an interception; a JSON 403 that echoes the id is the backend's verdict. + using var handler = new RecordingHttpMessageHandler(); + if (foreign) + handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage); + else + handler.QueueStatus(HttpStatusCode.Forbidden, "{\"error\":\"Device validation failed\"}"); + using var http = new HttpClient(handler); + var apiClient = new BackendApiClient( + http, "https://backend.test", manufacturer: "m", model: "d", serialNumber: "s", + useBootstrapTokenAuth: false, bootstrapToken: null, agentVersion: "0.0.0", logger: _logger); + var distress = new FakeDistressReporter(); + var config = new AgentConfiguration + { + ApiBaseUrl = "https://backend.test", + TenantId = "00000000-0000-0000-0000-000000000001", + SessionId = _persistence.GetOrCreate(_logger), + InterceptedRegistrationRetryBoots = 0, + }; + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: _logger, + authFailureTracker: NewTracker(distress), + deviceHardware: ("m", "d", "s"), + networkLinkWait: _ => Task.CompletedTask); + + Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome); + Assert.Equal(foreign, result.IsIntercepted); + Assert.Equal( + foreign ? DistressErrorType.ClientCertificateNotReceived : DistressErrorType.DeviceNotRegistered, + Assert.Single(distress.Calls).ErrorType); + + Assert.Equal(6, Boot(result, config)); + Assert.Equal(foreign, GaveUp); + } + + // ============================================================ the distress leaves first + + [Fact] + public void The_exit_waits_for_the_pending_ClientCertificateNotReceived_send() + { + var record = RecordFailed(Intercepted(), "fp-office"); + var (tracker, inFlight) = DispatchSlowCertificateDistress(); + + var exit = BackendSessionRegistration.ConcludeFailedRun( + Intercepted(), record, new AgentConfiguration(), tracker, + _persistence, _stateDir, CleanupFactory, _logger); + + Assert.Equal(6, exit); + Assert.True(inFlight.IsCompleted); + Assert.False(GaveUp); + } + + [Fact] + public void The_give_up_waits_for_the_pending_send_before_it_cleans_up() + { + var record = RecordFailed(Intercepted(), "fp-office"); + var (tracker, inFlight) = DispatchSlowCertificateDistress(); + bool? sentBeforeCleanup = null; + + var exit = BackendSessionRegistration.ConcludeFailedRun( + Intercepted(), record, new AgentConfiguration { InterceptedRegistrationRetryBoots = 0 }, tracker, + _persistence, _stateDir, + () => + { + sentBeforeCleanup = inFlight.IsCompleted; + throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests"); + }, + _logger); + + Assert.Equal(6, exit); + Assert.True(sentBeforeCleanup); + Assert.True(GaveUp); + } + + [Fact] + public void The_wait_for_the_distress_is_bounded_by_its_own_http_timeout() + { + Assert.True(BackendSessionRegistration.PendingDistressBound <= TimeSpan.FromSeconds(5)); + } + + // ============================================================ K5: measurement point + + [Theory] + [InlineData("fp-office", "fp-office", false)] + [InlineData("fp-office", "fp-home", true)] + public void A_success_after_failed_starts_reports_the_record_it_clears( + string failedOn, string registeredOn, bool networkChanged) + { + var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = 5 }; + Boot(NetworkFailure(), config, fingerprint: "fp-elsewhere"); + Boot(Intercepted(), config, fingerprint: failedOn); // the latest failed start's network counts + + var prior = BackendSessionRegistration.ClearAfterSuccess(_persistence, () => registeredOn, _logger)!; + + Assert.Equal(2, prior.FailedRuns); + Assert.Equal(1, prior.InterceptedRuns); + Assert.Equal("AuthFailed", prior.LastOutcome); + Assert.Equal(networkChanged, prior.NetworkChanged); + Assert.Null(_persistence.LoadRegistrationFailureRecord()); + + var data = EmitAgentStarted(prior); + Assert.Equal(2, data["priorRegistrationFailedRuns"]); + Assert.Equal(1, data["priorRegistrationInterceptedRuns"]); + Assert.Equal("AuthFailed", data["priorRegistrationLastOutcome"]); + Assert.Equal(networkChanged, data["priorRegistrationNetworkChanged"]); + } + + [Theory] + [InlineData(null, "fp-home")] // the failed start's network was unknown + [InlineData("fp-office", null)] // this start's network is unknown + public void Network_change_is_omitted_when_either_fingerprint_is_unknown(string? failedOn, string? registeredOn) + { + Boot(Intercepted(), new AgentConfiguration(), fingerprint: failedOn); + + var prior = BackendSessionRegistration.ClearAfterSuccess(_persistence, () => registeredOn!, _logger)!; + + Assert.Null(prior.NetworkChanged); + var data = EmitAgentStarted(prior); + Assert.Equal(1, data["priorRegistrationFailedRuns"]); + Assert.False(data.ContainsKey("priorRegistrationNetworkChanged")); + } + + [Fact] + public void A_success_without_failed_starts_reports_nothing_and_never_reads_the_network() + { + var captures = 0; + + var prior = BackendSessionRegistration.ClearAfterSuccess( + _persistence, () => { captures++; return "fp-office"; }, _logger); + + Assert.Null(prior); + Assert.Equal(0, captures); + Assert.DoesNotContain(EmitAgentStarted(null).Keys, k => k.StartsWith("priorRegistration", StringComparison.Ordinal)); + } + + [Fact] + public void The_network_fingerprint_stays_on_the_device() + { + Boot(Intercepted(), new AgentConfiguration(), fingerprint: "0123456789abcdef"); + + // Kept locally beside the record ... + Assert.Contains("0123456789abcdef", File.ReadAllText(Path.Combine(_tmp.Path, "registration-failure.json"))); + Assert.Equal("0123456789abcdef", _persistence.LoadRegistrationFailureRecord()!.NetworkFingerprint); + + // ... while the summary the emergency break sends cannot carry it. + var wire = JsonConvert.SerializeObject(new AgentErrorReport + { + PriorRegistrationFailure = _persistence.LoadRegistrationFailure(), + }); + Assert.DoesNotContain("0123456789abcdef", wire); + Assert.DoesNotContain("Fingerprint", wire); + Assert.Contains("\"InterceptedRuns\":1", wire); + } + + // ============================================================ helpers + + private static SessionRegistrationResult Intercepted() => + SessionRegistrationResult.AuthFailed(403, "The device certificate did not reach the backend.", intercepted: true); + + private static SessionRegistrationResult BackendRejected() => + SessionRegistrationResult.AuthFailed(403, "Backend returned 403 Forbidden. The device is not authorized."); + + private static SessionRegistrationResult NetworkFailure() => + SessionRegistrationResult.Failed("No such host is known."); + + private bool GaveUp => File.Exists(Path.Combine(_stateDir, "enrollment-complete.marker")); + + private AuthFailureTracker NewTracker(FakeDistressReporter distress) => + new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, SystemClock.Instance, _logger, distress); + + /// + /// What the registration helper does when the answer did not come from the backend: the + /// tracker dispatches ClientCertificateNotReceived — here a send that stays in flight for a + /// second, started right before the conclusion so nothing else can use the time up. + /// + private (AuthFailureTracker Tracker, Task InFlight) DispatchSlowCertificateDistress() + { + Task? inFlight = null; + var distress = new FakeDistressReporter { Send = () => inFlight = Task.Delay(TimeSpan.FromSeconds(1)) }; + var tracker = NewTracker(distress); + tracker.RecordFailure(403, "agent/register-session", InterceptionFixtures.CertificateRequired()); + + Assert.Equal(DistressErrorType.ClientCertificateNotReceived, Assert.Single(distress.Calls).ErrorType); + Assert.False(inFlight!.IsCompleted); + return (tracker, inFlight); + } + + private RegistrationFailureSummary? RecordFailed(SessionRegistrationResult result, string? fingerprint) => + BackendSessionRegistration.RecordFailedRun( + _persistence, result, At, At.AddSeconds(2), linkUpAtStart: true, linkUpAtEnd: true, + configFetchOutcome: "FromCache", networkFingerprint: fingerprint, logger: _logger); + + /// One start whose registration failed with , ended the way Register ends it. + private int Boot(SessionRegistrationResult result, AgentConfiguration config, string? fingerprint = "fp-office") + { + var record = RecordFailed(result, fingerprint); + return BackendSessionRegistration.ConcludeFailedRun( + result, record, config, authFailureTracker: null, _persistence, _stateDir, CleanupFactory, _logger); + } + + private CleanupService CleanupFactory() + { + _cleanupCalls++; + // The real factory spawns a PowerShell cleanup script; throwing makes the cleanup retry + // swallow and log it, so the call is counted without a side effect (as in ProgramGuardsTests). + throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests"); + } + + /// The emergency break's end state (ProgramGuardsTests): marker, session files gone, cleanup per SelfDestructOnComplete. + private void AssertCleanedUpLikeTheEmergencyBreak(int expectedCleanupCalls) + { + Assert.True(GaveUp); + Assert.False(_persistence.SessionExists()); + Assert.Null(_persistence.LoadRegistrationFailure()); + Assert.Equal(expectedCleanupCalls, _cleanupCalls); + // The next start ends at the bootstrap guard, exactly as after the break. + Assert.True(ProgramAlias.CheckEnrollmentCompleteMarker( + _stateDir, selfDestructOnComplete: false, cleanupServiceFactory: null, _logger, consoleMode: false)); + } + + private Dictionary EmitAgentStarted(PriorRegistrationFailures? prior) + { + var sink = new FakeSignalIngressSink(); + LifecycleEmitters.EmitAgentStarted( + new InformationalEventPost(sink, SystemClock.Instance, _logger), + new AgentConfiguration { SessionId = "S1", TenantId = "00000000-0000-0000-0000-000000000001", CommandLineArgs = string.Empty }, + new ProgramAlias.PreviousExitSummary { ExitType = "reboot_kill" }, + "2.0.test", + remoteConfigService: null, + _logger, + priorRegistrationFailures: prior); + return (Dictionary)Assert.Single(sink.Posted).TypedPayload!; + } + + /// A start whose config fetch is answered by the interception: it falls back to the cache. + private sealed class InterceptedConfigService : RemoteConfigService + { + private readonly AgentConfigResponse _cached; + + public InterceptedConfigService(AgentLogger logger, AgentConfigResponse cached) + : base( + new BackendApiClient( + httpClient: new HttpClient(), baseUrl: "http://localhost", manufacturer: string.Empty, + model: string.Empty, serialNumber: string.Empty, useBootstrapTokenAuth: false, + bootstrapToken: null, agentVersion: "0.0.0", logger: logger), + "00000000-0000-0000-0000-000000000001", + logger) + { + _cached = cached; + } + + protected override Task CallBackendAsync() => + throw new BackendAuthException("The device certificate did not reach the backend.", 403, + classification: InterceptionFixtures.CertificateRequired()); + + protected override AgentConfigResponse LoadCachedConfig() => _cached; + + protected override Task WaitForNetworkLinkAsync() => Task.CompletedTask; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs index f8896aaa..5a88cdc2 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs @@ -1,11 +1,12 @@ using System; -using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Logging; -using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; using AutopilotMonitor.Shared.Models; using Xunit; @@ -261,8 +262,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security } [Fact] - public void RecordFailure_endpoint_unavailable_suppresses_distress_but_still_counts() + public void RecordFailure_only_the_stopped_app_page_counts_without_distress() { + // Rewritten deliberately for the client-certificate interception plan: the former + // "endpoint unavailable" flag covered every HTML platform page, so the platform's + // "Client Certificate Required" page (TLS inspection on the path) shut healthy agents + // down. Now only the stopped-app page keeps that semantics — it counts toward the + // shutdown (a dead endpoint must not retry forever) without a distress report that + // could not reach it — while the certificate page never counts (tests below). var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); var fakeDistress = new FakeDistressReporter(); var tracker = new AuthFailureTracker(maxFailures: 2, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); @@ -270,19 +277,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security int events = 0; tracker.ThresholdExceeded += (_, _) => events++; - // Platform-403s from a stopped endpoint: a distress report cannot reach it and the - // DeviceNotRegistered classification would be wrong — nothing must be dispatched. - tracker.RecordFailure(403, "agent/config", endpointUnavailable: true); - tracker.RecordFailure(403, "agent/register-session", endpointUnavailable: true); + tracker.RecordFailure(403, "agent/config", InterceptionFixtures.CertificateRequired()); + Assert.Equal(0, tracker.ConsecutiveFailures); + + tracker.RecordFailure(403, "agent/config", InterceptionFixtures.StoppedApp()); + tracker.RecordFailure(403, "agent/register-session", InterceptionFixtures.StoppedApp()); - Assert.Empty(fakeDistress.Calls); - // The shutdown thresholds still apply — a dead endpoint must not retry forever. Assert.Equal(2, tracker.ConsecutiveFailures); Assert.Equal(1, events); + Assert.DoesNotContain(fakeDistress.Calls, c => c.ErrorType == DistressErrorType.DeviceNotRegistered); } [Fact] - public void RecordFailure_endpoint_unavailable_does_not_consume_streak_report_slot() + public void RecordFailure_stopped_app_page_does_not_consume_streak_report_slot() { var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); var fakeDistress = new FakeDistressReporter(); @@ -290,7 +297,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security // The streak opens with a platform-403 (no dispatch), then a live backend answers // with a genuine 403 — that one must still produce the streak's single report. - tracker.RecordFailure(403, "agent/config", endpointUnavailable: true); + tracker.RecordFailure(403, "agent/config", InterceptionFixtures.StoppedApp()); tracker.RecordFailure(403, "agent/config"); tracker.RecordFailure(403, "agent/config"); @@ -298,6 +305,133 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType); } + // ============================================================= Foreign answers (interception) + + [Fact] + public void RecordFailure_foreign_never_reaches_the_threshold() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 10, clock, NewLogger(), new FakeDistressReporter()); + + int events = 0; + tracker.ThresholdExceeded += (_, _) => events++; + + for (int i = 0; i < 10; i++) + { + tracker.RecordFailure(403, "agent/telemetry", InterceptionFixtures.CertificateRequired()); + clock.Now = clock.Now.AddMinutes(3); // also past the time window + } + + Assert.Equal(0, events); + Assert.Equal(0, tracker.ConsecutiveFailures); + } + + [Fact] + public void RecordFailure_foreign_dispatches_one_ClientCertificateNotReceived_per_process() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var fakeDistress = new FakeDistressReporter(); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + + tracker.RecordFailure(403, "agent/register-session", InterceptionFixtures.CertificateRequired()); + tracker.RecordFailure(403, "agent/telemetry", InterceptionFixtures.CertificateRequired()); + tracker.RecordSuccess(); // a success re-arms the streak report, never the per-process one + tracker.RecordFailure(401, "agent/telemetry", InterceptionFixtures.CertificateRequired(401)); + + var call = Assert.Single(fakeDistress.Calls); + Assert.Equal(DistressErrorType.ClientCertificateNotReceived, call.ErrorType); + Assert.Equal(403, call.HttpStatusCode); + // Operation, status, sub-reason, page title, issuer — in that order, within the 256 budget. + Assert.StartsWith("agent/register-session http 403 client_certificate_required;", call.Message); + Assert.True( + call.Message.IndexOf(InterceptionFixtures.PlatformPageTitle, StringComparison.Ordinal) < + call.Message.IndexOf(InterceptionFixtures.InspectionIssuer, StringComparison.Ordinal)); + Assert.True(call.Message.Length <= 256, $"distress message is {call.Message.Length} characters"); + } + + [Fact] + public void RecordFailure_foreign_within_a_backend_streak_neither_resets_nor_advances_it() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var fakeDistress = new FakeDistressReporter(); + var tracker = new AuthFailureTracker(maxFailures: 3, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + + int events = 0; + tracker.ThresholdExceeded += (_, _) => events++; + + tracker.RecordFailure(403, "agent/telemetry"); + tracker.RecordFailure(403, "agent/telemetry"); + tracker.RecordFailure(403, "agent/telemetry", InterceptionFixtures.CertificateRequired()); + tracker.RecordFailure(403, "agent/telemetry", InterceptionFixtures.CertificateRequired()); + + Assert.Equal(2, tracker.ConsecutiveFailures); + Assert.Equal(0, events); + + tracker.RecordFailure(403, "agent/telemetry"); // the third backend rejection trips it + + Assert.Equal(3, tracker.ConsecutiveFailures); + Assert.Equal(1, events); + Assert.Equal(1, fakeDistress.Calls.Count(c => c.ErrorType == DistressErrorType.DeviceNotRegistered)); + } + + // ============================================================= Pending foreign distress + + [Fact] + public void WaitForPendingForeignDistress_returns_at_once_when_nothing_was_dispatched() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), new FakeDistressReporter()); + + Assert.True(tracker.WaitForPendingForeignDistress(TimeSpan.Zero)); + } + + [Fact] + public void WaitForPendingForeignDistress_waits_for_the_send_to_finish() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + Task? inFlight = null; + var fakeDistress = new FakeDistressReporter { Send = () => inFlight = Task.Delay(200) }; + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + + tracker.RecordFailure(403, "agent/register-session", InterceptionFixtures.CertificateRequired()); + + Assert.True(tracker.WaitForPendingForeignDistress(TimeSpan.FromSeconds(10))); + Assert.True(inFlight!.IsCompleted); + } + + [Fact] + public void WaitForPendingForeignDistress_stops_at_the_bound() + { + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var neverSent = new TaskCompletionSource(); + var fakeDistress = new FakeDistressReporter { Send = () => neverSent.Task }; + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + tracker.RecordFailure(403, "agent/register-session", InterceptionFixtures.CertificateRequired()); + + var watch = Stopwatch.StartNew(); + var finished = tracker.WaitForPendingForeignDistress(TimeSpan.FromMilliseconds(100)); + watch.Stop(); + + Assert.False(finished); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(5), $"waited {watch.Elapsed}"); + } + + [Fact] + public void WaitForPendingForeignDistress_never_waits_for_a_backend_streak_report() + { + // Only the ClientCertificateNotReceived send is kept: a failed start must not block + // on anything else. + var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + var neverSent = new TaskCompletionSource(); + var fakeDistress = new FakeDistressReporter { Send = () => neverSent.Task }; + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress); + + tracker.RecordFailure(403, "agent/register-session"); + + Assert.Equal(DistressErrorType.DeviceNotRegistered, Assert.Single(fakeDistress.Calls).ErrorType); + Assert.True(tracker.WaitForPendingForeignDistress(TimeSpan.Zero)); + } + [Fact] public void RecordSuccess_rearms_distress_dispatch_for_the_next_streak() { @@ -329,29 +463,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.Equal(1, events); } - private sealed class FakeDistressReporter : DistressReporter - { - public sealed class Call - { - public DistressErrorType ErrorType { get; set; } - public string Message { get; set; } = default!; - public int? HttpStatusCode { get; set; } - } - - public List Calls { get; } = new List(); - - public FakeDistressReporter() - : base(baseUrl: "https://example.test", tenantId: "t", manufacturer: "m", model: "d", serialNumber: "s", agentVersion: "0.0.0", logger: null!) - { - } - - public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null) - { - Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode }); - return Task.CompletedTask; - } - } - private sealed class FakeClock : AutopilotMonitor.DecisionCore.Engine.IClock { public DateTime Now { get; set; } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs index 4f6cc85f..b6441945 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs @@ -298,9 +298,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.False(File.Exists(Path.Combine(tmp.Path, "registration-failure.json"))); } + [Fact] + public void RecordRegistrationFailure_counts_intercepted_runs_and_keeps_the_latest_network_fingerprint() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + var at = new DateTime(2026, 10, 12, 9, 0, 0, DateTimeKind.Utc); + var intercepted = FailedRun(at, outcome: "AuthFailed"); + intercepted.InterceptedRuns = 1; + + sut.RecordRegistrationFailure(intercepted, networkFingerprint: "fp-office"); + sut.RecordRegistrationFailure(FailedRun(at.AddHours(1)), networkFingerprint: null); + var merged = sut.RecordRegistrationFailure(intercepted, networkFingerprint: "fp-home"); + + Assert.Equal(3, merged!.FailedRuns); + Assert.Equal(2, merged.InterceptedRuns); + var record = new SessionIdPersistence(tmp.Path).LoadRegistrationFailureRecord(); + Assert.Equal(3, record!.Summary.FailedRuns); + Assert.Equal(2, record.Summary.InterceptedRuns); + Assert.Equal("fp-home", record.NetworkFingerprint); + } + + [Fact] + public void A_record_from_an_agent_before_the_interception_buffer_still_loads() + { + using var tmp = new TempDirectory(); + File.WriteAllText(Path.Combine(tmp.Path, "registration-failure.json"), + "{\"FailedRuns\":2,\"FirstFailedAtUtc\":\"2026-09-01T08:00:00Z\",\"LastFailedAtUtc\":\"2026-09-02T08:00:00Z\"," + + "\"Outcome\":\"AuthFailed\",\"LastError\":\"x\",\"NetworkLinkUpAtStart\":true,\"NetworkLinkUpAtEnd\":true," + + "\"AttemptWindowSeconds\":3.0,\"ConfigFetchOutcome\":\"FromCache\"}"); + + var record = new SessionIdPersistence(tmp.Path).LoadRegistrationFailureRecord(); + + Assert.Equal(2, record!.Summary.FailedRuns); + Assert.Equal(0, record.Summary.InterceptedRuns); + Assert.Null(record.NetworkFingerprint); + Assert.Equal(new DateTime(2026, 9, 1, 8, 0, 0, DateTimeKind.Utc), record.Summary.FirstFailedAtUtc); + } + [Theory] [InlineData("not json")] [InlineData("{\"FailedRuns\":0}")] + [InlineData("null")] + [InlineData("[]")] + [InlineData("")] public void LoadRegistrationFailure_reports_nothing_for_a_corrupt_or_empty_record(string content) { using var tmp = new TempDirectory(); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs index 6fbaa36d..f9fa9166 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs @@ -6,6 +6,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.Security; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; using AutopilotMonitor.DecisionCore.Engine; using AutopilotMonitor.Shared.Models; using Xunit; @@ -96,6 +97,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.Equal(1, tracker.ConsecutiveFailures); } + [Fact] + public async Task Intercepted_registration_ends_auth_failed_without_counting_and_reports_once() + { + // Something on the network path answered (TLS inspection): the run still ends + // AuthFailed (exit 6, unchanged until the interception buffer exists), but the tracker + // does not count it and sends ClientCertificateNotReceived instead of DeviceNotRegistered. + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp); + var config = NewConfig(); + + var apiClient = new FakeApiClient(_ => + throw new BackendAuthException("The device certificate did not reach the backend.", statusCode: 403, + classification: InterceptionFixtures.CertificateRequired())); + var distress = new FakeDistressReporter(); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, + clock: SystemClock.Instance, logger: logger, distressReporter: distress); + + var result = await SessionRegistrationHelper.RegisterWithRetryAsync( + apiClient, config, agentVersion: "0.0.0", logger: logger, authFailureTracker: tracker, + networkLinkWait: _ => Task.CompletedTask); + + Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome); + Assert.Equal(403, result.HttpStatusCode); + Assert.Equal(1, apiClient.CallCount); + Assert.Equal(0, tracker.ConsecutiveFailures); + var call = Assert.Single(distress.Calls); + Assert.Equal(DistressErrorType.ClientCertificateNotReceived, call.ErrorType); + Assert.StartsWith("agent/register-session http 403 client_certificate_required", call.Message); + } + // ── SESSION-OWNER-BINDING ──────────────────────────────────────────── private static BackendAuthException OwnerMismatch() diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs index aa8b3b86..451cfab6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs @@ -165,6 +165,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination public IReadOnlyCollection StarvedAlreadyReportedOverride { get; set; } = Array.Empty(); public List TerminationActionLog { get; } = new List(); public TimeSpan SpoolDrainPeriodOverride { get; set; } = TimeSpan.Zero; + public int TerminalUploadAttemptRequests; // Session bc803955 — DevicePrep app-wave settle wait. Zero (default) disables the // wait so the existing timing-sensitive tests stay untouched; the dedicated settle // tests opt in with a real budget. @@ -267,6 +268,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination public long IngressPendingSignalCount => _rig.IngressPendingSignalCountAccessor!(); public bool CanObserveSpool => _rig.PendingItemCountAccessor != null; public int SpoolPendingItemCount => _rig.PendingItemCountAccessor!(); + + public void RequestTerminalUploadAttempt() + { + Interlocked.Increment(ref _rig.TerminalUploadAttemptRequests); + _rig.TerminationActionLog.Add("terminalUploadAttempt"); + } } /// ARCH-F2 — recording shutdown gate (no real shutdown.exe in tests). @@ -489,6 +496,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination rig.Build(cfg).Handle(sender: null!, ServerArgs(TerminationOrigins.SessionGone)); Assert.Equal(0, spoolPolls); + Assert.Equal(0, rig.TerminalUploadAttemptRequests); Assert.Equal(0, rig.DiagnosticsUploads); Assert.DoesNotContain(Constants.EventTypes.DiagnosticsCollecting, rig.EmittedEventTypes); Assert.Contains(Constants.EventTypes.AgentShuttingDown, rig.EmittedEventTypes); @@ -497,6 +505,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination Assert.Equal(1, rig.ShutdownSignalled); } + public enum TerminalExit { SelfDestruct, StandaloneReboot, WhiteGlovePart1 } + + [Theory] + [InlineData(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed, TerminalExit.SelfDestruct)] + [InlineData(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed, TerminalExit.SelfDestruct)] + [InlineData(EnrollmentTerminationReason.StoppedWaiting, EnrollmentTerminationOutcome.TimedOut, SessionStage.AwaitingHello, TerminalExit.SelfDestruct)] + [InlineData(EnrollmentTerminationReason.MaxLifetimeExceeded, EnrollmentTerminationOutcome.TimedOut, SessionStage.AwaitingHello, TerminalExit.SelfDestruct)] + [InlineData(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed, TerminalExit.StandaloneReboot)] + [InlineData(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed, TerminalExit.WhiteGlovePart1)] + public void Handle_while_uploads_are_intercepted_makes_one_terminal_attempt_then_exits_as_usual( + EnrollmentTerminationReason reason, EnrollmentTerminationOutcome outcome, SessionStage stage, TerminalExit exit) + { + // Enrollment end during an interception pause: no deferral. DrainSpool asks for the one + // terminal upload attempt, waits its bounded budget (the spool never empties — every + // upload is answered on the network path), then the exit runs exactly as for any + // session. Unsent telemetry is lost, as today. + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = stage }.Build(); + rig.SpoolDrainPeriodOverride = TimeSpan.FromMilliseconds(200); + rig.PendingItemCountAccessor = () => 7; + rig.DiagnosticsResult = new DiagnosticsUploadResult + { + ErrorCode = "The device certificate did not reach the backend (TLS inspection or a proxy on the network path): 403 Forbidden ...", + }; + var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: exit != TerminalExit.StandaloneReboot); + cfg.RebootOnComplete = exit == TerminalExit.StandaloneReboot; + + rig.Build(cfg).Handle(sender: null!, Args(reason, outcome, stage)); + + Assert.Equal(1, rig.TerminalUploadAttemptRequests); + Assert.Equal(1, rig.ShutdownSignalled); + Assert.True(rig.TerminationActionLog.IndexOf("terminalUploadAttempt") < rig.TerminationActionLog.IndexOf("signalShutdown")); + if (exit == TerminalExit.WhiteGlovePart1) + { + Assert.True(rig.SessionPersistence.IsWhiteGloveResume()); + Assert.Equal(0, rig.CleanupService.Invocations); + return; + } + + Assert.Equal(1, rig.DiagnosticsUploads); + Assert.Contains(Constants.EventTypes.DiagnosticsUploadFailed, rig.EmittedEventTypes); + Assert.True(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker"))); + Assert.Equal(exit == TerminalExit.SelfDestruct ? 1 : 0, rig.CleanupService.Invocations); + Assert.Equal(exit == TerminalExit.StandaloneReboot ? 1 : 0, rig.RebootInvocations); + } + [Fact] public void Handle_kill_signal_origin_still_drains_spool_and_uploads_diagnostics() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendApiClientAuthClassificationTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendApiClientAuthClassificationTests.cs index 5056925c..d08c8692 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendApiClientAuthClassificationTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendApiClientAuthClassificationTests.cs @@ -6,16 +6,19 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Transport; +using AutopilotMonitor.Shared.Models; using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport { /// - /// 401/403 classification in : a backend JSON 403 is a real - /// device-authorization verdict, while an HTML body means the response came from the Azure - /// platform / an edge proxy (stopped or retired app) and must NOT be reported as - /// "device is not authorized". Field case 2026-08-09: a pre-cutover agent hit - /// the stopped legacy Function App and support chased a non-existent authorization problem. + /// 401/403 classification in : only a response that echoes the + /// request's correlation id is the backend's device-authorization verdict. The platform page of + /// a stopped app (field case 2026-08-09: a pre-cutover agent hit the stopped legacy Function App + /// and support chased a non-existent authorization problem) and anything answered on the network + /// path (TLS inspection: "Client Certificate Required", proxy pages) must NOT be reported as + /// "device is not authorized". /// public sealed class BackendApiClientAuthClassificationTests { @@ -24,17 +27,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport private readonly Func _responder; public StubHandler(Func responder) => _responder = responder; + public int Calls { get; private set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - => Task.FromResult(_responder(request)); + { + Calls++; + var response = _responder(request); + response.RequestMessage = request; // as HttpClientHandler does + return Task.FromResult(response); + } } - private static BackendApiClient BuildClient(HttpStatusCode status, string body, string mediaType) - { - var handler = new StubHandler(_ => new HttpResponseMessage(status) + /// + /// True: the response echoes the request's X-Correlation-ID like the backend's middleware. + /// False: a platform or proxy answer without the echo. + /// + private static StubHandler Answer(HttpStatusCode status, string body, string mediaType, bool fromBackend) => + new StubHandler(request => { - Content = new StringContent(body, Encoding.UTF8, mediaType) + var response = new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, mediaType) }; + if (fromBackend && request.Headers.TryGetValues(ResponseOriginClassifier.CorrelationIdHeader, out var ids)) + response.Headers.TryAddWithoutValidation(ResponseOriginClassifier.CorrelationIdHeader, ids); + return response; }); - return new BackendApiClient( + + private static BackendApiClient BuildClient(HttpMessageHandler handler, ServerCertificateObserver? serverCertificates = null) => + new BackendApiClient( new HttpClient(handler), baseUrl: "https://backend.example", manufacturer: "Contoso", @@ -43,8 +61,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport useBootstrapTokenAuth: false, bootstrapToken: null, agentVersion: "2.0.0", - logger: null); - } + logger: null, + serverCertificates: serverCertificates); + + private static BackendApiClient BuildClient(HttpStatusCode status, string body, string mediaType, bool fromBackend = true) => + BuildClient(Answer(status, body, mediaType, fromBackend)); [Fact] public async Task Backend_json_403_is_reported_as_device_not_authorized() @@ -134,16 +155,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport public async Task Platform_html_403_is_reported_as_endpoint_unavailable_with_page_title() { using var client = BuildClient( - HttpStatusCode.Forbidden, - "Web App - Unavailablestopped", - "text/html"); + HttpStatusCode.Forbidden, InterceptionFixtures.StoppedAppPage, "text/html", fromBackend: false); var ex = await Assert.ThrowsAsync(() => client.GetAgentConfigAsync("tenant-1")); Assert.Equal(403, ex.StatusCode); Assert.True(ex.EndpointUnavailable); - Assert.Contains("Web App - Unavailable", ex.Message); + Assert.False(ex.IsForeignResponse); + Assert.Equal(ResponseOrigin.PlatformStopped, ex.Origin); + Assert.Contains("HTML platform error page (\"Web App - Unavailable\")", ex.Message); Assert.Contains("NOT a device-authorization failure", ex.Message); + Assert.Contains("stale ApiBaseUrl", ex.Message); Assert.DoesNotContain("Check client certificate", ex.Message); } @@ -153,7 +175,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport using var client = BuildClient( HttpStatusCode.Forbidden, " Error 403 - This web app is stopped.", - "text/plain"); + "text/plain", + fromBackend: false); var ex = await Assert.ThrowsAsync(() => client.GetAgentConfigAsync("tenant-1")); @@ -161,6 +184,113 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport Assert.Contains("HTML platform error page", ex.Message); } + // ── answered on the network path (TLS inspection, proxy) ───────────── + + [Fact] + public async Task Certificate_required_page_says_the_certificate_did_not_reach_the_backend() + { + using var client = BuildClient( + HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage, "text/html", fromBackend: false); + + var ex = await Assert.ThrowsAsync(() => client.GetAgentConfigAsync("tenant-1")); + + Assert.Equal(403, ex.StatusCode); + Assert.True(ex.IsForeignResponse); + Assert.False(ex.EndpointUnavailable); + Assert.Equal(ResponseClassification.ClientCertificateRequired, ex.Classification.ForeignReason); + Assert.Null(ex.ErrorCode); + // The cause leads: RegistrationFailureSummary.LastError keeps 256 characters. + Assert.StartsWith("The device certificate did not reach the backend (TLS inspection or a proxy on the network path)", ex.Message); + Assert.Contains("HTML platform error page (\"Web App - Unavailable\")", ex.Message); + Assert.True(ex.Message.IndexOf("Web App - Unavailable", StringComparison.Ordinal) < 256); + Assert.Contains("NOT a device-authorization failure", ex.Message); + Assert.DoesNotContain("stale ApiBaseUrl", ex.Message); + Assert.DoesNotContain("Check client certificate", ex.Message); + } + + [Fact] + public async Task Proxy_page_keeps_the_phrase_and_title_ANALYZE_NET_001_matches() + { + using var client = BuildClient( + HttpStatusCode.Forbidden, InterceptionFixtures.ProxyPage, "text/html", fromBackend: false); + + var ex = await Assert.ThrowsAsync( + () => client.GetDiagnosticsUploadUrlAsync("tenant-1", "session-1", "package.zip")); + + Assert.True(ex.IsForeignResponse); + Assert.Equal(ResponseClassification.ForeignResponse, ex.Classification.ForeignReason); + Assert.Contains("HTML platform error page (\"Example Gateway - Notification\")", ex.Message); + Assert.Contains("403", ex.Message); + Assert.DoesNotContain("stale ApiBaseUrl", ex.Message); + } + + [Fact] + public async Task Json_403_without_the_echo_is_foreign_and_its_errorCode_is_ignored() + { + // An errorCode in a body the backend did not author must not drive a session rotation. + using var client = BuildClient( + HttpStatusCode.Forbidden, + "{\"success\":false,\"errorCode\":\"session_owner_mismatch\"}", + "application/json", + fromBackend: false); + + var ex = await Assert.ThrowsAsync( + () => client.RegisterSessionAsync(new AutopilotMonitor.Shared.Models.SessionRegistration())); + + Assert.True(ex.IsForeignResponse); + Assert.Null(ex.ErrorCode); + Assert.Contains("a response without the backend's correlation echo", ex.Message); + } + + [Fact] + public async Task Foreign_answer_carries_the_observed_server_certificate_issuer() + { + var observer = new ServerCertificateObserver(); + using (var cert = ServerCertificateObserverTests.SelfSigned("CN=Example Inspection CA")) + { + observer.Validate(cert, System.Net.Security.SslPolicyErrors.None); + } + using var client = BuildClient( + Answer(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage, "text/html", fromBackend: false), + observer); + + var ex = await Assert.ThrowsAsync(() => client.GetAgentConfigAsync("tenant-1")); + + Assert.Equal("CN=Example Inspection CA", ex.Classification.ServerCertIssuer); + } + + // ── agent/error: a report the backend never received is not delivered ── + + [Theory] + [InlineData(HttpStatusCode.OK, true, true)] + [InlineData(HttpStatusCode.InternalServerError, true, true)] // unchanged: only the round-trip counts + [InlineData(HttpStatusCode.Forbidden, true, true)] // the backend answered + [InlineData(HttpStatusCode.Forbidden, false, false)] // answered on the network path + [InlineData(HttpStatusCode.Unauthorized, false, false)] + public async Task ReportAgentError_counts_as_delivered_unless_the_backend_never_answered( + HttpStatusCode status, bool fromBackend, bool expectedDelivered) + { + using var client = BuildClient(status, InterceptionFixtures.CertificateRequiredPage, "text/html", fromBackend); + + var delivered = await client.ReportAgentErrorAsync(new AgentErrorReport { TenantId = "tenant-1", SessionId = "session-1" }); + + Assert.Equal(expectedDelivered, delivered); + } + + [Fact] + public async Task Intercepted_emergency_report_rolls_its_reservation_back() + { + var handler = Answer(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage, "text/html", fromBackend: false); + using var client = BuildClient(handler); + var reporter = new EmergencyReporter(client, "session-1", "tenant-1", "2.0.0", logger: null); + + await reporter.TrySendAsync(AgentErrorType.ConfigFetchFailed, "first"); + await reporter.TrySendAsync(AgentErrorType.ConfigFetchFailed, "second"); + + // Not delivered → no dedup, no cooldown: the same key is attempted again. + Assert.Equal(2, handler.Calls); + } + [Fact] public async Task Backend_401_keeps_authorization_message_and_status() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderLoggingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderLoggingTests.cs index d9ec99fa..0db3eb4a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderLoggingTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderLoggingTests.cs @@ -156,6 +156,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport Assert.Contains("BackendTelemetryUploader: ingest PERMANENT", logContent); } + [Fact] + public async Task UploadBatch_logs_the_whole_interception_finding_at_warning() + { + // The old PERMANENT line carried the first 200 body characters, which ended before the + // platform's "Client Certificate Required" text. The INTERCEPTED line names the finding. + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + + var handler = new RecordingHttpMessageHandler(); + handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage); + using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + + var sut = new BackendTelemetryUploader( + httpClient: http, + baseUrl: "https://backend.test", + tenantId: "T1", + logger: logger); + + var result = await sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + Assert.True(result.IsIntercepted); + + Assert.True(handler.Captured[0].TryGetHeader("X-Correlation-ID", out var corr)); + var logContent = ReadLogs(tmp.Path); + Assert.Contains("BackendTelemetryUploader: ingest INTERCEPTED", logContent); + Assert.Contains($"corr={corr}", logContent); + Assert.Contains("status=403", logContent); + Assert.Contains("reason=client_certificate_required", logContent); + Assert.Contains($"pageTitle=\"{InterceptionFixtures.PlatformPageTitle}\"", logContent); + Assert.Contains("serverCertIssuer=\"(unknown)\"", logContent); + Assert.DoesNotContain("ingest PERMANENT", logContent); + } + [Fact] public async Task UploadBatch_empty_batch_no_log_no_network() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderTests.cs index 32cf0386..f06fdbcf 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderTests.cs @@ -4,7 +4,16 @@ using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Transport; using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Engine; using Newtonsoft.Json.Linq; using Xunit; using AutopilotMonitor.Shared.Models; @@ -51,7 +60,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport string? model = "Latitude", string? serial = "ABC123", string? bootstrap = null, - string? version = "11.0.0") + string? version = "11.0.0", + AuthFailureTracker? tracker = null, + ServerCertificateObserver? serverCertificates = null, + NetworkMetrics? networkMetrics = null) { Http = new HttpClient(Handler) { Timeout = TimeSpan.FromSeconds(30) }; Sut = new BackendTelemetryUploader( @@ -62,7 +74,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport model: model, serialNumber: serial, bootstrapToken: bootstrap, - agentVersion: version); + agentVersion: version, + authFailureTracker: tracker, + serverCertificates: serverCertificates, + networkMetrics: networkMetrics); } public void Dispose() @@ -245,6 +260,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport Assert.Null(result.ErrorReason); } + // ============================================================ 2xx without the correlation echo + + [Fact] + public async Task Ok_with_the_echo_is_a_success_and_not_counted() + { + var metrics = new NetworkMetrics(); + using var rig = new Rig(networkMetrics: metrics); + rig.Handler.QueueStatus(HttpStatusCode.OK); + + var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal(0, metrics.OkWithoutEchoCount); + } + + [Fact] + public async Task Ok_without_the_echo_is_still_a_success_and_only_counted() + { + // A 200 that did not come from the backend (e.g. a block page served with 200): the + // upload path behaves exactly as before — control signals are still read — and the + // only difference is the counter. + var metrics = new NetworkMetrics(); + using var tmp = new TempDirectory(); + var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, SystemClock.Instance, + new AgentLogger(System.IO.Path.Combine(tmp.Path, "logs"), AgentLogLevel.Info)); + tracker.RecordFailure(403, "agent/telemetry"); + using var rig = new Rig(tracker: tracker, networkMetrics: metrics); + rig.Handler.QueueForeign(HttpStatusCode.OK, "Example"); + rig.Handler.QueueForeign(HttpStatusCode.OK, "{\"deviceKillSignal\":true}", mediaType: "application/json"); + + var page = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + var signals = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(2) }, CancellationToken.None); + + Assert.True(page.Success); + Assert.Null(page.ErrorReason); + Assert.True(signals.Success); + Assert.True(signals.DeviceKillSignal); + Assert.Equal(0, tracker.ConsecutiveFailures); // a 2xx still resets the auth streak + Assert.Equal(2, metrics.OkWithoutEchoCount); + } + + [Fact] + public async Task Answers_other_than_2xx_never_count_as_ok_without_echo() + { + var metrics = new NetworkMetrics(); + using var rig = new Rig(networkMetrics: metrics); + rig.Handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage); + rig.Handler.QueueForeign(HttpStatusCode.ServiceUnavailable, "busy"); + + await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.Equal(0, metrics.OkWithoutEchoCount); + } + + [Fact] + public async Task The_count_reaches_the_agent_metrics_snapshot() + { + var metrics = new NetworkMetrics(); + using var rig = new Rig(networkMetrics: metrics); + rig.Handler.QueueForeign(HttpStatusCode.OK, "Example"); + await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + var sink = new FakeSignalIngressSink(); + using var tmp = new TempDirectory(); + var collector = new AgentSelfMetricsCollector( + sessionId: "S1", + tenantId: "T1", + post: new InformationalEventPost(sink, SystemClock.Instance), + networkMetrics: metrics, + logger: new AgentLogger(System.IO.Path.Combine(tmp.Path, "logs"), AgentLogLevel.Info), + agentVersion: "2.0.test"); + collector.CollectSafe(); + + var snapshot = (IDictionary)Assert.Single(sink.Posted, p => + p.TypedPayload is IDictionary d && d.ContainsKey("agent_version")).TypedPayload!; + Assert.Equal(1L, snapshot["telemetry_upload_ok_without_echo"]); + } + [Theory] [InlineData(HttpStatusCode.Unauthorized)] [InlineData(HttpStatusCode.Forbidden)] @@ -261,6 +355,104 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport Assert.Contains(((int)status).ToString(), result.ErrorReason); } + // ============================================================ 401/403 origin + + private sealed class OriginRig : IDisposable + { + private readonly TempDirectory _tmp = new TempDirectory(); + + public FakeDistressReporter Distress { get; } = new FakeDistressReporter(); + public AuthFailureTracker Tracker { get; } + public Rig Upload { get; } + + public OriginRig() + { + Tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, SystemClock.Instance, + new AgentLogger(_tmp.Path, AgentLogLevel.Info), Distress); + var serverCertificates = new ServerCertificateObserver(); + using (var cert = ServerCertificateObserverTests.SelfSigned("CN=Example Inspection CA")) + { + serverCertificates.Validate(cert, System.Net.Security.SslPolicyErrors.None); + } + Upload = new Rig(tracker: Tracker, serverCertificates: serverCertificates); + } + + public Task UploadAsync() => Upload.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + public void Dispose() + { + Upload.Dispose(); + _tmp.Dispose(); + } + } + + [Fact] + public async Task Backend_403_is_an_auth_failure_that_counts_and_reports_DeviceNotRegistered() + { + using var rig = new OriginRig(); + rig.Upload.Handler.QueueStatus(HttpStatusCode.Forbidden, body: "{\"error\":\"Device validation failed\"}"); + + var result = await rig.UploadAsync(); + + Assert.True(result.IsAuthFailure); + Assert.False(result.IsIntercepted); + Assert.Equal(1, rig.Tracker.ConsecutiveFailures); + Assert.Equal(DistressErrorType.DeviceNotRegistered, Assert.Single(rig.Distress.Calls).ErrorType); + } + + [Fact] + public async Task Stopped_app_403_counts_like_an_auth_failure_without_distress() + { + using var rig = new OriginRig(); + rig.Upload.Handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.StoppedAppPage); + + var result = await rig.UploadAsync(); + + Assert.True(result.IsAuthFailure); + Assert.False(result.IsIntercepted); + Assert.Equal(1, rig.Tracker.ConsecutiveFailures); + Assert.Empty(rig.Distress.Calls); + } + + [Fact] + public async Task Certificate_required_403_is_intercepted_never_counted_and_reported_once() + { + using var rig = new OriginRig(); + rig.Upload.Handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage); + rig.Upload.Handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage); + + var result = await rig.UploadAsync(); + await rig.UploadAsync(); + + Assert.False(result.Success); + Assert.True(result.IsIntercepted); + Assert.False(result.IsAuthFailure); + Assert.False(result.IsTransient); + Assert.Equal(403, result.Interception!.StatusCode); + Assert.Equal(ResponseClassification.ClientCertificateRequired, result.Interception.ForeignReason); + Assert.Equal(InterceptionFixtures.PlatformPageTitle, result.Interception.PageTitle); + Assert.Equal("CN=Example Inspection CA", result.Interception.ServerCertIssuer); + Assert.Equal(0, rig.Tracker.ConsecutiveFailures); + var distress = Assert.Single(rig.Distress.Calls); + Assert.Equal(DistressErrorType.ClientCertificateNotReceived, distress.ErrorType); + Assert.Contains("agent/telemetry http 403 client_certificate_required", distress.Message); + Assert.Contains("CN=Example Inspection CA", distress.Message); + } + + [Fact] + public async Task Proxy_page_403_is_intercepted_as_foreign_response() + { + using var rig = new OriginRig(); + rig.Upload.Handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.ProxyPage); + + var result = await rig.UploadAsync(); + + Assert.True(result.IsIntercepted); + Assert.Equal(ResponseClassification.ForeignResponse, result.Interception!.ForeignReason); + Assert.Equal(InterceptionFixtures.ProxyPageTitle, result.Interception.PageTitle); + Assert.Equal(0, rig.Tracker.ConsecutiveFailures); + } + [Theory] [InlineData(HttpStatusCode.BadRequest)] // 400 [InlineData(HttpStatusCode.NotFound)] // 404 diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/FakeBackendTelemetryUploader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/FakeBackendTelemetryUploader.cs index e76601ef..ac8c7ec0 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/FakeBackendTelemetryUploader.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/FakeBackendTelemetryUploader.cs @@ -74,6 +74,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport return this; } + /// Queues a 403 the platform's "Client Certificate Required" page answered (an interception). + public FakeBackendTelemetryUploader QueueIntercepted(int times = 1) + { + for (int i = 0; i < times; i++) + { + _script.Enqueue(_ => UploadResult.Intercepted( + "intercepted: http 403 (client_certificate_required)", InterceptionFixtures.CertificateRequired())); + } + return this; + } + public FakeBackendTelemetryUploader QueueThrow(Exception ex) { _script.Enqueue(_ => throw ex); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/InterceptionFixtures.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/InterceptionFixtures.cs new file mode 100644 index 00000000..c1cffac0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/InterceptionFixtures.cs @@ -0,0 +1,48 @@ +using AutopilotMonitor.Agent.V2.Core.Transport; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport +{ + /// + /// Synthetic copies of the pages an agent gets instead of a backend answer: the Azure platform + /// page when no client certificate arrived, the platform page of a stopped app, and a proxy's + /// block page. Rebuilt from the observed structure and wording; none of them is a captured + /// artifact. The style block keeps the platform marker text past the first 200 characters, where + /// the old upload log excerpt ended. + /// + internal static class InterceptionFixtures + { + public const string PlatformPageTitle = "Web App - Unavailable"; + public const string ProxyPageTitle = "Example Gateway - Notification"; + public const string InspectionIssuer = "CN=Example Inspection CA, O=Example Security Ltd, C=US"; + + private const string PlatformPageHead = + "\r\n" + + "\r\n\r\nWeb App - Unavailable\r\n" + + "\r\n\r\n\r\n\r\n"; + + public const string CertificateRequiredPage = + PlatformPageHead + + "Error 403 - Forbidden: Client Certificate Required.\r\n" + + "The web app you have attempted to reach requires a client certificate and does not accept any requests without one.\r\n" + + "\r\n\r\n\r\n"; + + public const string StoppedAppPage = + PlatformPageHead + + "Error 403 - This web app is stopped.\r\n" + + "The web app you have attempted to reach is currently stopped and does not accept any requests.\r\n" + + "\r\n
The web app you have attempted to reach requires a client certificate and does not accept any requests without one.
The web app you have attempted to reach is currently stopped and does not accept any requests.