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 }