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
}