From 35f12ceb0d6524814a5a20e50fc4975250d25f98 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 13:38:10 -0600 Subject: [PATCH] Allow NVRs on Management VLAN in security audit NVRs (UNVR, UNVR-Pro, Cloud Key) are infrastructure devices that legitimately belong on Management or Security VLANs. Previously all Protect devices were classified as cameras and flagged if not on the Security VLAN, causing false positives for dual-homed NVRs. Closes #268 --- .../Rules/CameraVlanRule.cs | 9 +- .../Rules/VlanPlacementChecker.cs | 47 ++++- .../Rules/WirelessCameraVlanRule.cs | 11 +- .../Services/DeviceTypeDetectionService.cs | 22 +- .../Models/ProtectCamera.cs | 24 ++- src/NetworkOptimizer.UniFi/UniFiApiClient.cs | 2 +- .../Services/AuditService.cs | 8 +- .../Rules/CameraVlanRuleTests.cs | 194 ++++++++++++++++++ .../Rules/WirelessCameraVlanRuleTests.cs | 72 +++++++ .../DeviceTypeDetectionServiceTests.cs | 100 +++++++++ 10 files changed, 461 insertions(+), 28 deletions(-) diff --git a/src/NetworkOptimizer.Audit/Rules/CameraVlanRule.cs b/src/NetworkOptimizer.Audit/Rules/CameraVlanRule.cs index ae33eae0a..8c8453fe0 100644 --- a/src/NetworkOptimizer.Audit/Rules/CameraVlanRule.cs +++ b/src/NetworkOptimizer.Audit/Rules/CameraVlanRule.cs @@ -71,8 +71,11 @@ public class CameraVlanRule : AuditRuleBase if (network == null) return null; + // Check if this is an NVR (allowed on Management VLAN) + var isNvr = detection.Metadata?.ContainsKey("is_nvr") == true; + // Check placement using shared logic - var placement = VlanPlacementChecker.CheckCameraPlacement(network, networks, ScoreImpact); + var placement = VlanPlacementChecker.CheckCameraPlacement(network, networks, ScoreImpact, isNvr: isNvr); if (placement.IsCorrectlyPlaced) return null; @@ -159,7 +162,9 @@ public class CameraVlanRule : AuditRuleBase } } - var message = $"{detection.CategoryName} on {network.Name} VLAN - should be on security VLAN"; + var message = isNvr + ? $"NVR on {network.Name} VLAN - should be on management or security VLAN" + : $"{detection.CategoryName} on {network.Name} VLAN - should be on security VLAN"; return new AuditIssue { diff --git a/src/NetworkOptimizer.Audit/Rules/VlanPlacementChecker.cs b/src/NetworkOptimizer.Audit/Rules/VlanPlacementChecker.cs index 6103a45de..100d86616 100644 --- a/src/NetworkOptimizer.Audit/Rules/VlanPlacementChecker.cs +++ b/src/NetworkOptimizer.Audit/Rules/VlanPlacementChecker.cs @@ -201,34 +201,65 @@ public static PlacementResult CheckPrinterPlacement( /// /// Check if a camera/surveillance device is correctly placed on a Security VLAN. + /// NVRs are also accepted on Management VLANs since they are infrastructure devices. /// /// The network the device is currently on /// All available networks /// Default score impact (cameras are always high-risk) + /// Whether this device is an NVR (allowed on Management VLAN) /// Placement result with recommendation public static PlacementResult CheckCameraPlacement( NetworkInfo? currentNetwork, List allNetworks, - int defaultScoreImpact = 8) + int defaultScoreImpact = 8, + bool isNvr = false) { // Cameras should only be on Security networks - var isCorrectlyPlaced = currentNetwork?.Purpose == NetworkPurpose.Security; + // NVRs are also accepted on Management VLANs (they are infrastructure devices) + var isCorrectlyPlaced = currentNetwork?.Purpose == NetworkPurpose.Security + || (isNvr && currentNetwork?.Purpose == NetworkPurpose.Management); - // Find the Security network to recommend (prefer lower VLAN number) + // Find networks to recommend var securityNetwork = allNetworks .Where(n => n.Purpose == NetworkPurpose.Security) .OrderBy(n => n.VlanId) .FirstOrDefault(); - var recommendedLabel = securityNetwork != null - ? $"{securityNetwork.Name} ({securityNetwork.VlanId})" - : "Security VLAN"; + string recommendedLabel; + NetworkInfo? recommendedNetwork; - // Cameras are always high-risk - always Critical severity + if (isNvr) + { + // For NVRs, recommend Management VLAN as the primary target + var managementNetwork = allNetworks + .Where(n => n.Purpose == NetworkPurpose.Management) + .OrderBy(n => n.VlanId) + .FirstOrDefault(); + + recommendedNetwork = managementNetwork ?? securityNetwork; + + if (managementNetwork != null && securityNetwork != null) + recommendedLabel = $"{managementNetwork.Name} ({managementNetwork.VlanId}) or {securityNetwork.Name} ({securityNetwork.VlanId})"; + else if (managementNetwork != null) + recommendedLabel = $"{managementNetwork.Name} ({managementNetwork.VlanId})"; + else if (securityNetwork != null) + recommendedLabel = $"{securityNetwork.Name} ({securityNetwork.VlanId})"; + else + recommendedLabel = "Management or Security VLAN"; + } + else + { + recommendedNetwork = securityNetwork; + recommendedLabel = securityNetwork != null + ? $"{securityNetwork.Name} ({securityNetwork.VlanId})" + : "Security VLAN"; + } + + // Cameras and NVRs are always high-risk - always Critical severity return new PlacementResult( IsCorrectlyPlaced: isCorrectlyPlaced, IsLowRisk: false, - RecommendedNetwork: securityNetwork, + RecommendedNetwork: recommendedNetwork, RecommendedNetworkLabel: recommendedLabel, Severity: AuditSeverity.Critical, ScoreImpact: defaultScoreImpact); diff --git a/src/NetworkOptimizer.Audit/Rules/WirelessCameraVlanRule.cs b/src/NetworkOptimizer.Audit/Rules/WirelessCameraVlanRule.cs index dc2fb572a..d3b60d4d9 100644 --- a/src/NetworkOptimizer.Audit/Rules/WirelessCameraVlanRule.cs +++ b/src/NetworkOptimizer.Audit/Rules/WirelessCameraVlanRule.cs @@ -33,14 +33,21 @@ public class WirelessCameraVlanRule : WirelessAuditRuleBase if (network == null) return null; + // Check if this is an NVR (allowed on Management VLAN) + var isNvr = client.Detection.Metadata?.ContainsKey("is_nvr") == true; + // Check placement using shared logic - var placement = VlanPlacementChecker.CheckCameraPlacement(network, networks, ScoreImpact); + var placement = VlanPlacementChecker.CheckCameraPlacement(network, networks, ScoreImpact, isNvr: isNvr); if (placement.IsCorrectlyPlaced) return null; + var message = isNvr + ? $"NVR on {network.Name} VLAN - should be on management or security VLAN" + : $"{client.Detection.CategoryName} on {network.Name} VLAN - should be on security VLAN"; + return CreateIssue( - $"{client.Detection.CategoryName} on {network.Name} VLAN - should be on security VLAN", + message, client, recommendedNetwork: placement.RecommendedNetwork?.Name, recommendedVlan: placement.RecommendedNetwork?.VlanId, diff --git a/src/NetworkOptimizer.Audit/Services/DeviceTypeDetectionService.cs b/src/NetworkOptimizer.Audit/Services/DeviceTypeDetectionService.cs index 7516445f5..ec3492c26 100644 --- a/src/NetworkOptimizer.Audit/Services/DeviceTypeDetectionService.cs +++ b/src/NetworkOptimizer.Audit/Services/DeviceTypeDetectionService.cs @@ -144,8 +144,19 @@ private DeviceDetectionResult DetectDeviceTypeCore( if (_protectCameras != null && !string.IsNullOrEmpty(client?.Mac) && _protectCameras.TryGetName(client.Mac, out var protectCameraName)) { - _logger?.LogDebug("[Detection] '{DisplayName}': UniFi Protect device '{CameraName}' (confirmed by controller)", - displayName, protectCameraName); + var isNvr = _protectCameras.IsNvr(client.Mac); + _logger?.LogDebug("[Detection] '{DisplayName}': UniFi Protect {DeviceType} '{CameraName}' (confirmed by controller)", + displayName, isNvr ? "NVR" : "device", protectCameraName); + var metadata = new Dictionary + { + ["detection_method"] = "unifi_protect_api", + ["mac"] = client.Mac, + ["protect_name"] = protectCameraName ?? "" + }; + if (isNvr) + { + metadata["is_nvr"] = true; + } return new DeviceDetectionResult { Category = ClientDeviceCategory.Camera, // All Protect security devices use Camera category for VLAN rules @@ -154,12 +165,7 @@ private DeviceDetectionResult DetectDeviceTypeCore( VendorName = "Ubiquiti", ProductName = protectCameraName ?? "UniFi Protect", RecommendedNetwork = NetworkPurpose.Security, - Metadata = new Dictionary - { - ["detection_method"] = "unifi_protect_api", - ["mac"] = client.Mac, - ["protect_name"] = protectCameraName ?? "" - } + Metadata = metadata }; } diff --git a/src/NetworkOptimizer.Core/Models/ProtectCamera.cs b/src/NetworkOptimizer.Core/Models/ProtectCamera.cs index 97c5ca474..1ad5be60a 100644 --- a/src/NetworkOptimizer.Core/Models/ProtectCamera.cs +++ b/src/NetworkOptimizer.Core/Models/ProtectCamera.cs @@ -22,11 +22,17 @@ public sealed record ProtectCamera /// public string? ConnectionNetworkId { get; init; } + /// + /// Whether this device is an NVR (UNVR, UNVR-Pro, Cloud Key). + /// NVRs are infrastructure devices that can legitimately be on Management or Security VLANs. + /// + public bool IsNvr { get; init; } + /// /// Create a ProtectCamera from MAC and name /// - public static ProtectCamera Create(string mac, string name, string? connectionNetworkId = null) - => new() { Mac = mac.ToLowerInvariant(), Name = name, ConnectionNetworkId = connectionNetworkId }; + public static ProtectCamera Create(string mac, string name, string? connectionNetworkId = null, bool isNvr = false) + => new() { Mac = mac.ToLowerInvariant(), Name = name, ConnectionNetworkId = connectionNetworkId, IsNvr = isNvr }; } /// @@ -60,9 +66,9 @@ public void Add(string mac, string name) /// /// Add a camera by MAC, name, and connection network ID /// - public void Add(string mac, string name, string? connectionNetworkId) + public void Add(string mac, string name, string? connectionNetworkId, bool isNvr = false) { - Add(ProtectCamera.Create(mac, name, connectionNetworkId)); + Add(ProtectCamera.Create(mac, name, connectionNetworkId, isNvr)); } /// @@ -119,6 +125,16 @@ public bool TryGetNetworkId(string? mac, out string? networkId) return false; } + /// + /// Check if a MAC address belongs to an NVR device + /// + public bool IsNvr(string? mac) + { + if (string.IsNullOrEmpty(mac)) + return false; + return _cameras.TryGetValue(mac, out var camera) && camera.IsNvr; + } + /// /// Get all cameras in the collection /// diff --git a/src/NetworkOptimizer.UniFi/UniFiApiClient.cs b/src/NetworkOptimizer.UniFi/UniFiApiClient.cs index 6b31efd71..f6119ccd3 100644 --- a/src/NetworkOptimizer.UniFi/UniFiApiClient.cs +++ b/src/NetworkOptimizer.UniFi/UniFiApiClient.cs @@ -678,7 +678,7 @@ public async Task GetProtectCamerasAsync(CancellationTo if (device.RequiresSecurityVlan) { var name = !string.IsNullOrEmpty(device.Name) ? device.Name : device.Model ?? "Protect Device"; - result.Add(device.Mac, name, device.ConnectionNetworkId); + result.Add(device.Mac, name, device.ConnectionNetworkId, device.IsNvr); var deviceType = device.IsCamera ? "camera" : device.IsDoorbell ? "doorbell" : diff --git a/src/NetworkOptimizer.Web/Services/AuditService.cs b/src/NetworkOptimizer.Web/Services/AuditService.cs index 120d1a8bf..6d88ff17e 100644 --- a/src/NetworkOptimizer.Web/Services/AuditService.cs +++ b/src/NetworkOptimizer.Web/Services/AuditService.cs @@ -1591,9 +1591,11 @@ private static string GetIssueTitle(string type, string message, Audit.Models.Au ? "IoT Device Allowed on VLAN" : (isInformational ? "IoT Device Possibly on Wrong VLAN" : "IoT Device on Wrong VLAN"), Audit.IssueTypes.CameraVlan or Audit.IssueTypes.WifiCameraVlan or "OFFLINE-CAMERA-VLAN" or "OFFLINE-CLOUD-CAMERA-VLAN" => - message.StartsWith("Security System") - ? (isInformational ? "Security System Possibly on Wrong VLAN" : "Security System on Wrong VLAN") - : (isInformational ? "Camera Possibly on Wrong VLAN" : "Camera on Wrong VLAN"), + message.StartsWith("NVR") + ? (isInformational ? "NVR Possibly on Wrong VLAN" : "NVR on Wrong VLAN") + : message.StartsWith("Security System") + ? (isInformational ? "Security System Possibly on Wrong VLAN" : "Security System on Wrong VLAN") + : (isInformational ? "Camera Possibly on Wrong VLAN" : "Camera on Wrong VLAN"), Audit.IssueTypes.InfraNotOnMgmt => "Infrastructure Device on Wrong VLAN", // Port security diff --git a/tests/NetworkOptimizer.Audit.Tests/Rules/CameraVlanRuleTests.cs b/tests/NetworkOptimizer.Audit.Tests/Rules/CameraVlanRuleTests.cs index 12f306f19..7e64bcd84 100644 --- a/tests/NetworkOptimizer.Audit.Tests/Rules/CameraVlanRuleTests.cs +++ b/tests/NetworkOptimizer.Audit.Tests/Rules/CameraVlanRuleTests.cs @@ -1314,4 +1314,198 @@ public void Evaluate_CameraOnCorporateVlan_MessageStartsWithCamera() } #endregion + + #region NVR Tests - NVRs Allowed on Management VLAN + + [Fact] + public void Evaluate_ProtectNvr_OnManagementVlan_ReturnsNull() + { + // Arrange - NVR on Management VLAN should pass (NVRs are infrastructure devices) + var mgmtNetwork = new NetworkInfo { Id = "mgmt-net", Name = "Management", VlanId = 5, Purpose = NetworkPurpose.Management }; + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "UNVR-Pro", null, isNvr: true); + _detectionService.SetProtectCameras(protectCameras); + + var port = CreateProtectPort("00:11:22:33:44:55", mgmtNetwork, "Server Rack Switch"); + var networks = CreateNetworkList(mgmtNetwork); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert - NVR correctly placed on Management VLAN + result.Should().BeNull(); + } + + [Fact] + public void Evaluate_ProtectNvr_OnSecurityVlan_ReturnsNull() + { + // Arrange - NVR on Security VLAN should also pass + var securityNetwork = new NetworkInfo { Id = "sec-net", Name = "Security", VlanId = 30, Purpose = NetworkPurpose.Security }; + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "UNVR", null, isNvr: true); + _detectionService.SetProtectCameras(protectCameras); + + var port = CreateProtectPort("00:11:22:33:44:55", securityNetwork, "Camera Switch"); + var networks = CreateNetworkList(securityNetwork); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert - NVR correctly placed on Security VLAN + result.Should().BeNull(); + } + + [Fact] + public void Evaluate_ProtectNvr_OnCorporateVlan_ReturnsCriticalIssue() + { + // Arrange - NVR on Corporate VLAN should be flagged + var corpNetwork = new NetworkInfo { Id = "corp-net", Name = "Corporate", VlanId = 10, Purpose = NetworkPurpose.Corporate }; + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "UNVR-Pro", null, isNvr: true); + _detectionService.SetProtectCameras(protectCameras); + + var port = CreateProtectPort("00:11:22:33:44:55", corpNetwork, "Office Switch"); + var networks = CreateNetworkList(corpNetwork); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert - NVR on wrong VLAN + result.Should().NotBeNull(); + result!.Severity.Should().Be(AuditSeverity.Critical); + } + + [Fact] + public void Evaluate_ProtectNvr_OnIoTVlan_ReturnsCriticalIssue() + { + // Arrange - NVR on IoT VLAN should be flagged + var iotNetwork = new NetworkInfo { Id = "iot-net", Name = "IoT", VlanId = 40, Purpose = NetworkPurpose.IoT }; + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "Cloud Key Gen2 Plus", null, isNvr: true); + _detectionService.SetProtectCameras(protectCameras); + + var port = CreateProtectPort("00:11:22:33:44:55", iotNetwork, "Test Switch"); + var networks = CreateNetworkList(iotNetwork); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert - NVR on wrong VLAN + result.Should().NotBeNull(); + result!.Severity.Should().Be(AuditSeverity.Critical); + } + + [Fact] + public void Evaluate_ProtectNvr_OnGuestVlan_ReturnsCriticalIssue() + { + // Arrange - NVR on Guest VLAN should be flagged + var guestNetwork = new NetworkInfo { Id = "guest-net", Name = "Guest", VlanId = 50, Purpose = NetworkPurpose.Guest }; + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "UNVR", null, isNvr: true); + _detectionService.SetProtectCameras(protectCameras); + + var port = CreateProtectPort("00:11:22:33:44:55", guestNetwork, "Test Switch"); + var networks = CreateNetworkList(guestNetwork); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert - NVR on wrong VLAN + result.Should().NotBeNull(); + result!.Severity.Should().Be(AuditSeverity.Critical); + } + + [Fact] + public void Evaluate_ProtectNvr_IssueMessageStartsWithNvr() + { + // Arrange - NVR issue message should start with "NVR" for correct UI title mapping + var corpNetwork = new NetworkInfo { Id = "corp-net", Name = "Corporate", VlanId = 10, Purpose = NetworkPurpose.Corporate }; + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "UNVR-Pro", null, isNvr: true); + _detectionService.SetProtectCameras(protectCameras); + + var port = CreateProtectPort("00:11:22:33:44:55", corpNetwork, "Office Switch"); + var networks = CreateNetworkList(corpNetwork); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert - Message must start with "NVR" for correct UI title + result.Should().NotBeNull(); + result!.Message.Should().StartWith("NVR"); + result.Message.Should().Contain("management or security"); + } + + [Fact] + public void Evaluate_ProtectNvr_RecommendsManagementOrSecurity() + { + // Arrange - NVR recommendation should mention both Management and Security VLANs + var corpNetwork = new NetworkInfo { Id = "corp-net", Name = "Corporate", VlanId = 10, Purpose = NetworkPurpose.Corporate }; + var mgmtNetwork = new NetworkInfo { Id = "mgmt-net", Name = "Management", VlanId = 5, Purpose = NetworkPurpose.Management }; + var securityNetwork = new NetworkInfo { Id = "sec-net", Name = "Security", VlanId = 30, Purpose = NetworkPurpose.Security }; + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "UNVR", null, isNvr: true); + _detectionService.SetProtectCameras(protectCameras); + + var port = CreateProtectPort("00:11:22:33:44:55", corpNetwork, "Office Switch"); + var networks = new List { corpNetwork, mgmtNetwork, securityNetwork }; + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert - Recommendation should mention both networks + result.Should().NotBeNull(); + result!.RecommendedAction.Should().Contain("Management"); + result.RecommendedAction.Should().Contain("Security"); + } + + [Fact] + public void Evaluate_ProtectCamera_StillFlaggedOnManagementVlan() + { + // Arrange - Regular cameras should NOT be allowed on Management VLAN (regression guard) + var mgmtNetwork = new NetworkInfo { Id = "mgmt-net", Name = "Management", VlanId = 5, Purpose = NetworkPurpose.Management }; + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "G4 Pro"); // Not an NVR + _detectionService.SetProtectCameras(protectCameras); + + var port = CreateProtectPort("00:11:22:33:44:55", mgmtNetwork, "Test Switch"); + var networks = CreateNetworkList(mgmtNetwork); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert - Regular camera should still be flagged on Management VLAN + result.Should().NotBeNull(); + result!.Severity.Should().Be(AuditSeverity.Critical); + result.Message.Should().NotStartWith("NVR"); + } + + /// + /// Create a port with a Protect device connected (for NVR tests) + /// + private static PortInfo CreateProtectPort(string mac, NetworkInfo network, string switchName) + { + var switchInfo = new SwitchInfo { Name = switchName, Model = "USW-24", Type = "usw" }; + var connectedClient = new UniFiClientResponse + { + Mac = mac, + Name = string.Empty, + Hostname = string.Empty, + IsWired = true, + NetworkId = network.Id + }; + + return new PortInfo + { + PortIndex = 1, + Name = "Port 1", + IsUp = true, + ForwardMode = "native", + NativeNetworkId = network.Id, + Switch = switchInfo, + ConnectedClient = connectedClient + }; + } + + #endregion } diff --git a/tests/NetworkOptimizer.Audit.Tests/Rules/WirelessCameraVlanRuleTests.cs b/tests/NetworkOptimizer.Audit.Tests/Rules/WirelessCameraVlanRuleTests.cs index f777237fc..fabb89a9d 100644 --- a/tests/NetworkOptimizer.Audit.Tests/Rules/WirelessCameraVlanRuleTests.cs +++ b/tests/NetworkOptimizer.Audit.Tests/Rules/WirelessCameraVlanRuleTests.cs @@ -323,6 +323,78 @@ private static List CreateNetworkList(params NetworkInfo[] networks #endregion + #region NVR Tests - NVRs Allowed on Management VLAN + + [Fact] + public void Evaluate_WirelessNvr_OnManagementVlan_ReturnsNull() + { + // Arrange - Wireless NVR (e.g., Cloud Key on WiFi) on Management VLAN should pass + var mgmtNetwork = new NetworkInfo { Id = "mgmt-net", Name = "Management", VlanId = 5, Purpose = NetworkPurpose.Management }; + var client = CreateWirelessNvrClient(network: mgmtNetwork); + var networks = CreateNetworkList(mgmtNetwork); + + // Act + var result = _rule.Evaluate(client, networks); + + // Assert - NVR correctly placed on Management VLAN + result.Should().BeNull(); + } + + [Fact] + public void Evaluate_WirelessNvr_OnCorporateVlan_ReturnsCriticalIssue() + { + // Arrange - Wireless NVR on Corporate VLAN should be flagged + var corpNetwork = new NetworkInfo { Id = "corp-net", Name = "Corporate", VlanId = 10, Purpose = NetworkPurpose.Corporate }; + var client = CreateWirelessNvrClient(network: corpNetwork); + var networks = CreateNetworkList(corpNetwork); + + // Act + var result = _rule.Evaluate(client, networks); + + // Assert - NVR on wrong VLAN + result.Should().NotBeNull(); + result!.Severity.Should().Be(AuditSeverity.Critical); + result.Message.Should().StartWith("NVR"); + } + + private static WirelessClientInfo CreateWirelessNvrClient( + NetworkInfo? network = null, + string clientName = "Cloud Key Gen2", + string clientMac = "00:11:22:33:44:55") + { + var client = new UniFiClientResponse + { + Mac = clientMac, + Name = clientName, + IsWired = false, + NetworkId = network?.Id ?? string.Empty + }; + + var detection = new DeviceDetectionResult + { + Category = ClientDeviceCategory.Camera, + Source = DetectionSource.UniFiFingerprint, + ConfidenceScore = 100, + VendorName = "Ubiquiti", + ProductName = clientName, + Metadata = new Dictionary + { + ["detection_method"] = "unifi_protect_api", + ["is_nvr"] = true + } + }; + + return new WirelessClientInfo + { + Client = client, + Network = network, + Detection = detection, + AccessPointName = "AP-Test" + }; + } + + #endregion + #region Message Format Tests - For UI Title Generation [Fact] diff --git a/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs b/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs index 45e0f3d66..bd809853f 100644 --- a/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs +++ b/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs @@ -2338,4 +2338,104 @@ public void DetectDeviceType_UniFiProtect_AIKey_StillDetectedViaAPI() } #endregion + + #region NVR Detection Metadata Tests + + [Fact] + public void DetectDeviceType_ProtectNvr_SetsIsNvrMetadata() + { + // Arrange - NVR in Protect collection should get is_nvr metadata + var service = new DeviceTypeDetectionService(); + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "UNVR-Pro", null, isNvr: true); + service.SetProtectCameras(protectCameras); + + var client = new UniFiClientResponse + { + Mac = "00:11:22:33:44:55", + Name = "UNVR-Pro" + }; + + // Act + var result = service.DetectDeviceType(client); + + // Assert - Should have is_nvr metadata + result.Category.Should().Be(ClientDeviceCategory.Camera); + result.ConfidenceScore.Should().Be(100); + result.Metadata.Should().ContainKey("is_nvr"); + result.Metadata!["is_nvr"].Should().Be(true); + } + + [Fact] + public void DetectDeviceType_ProtectCamera_DoesNotSetIsNvrMetadata() + { + // Arrange - Regular camera in Protect collection should NOT get is_nvr metadata + var service = new DeviceTypeDetectionService(); + var protectCameras = new ProtectCameraCollection(); + protectCameras.Add("00:11:22:33:44:55", "G4 Pro"); // Not an NVR + service.SetProtectCameras(protectCameras); + + var client = new UniFiClientResponse + { + Mac = "00:11:22:33:44:55", + Name = "G4 Pro" + }; + + // Act + var result = service.DetectDeviceType(client); + + // Assert - Should NOT have is_nvr metadata + result.Category.Should().Be(ClientDeviceCategory.Camera); + result.ConfidenceScore.Should().Be(100); + result.Metadata.Should().NotContainKey("is_nvr"); + } + + #endregion + + #region ProtectCameraCollection IsNvr Tests + + [Fact] + public void ProtectCameraCollection_IsNvr_ReturnsTrueForNvr() + { + // Arrange + var collection = new ProtectCameraCollection(); + collection.Add("00:11:22:33:44:55", "UNVR", null, isNvr: true); + + // Act & Assert + collection.IsNvr("00:11:22:33:44:55").Should().BeTrue(); + } + + [Fact] + public void ProtectCameraCollection_IsNvr_ReturnsFalseForCamera() + { + // Arrange + var collection = new ProtectCameraCollection(); + collection.Add("00:11:22:33:44:55", "G4 Pro"); // Not an NVR + + // Act & Assert + collection.IsNvr("00:11:22:33:44:55").Should().BeFalse(); + } + + [Fact] + public void ProtectCameraCollection_IsNvr_ReturnsFalseForUnknownMac() + { + // Arrange + var collection = new ProtectCameraCollection(); + collection.Add("00:11:22:33:44:55", "UNVR", null, isNvr: true); + + // Act & Assert + collection.IsNvr("AA:BB:CC:DD:EE:FF").Should().BeFalse(); + } + + [Fact] + public void ProtectCameraCollection_IsNvr_ReturnsFalseForNull() + { + // Arrange + var collection = new ProtectCameraCollection(); + + // Act & Assert + collection.IsNvr(null).Should().BeFalse(); + } + + #endregion }