diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs index 34263a27..4cc5fa35 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs @@ -1,13 +1,12 @@ -using System.Diagnostics; using AutopilotMonitor.Functions.Security; namespace AutopilotMonitor.Functions.Tests; /// /// Tests for — focused on pure-function pieces: -/// JSON-to-DTO mapping (incl. exact-match guard), cache key shape, and Activity-tag -/// enrichment used by SecurityValidator. The HTTP/cache/retry resilience is mirrored -/// 1:1 from AutopilotDeviceValidator; behavioural drift would surface here. +/// JSON-to-DTO mapping (incl. exact-match guard) and cache key shape. The HTTP/cache/retry +/// resilience is mirrored 1:1 from AutopilotDeviceValidator; behavioural drift +/// would surface here. /// public class DeviceAssociationValidatorTests { @@ -128,54 +127,4 @@ public class DeviceAssociationValidatorTests Assert.True(result.IsValid); } - // -- EnrichRequestTelemetryWithDeviceAssociation -- - - [Fact] - public void Enrich_PopulatesActivityTags_WhenMatched() - { - using var activity = new Activity("test").Start(); - var result = new DeviceAssociationResult - { - IsValid = true, - IsTransient = false, - AssociationState = "preassociated", - DevicePreparationPolicyId = "policy-1", - PreAssociationDateTime = new DateTime(2026, 4, 14, 12, 0, 0, DateTimeKind.Utc) - }; - - SecurityValidator.EnrichRequestTelemetryWithDeviceAssociation(result); - - var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value); - Assert.Equal("true", tags["devprep.association.matched"]); - Assert.Equal("false", tags["devprep.association.transient"]); - Assert.Equal("preassociated", tags["devprep.association.state"]); - Assert.Equal("policy-1", tags["devprep.association.policyId"]); - Assert.Contains("devprep.association.preAssociationUtc", tags.Keys); - } - - [Fact] - public void Enrich_OnlyMatchedAndTransient_WhenNoOtherFields() - { - using var activity = new Activity("test").Start(); - var result = new DeviceAssociationResult { IsValid = false, IsTransient = true }; - - SecurityValidator.EnrichRequestTelemetryWithDeviceAssociation(result); - - var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value); - Assert.Equal("false", tags["devprep.association.matched"]); - Assert.Equal("true", tags["devprep.association.transient"]); - Assert.DoesNotContain("devprep.association.state", tags.Keys); - Assert.DoesNotContain("devprep.association.policyId", tags.Keys); - } - - [Fact] - public void Enrich_NoActivity_DoesNotThrow() - { - // Activity.Current is null in environments where no listener registered the activity source. - // Must be a safe no-op. - Activity.Current = null; - var result = new DeviceAssociationResult { IsValid = true }; - var ex = Record.Exception(() => SecurityValidator.EnrichRequestTelemetryWithDeviceAssociation(result)); - Assert.Null(ex); - } } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index 32fd507c..3c745c2e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Linq; using System.Net; using System.Text.RegularExpressions; @@ -364,25 +363,34 @@ namespace AutopilotMonitor.Functions.Security } // 5. DevPrep "Device association" lookup (shadow mode — does NOT gate enrollment). - // Runs only when the tenant has opted in and the existing validators have already - // authorized the device. Result is attached to the request telemetry (App Insights) - // via Activity tags so we can observe DevPrep readiness without breaking enrollment. + // Fire-and-forget: kicked off as a detached Task so it never adds latency to the + // RegisterSession/Ingest response. Graph round-trips for tenantAssociatedDevices can + // take 200ms–30s (504 timeouts observed), and shadow mode must never block the agent. + // Result is logged at Warning level so it surfaces in App Insights traces. if (config.ValidateDeviceAssociation && _deviceAssociationValidator != null && !string.IsNullOrEmpty(serialNumber)) { - try + var validator = _deviceAssociationValidator; + var capturedTenant = tenantId; + var capturedSerial = serialNumber; + var capturedSession = sessionId; + var logger = _logger; + + _ = Task.Run(async () => { - var devPrepResult = await _deviceAssociationValidator.LookupAsync(tenantId, serialNumber, sessionId); - EnrichRequestTelemetryWithDeviceAssociation(devPrepResult); - _logger.LogInformation( - "DevPrep association lookup (shadow) for tenant {TenantId}, serial {SerialNumber}: matched={Matched}, transient={Transient}, state={State}, policy={PolicyId}", - tenantId, serialNumber, devPrepResult.IsValid, devPrepResult.IsTransient, - devPrepResult.AssociationState ?? "", devPrepResult.DevicePreparationPolicyId ?? ""); - } - catch (Exception ex) - { - // Shadow mode must never break the enrollment flow — log and move on. - _logger.LogWarning(ex, "DevPrep association lookup (shadow) failed for tenant {TenantId}, serial {SerialNumber} — ignored.", tenantId, serialNumber); - } + try + { + var devPrepResult = await validator.LookupAsync(capturedTenant, capturedSerial, capturedSession); + logger.LogWarning( + "DevPrep association lookup (shadow) for tenant {TenantId}, session {SessionId}, serial {SerialNumber}: matched={Matched}, transient={Transient}, state={State}, policy={PolicyId}", + capturedTenant, capturedSession ?? "", capturedSerial, + devPrepResult.IsValid, devPrepResult.IsTransient, + devPrepResult.AssociationState ?? "", devPrepResult.DevicePreparationPolicyId ?? ""); + } + catch (Exception ex) + { + logger.LogWarning(ex, "DevPrep association lookup (shadow) failed for tenant {TenantId}, serial {SerialNumber} — ignored.", capturedTenant, capturedSerial); + } + }); } // All checks passed @@ -399,26 +407,6 @@ namespace AutopilotMonitor.Functions.Security }; } - /// - /// Attaches DevPrep association lookup outcome to the current Activity so it surfaces - /// in Application Insights request telemetry as customDimensions. - /// Centralised here so the SecurityValidator's call-site stays small. - /// - internal static void EnrichRequestTelemetryWithDeviceAssociation(DeviceAssociationResult result) - { - var activity = Activity.Current; - if (activity == null) return; - - activity.AddTag("devprep.association.matched", result.IsValid ? "true" : "false"); - activity.AddTag("devprep.association.transient", result.IsTransient ? "true" : "false"); - - if (!string.IsNullOrEmpty(result.AssociationState)) - activity.AddTag("devprep.association.state", result.AssociationState); - if (!string.IsNullOrEmpty(result.DevicePreparationPolicyId)) - activity.AddTag("devprep.association.policyId", result.DevicePreparationPolicyId); - if (result.PreAssociationDateTime.HasValue) - activity.AddTag("devprep.association.preAssociationUtc", result.PreAssociationDateTime.Value.ToString("O")); - } } ///