diff --git a/src/NetworkOptimizer.Alerts/Delivery/NtfyChannelConfig.cs b/src/NetworkOptimizer.Alerts/Delivery/NtfyChannelConfig.cs new file mode 100644 index 000000000..f04c3d39b --- /dev/null +++ b/src/NetworkOptimizer.Alerts/Delivery/NtfyChannelConfig.cs @@ -0,0 +1,10 @@ +namespace NetworkOptimizer.Alerts.Delivery; + +public class NtfyChannelConfig +{ + public string ServerUrl { get; set; } = "https://ntfy.sh"; + public string Topic { get; set; } = string.Empty; + public string? AccessToken { get; set; } // Stored encrypted, for Bearer auth + public string? Username { get; set; } + public string? Password { get; set; } // Stored encrypted, for Basic auth +} diff --git a/src/NetworkOptimizer.Alerts/Delivery/NtfyDeliveryChannel.cs b/src/NetworkOptimizer.Alerts/Delivery/NtfyDeliveryChannel.cs new file mode 100644 index 000000000..c7806157d --- /dev/null +++ b/src/NetworkOptimizer.Alerts/Delivery/NtfyDeliveryChannel.cs @@ -0,0 +1,212 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using NetworkOptimizer.Alerts.Events; +using NetworkOptimizer.Alerts.Models; +using NetworkOptimizer.Core.Enums; + +namespace NetworkOptimizer.Alerts.Delivery; + +/// +/// Delivery channel for ntfy.sh push notifications using the JSON publishing API. +/// Supports both public ntfy.sh and self-hosted instances. +/// +public class NtfyDeliveryChannel : IAlertDeliveryChannel +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly ISecretDecryptor _secretDecryptor; + + public DeliveryChannelType ChannelType => DeliveryChannelType.Ntfy; + + public NtfyDeliveryChannel(ILogger logger, HttpClient httpClient, ISecretDecryptor secretDecryptor) + { + _logger = logger; + _httpClient = httpClient; + _secretDecryptor = secretDecryptor; + } + + public async Task SendAsync(AlertEvent alertEvent, AlertHistoryEntry historyEntry, DeliveryChannel channel, CancellationToken cancellationToken = default) + { + var config = JsonSerializer.Deserialize(channel.ConfigJson); + if (config == null || string.IsNullOrEmpty(config.Topic)) return false; + + var message = FormatMessage(alertEvent); + + var payload = JsonSerializer.Serialize(new + { + topic = config.Topic, + title = alertEvent.Title, + message, + priority = MapPriority(alertEvent.Severity), + tags = new[] { MapTag(alertEvent.Severity) }, + markdown = true + }); + + return await PostAsync(config, payload, cancellationToken); + } + + public async Task SendDigestAsync(IReadOnlyList alerts, DeliveryChannel channel, DigestSummary summary, CancellationToken cancellationToken = default) + { + var config = JsonSerializer.Deserialize(channel.ConfigJson); + if (config == null || string.IsNullOrEmpty(config.Topic)) return false; + + var sb = new StringBuilder(); + sb.AppendLine($"**{summary.TotalCount} alerts** in this period"); + if (summary.CriticalCount > 0) sb.AppendLine($"- Critical: {summary.CriticalCount}"); + if (summary.ErrorCount > 0) sb.AppendLine($"- Error: {summary.ErrorCount}"); + if (summary.WarningCount > 0) sb.AppendLine($"- Warning: {summary.WarningCount}"); + if (summary.InfoCount > 0) sb.AppendLine($"- Info: {summary.InfoCount}"); + + sb.AppendLine(); + + foreach (var alert in alerts.OrderByDescending(a => a.Severity).Take(10)) + { + sb.AppendLine($"- **{alert.Title}** - {alert.Source} ({TimestampFormatter.FormatLocalShort(alert.TriggeredAt)})"); + } + + if (alerts.Count > 10) + sb.AppendLine($"\n...and {alerts.Count - 10} more alerts"); + + // Use highest severity for priority + var maxSeverity = alerts.Max(a => a.Severity); + + var payload = JsonSerializer.Serialize(new + { + topic = config.Topic, + title = "Alert Digest", + message = sb.ToString().TrimEnd(), + priority = MapPriority(maxSeverity), + tags = new[] { "bell" }, + markdown = true + }); + + return await PostAsync(config, payload, cancellationToken); + } + + public async Task<(bool Success, string? Error)> TestAsync(DeliveryChannel channel, CancellationToken cancellationToken = default) + { + try + { + var config = JsonSerializer.Deserialize(channel.ConfigJson); + if (config == null || string.IsNullOrEmpty(config.Topic)) + return (false, "Invalid channel configuration"); + + var payload = JsonSerializer.Serialize(new + { + topic = config.Topic, + title = "Network Optimizer - Test", + message = "Alert channel test successful.", + priority = 3, + tags = new[] { "white_check_mark" }, + markdown = true + }); + + var success = await PostAsync(config, payload, cancellationToken); + return success ? (true, null) : (false, "ntfy POST failed"); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + + private async Task PostAsync(NtfyChannelConfig config, string payload, CancellationToken cancellationToken) + { + var url = $"{config.ServerUrl.TrimEnd('/')}"; + + const int maxRetries = 2; + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + + // Add auth header if configured + if (!string.IsNullOrEmpty(config.AccessToken)) + { + var token = _secretDecryptor.Decrypt(config.AccessToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + else if (!string.IsNullOrEmpty(config.Username) && !string.IsNullOrEmpty(config.Password)) + { + var password = _secretDecryptor.Decrypt(config.Password); + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{config.Username}:{password}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + + var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + { + _logger.LogDebug("ntfy message delivered to {Topic}", config.Topic); + return true; + } + + _logger.LogWarning("ntfy POST returned {StatusCode}", response.StatusCode); + if (attempt < maxRetries) + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt + 1)), cancellationToken); + } + catch (Exception ex) when (attempt < maxRetries) + { + _logger.LogWarning("ntfy attempt {Attempt} failed: {Error}", attempt + 1, ex.Message); + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt + 1)), cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deliver to ntfy"); + return false; + } + } + + return false; + } + + private static string FormatMessage(AlertEvent alertEvent) + { + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(alertEvent.Message)) + sb.AppendLine(alertEvent.Message); + + if (alertEvent.MetricValue.HasValue) + sb.AppendLine($"**Value:** {alertEvent.MetricValue}{(alertEvent.ThresholdValue.HasValue ? $" (threshold: {alertEvent.ThresholdValue})" : "")}"); + + if (!string.IsNullOrEmpty(alertEvent.DeviceName)) + sb.AppendLine($"**Device:** {alertEvent.DeviceName}"); + + if (!string.IsNullOrEmpty(alertEvent.DeviceIp)) + sb.AppendLine($"**IP:** {alertEvent.DeviceIp}"); + + sb.AppendLine($"**Source:** {alertEvent.Source}"); + sb.AppendLine($"**Severity:** {alertEvent.Severity}"); + + foreach (var ctx in alertEvent.Context) + sb.AppendLine($"**{ctx.Key}:** {ctx.Value}"); + + return sb.Length > 0 ? sb.ToString().TrimEnd() : alertEvent.EventType; + } + + /// + /// Map AlertSeverity to ntfy priority (1-5). + /// 5=max, 4=high, 3=default, 2=low, 1=min. + /// + internal static int MapPriority(AlertSeverity severity) => severity switch + { + AlertSeverity.Critical => 5, + AlertSeverity.Error => 4, + AlertSeverity.Warning => 3, + _ => 2 + }; + + /// + /// Map AlertSeverity to ntfy emoji shortcode tag. + /// + internal static string MapTag(AlertSeverity severity) => severity switch + { + AlertSeverity.Critical => "rotating_light", + AlertSeverity.Error => "red_circle", + AlertSeverity.Warning => "warning", + _ => "information_source" + }; +} diff --git a/src/NetworkOptimizer.Alerts/Models/DeliveryChannel.cs b/src/NetworkOptimizer.Alerts/Models/DeliveryChannel.cs index 694155c85..f5fbdbac0 100644 --- a/src/NetworkOptimizer.Alerts/Models/DeliveryChannel.cs +++ b/src/NetworkOptimizer.Alerts/Models/DeliveryChannel.cs @@ -11,7 +11,8 @@ public enum DeliveryChannelType Webhook, Slack, Discord, - Teams + Teams, + Ntfy } /// diff --git a/src/NetworkOptimizer.Audit/Analyzers/PortSecurityAnalyzer.cs b/src/NetworkOptimizer.Audit/Analyzers/PortSecurityAnalyzer.cs index 8fbf77c17..65df386f8 100644 --- a/src/NetworkOptimizer.Audit/Analyzers/PortSecurityAnalyzer.cs +++ b/src/NetworkOptimizer.Audit/Analyzers/PortSecurityAnalyzer.cs @@ -393,8 +393,26 @@ public List ExtractSwitches(JsonElement deviceData, List WAN lookup from ethernet_overrides (gateways only) + HashSet? wanIfnames = null; + if (device.TryGetProperty("ethernet_overrides", out var ethOverrides) && + ethOverrides.ValueKind == JsonValueKind.Array) + { + foreach (var ov in ethOverrides.EnumerateArray()) + { + var ifn = ov.GetStringOrNull("ifname"); + var ng = ov.GetStringOrNull("networkgroup"); + if (!string.IsNullOrEmpty(ifn) && !string.IsNullOrEmpty(ng) && + ng.StartsWith("WAN", StringComparison.OrdinalIgnoreCase)) + { + wanIfnames ??= new HashSet(StringComparer.OrdinalIgnoreCase); + wanIfnames.Add(ifn); + } + } + } + var ports = device.GetArrayOrEmpty("port_table") - .Select(port => ParsePort(port, switchInfoPlaceholder, networks, clientsByPort, historyByPort, portProfiles, deviceUplinkLookup)) + .Select(port => ParsePort(port, switchInfoPlaceholder, networks, clientsByPort, historyByPort, portProfiles, deviceUplinkLookup, wanIfnames)) .Where(p => p != null) .Cast() .ToList(); @@ -488,7 +506,7 @@ private SwitchCapabilities ParseSwitchCapabilities(JsonElement device) /// Parse a single port from JSON /// private PortInfo? ParsePort(JsonElement port, SwitchInfo switchInfo, List networks, Dictionary<(string, int), UniFiClientResponse> clientsByPort, Dictionary<(string, int), UniFiClientDetailResponse>? historyByPort = null) - => ParsePort(port, switchInfo, networks, clientsByPort, historyByPort, portProfiles: null, deviceUplinkLookup: null); + => ParsePort(port, switchInfo, networks, clientsByPort, historyByPort, portProfiles: null, deviceUplinkLookup: null, wanIfnames: null); /// /// Parse a single port from JSON with port profile resolution and device uplink detection @@ -500,7 +518,8 @@ private SwitchCapabilities ParseSwitchCapabilities(JsonElement device) Dictionary<(string, int), UniFiClientResponse> clientsByPort, Dictionary<(string, int), UniFiClientDetailResponse>? historyByPort, Dictionary? portProfiles, - Dictionary<(string, int), string>? deviceUplinkLookup) + Dictionary<(string, int), string>? deviceUplinkLookup, + HashSet? wanIfnames = null) { var portIdx = port.GetIntOrDefault("port_idx", -1); if (portIdx < 0) @@ -599,7 +618,9 @@ private SwitchCapabilities ParseSwitchCapabilities(JsonElement device) forwardMode = "custom"; var networkName = port.GetStringOrNull("network_name")?.ToLowerInvariant(); - var isWan = networkName?.StartsWith("wan") ?? false; + var ifname = port.GetStringOrNull("ifname"); + var isWan = (networkName?.StartsWith("wan") ?? false) || + (wanIfnames != null && !string.IsNullOrEmpty(ifname) && wanIfnames.Contains(ifname)); var poeEnable = port.GetBoolOrDefault("poe_enable"); var portPoe = port.GetBoolOrDefault("port_poe"); @@ -650,6 +671,7 @@ private SwitchCapabilities ParseSwitchCapabilities(JsonElement device) { PortIndex = portIdx, Name = portName, + IsEnabled = port.GetBoolOrDefault("enable", defaultValue: true), IsUp = port.GetBoolOrDefault("up"), Speed = port.GetIntOrDefault("speed"), ForwardMode = forwardMode, diff --git a/src/NetworkOptimizer.Audit/Models/PortInfo.cs b/src/NetworkOptimizer.Audit/Models/PortInfo.cs index 79e674525..0b5eaa7ed 100644 --- a/src/NetworkOptimizer.Audit/Models/PortInfo.cs +++ b/src/NetworkOptimizer.Audit/Models/PortInfo.cs @@ -17,6 +17,12 @@ public class PortInfo /// public string? Name { get; init; } + /// + /// Whether the port is administratively enabled (hardware-level enable/disable). + /// Defaults to true when not present in the API response. + /// + public bool IsEnabled { get; init; } = true; + /// /// Whether the port link is up /// diff --git a/src/NetworkOptimizer.Audit/Rules/UnusedPortRule.cs b/src/NetworkOptimizer.Audit/Rules/UnusedPortRule.cs index 25c2e928a..c846ba9fd 100644 --- a/src/NetworkOptimizer.Audit/Rules/UnusedPortRule.cs +++ b/src/NetworkOptimizer.Audit/Rules/UnusedPortRule.cs @@ -51,8 +51,8 @@ public static void SetThresholds(int unusedPortDays, int namedPortDays) if (port.IsUplink || port.IsWan) return null; - // Check if port is disabled - if (port.ForwardMode == "disabled") + // Check if port is disabled (either via forward mode or hardware enable flag) + if (port.ForwardMode == "disabled" || !port.IsEnabled) return null; // Correctly configured // Skip if port has an intentional unrestricted access profile diff --git a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor index 7f3597033..a2ab1540f 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/Settings.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/Settings.razor @@ -1129,7 +1129,7 @@

- Configure notification channels for the alert engine. Alerts are delivered via email, webhooks, Slack, Discord, or Microsoft Teams. + Configure notification channels for the alert engine. Alerts are delivered via email, webhooks, Slack, Discord, Microsoft Teams, or ntfy.sh.

@if (alertChannels?.Count > 0) @@ -1187,6 +1187,7 @@ +
@@ -1231,11 +1232,64 @@ Comma-separated email addresses } + else if (channelFormType == "Ntfy") + { +
+ + + Default is ntfy.sh. Change for self-hosted instances. +
+
+ + +
+
+ + @if (clearNtfyToken) + { +
+ Token will be cleared on save. + Undo +
+ } + else + { + + @if (hasNtfyToken && string.IsNullOrEmpty(channelNtfyAccessToken)) + { + Clear existing token + } + } +
+
+ + +
+
+ + @if (clearNtfyPassword) + { +
+ Password will be cleared on save. + Undo +
+ } + else + { + + @if (hasNtfyPassword && string.IsNullOrEmpty(channelNtfyPassword)) + { + Clear existing password + } + } +
+ Use access token OR username/password, not both. + } else {
- +
@if (channelFormType == "Webhook") { @@ -1829,6 +1883,15 @@ private string channelWebhookSecret = ""; private bool hasWebhookSecret = false; private bool clearWebhookSecret = false; + private string channelNtfyServerUrl = "https://ntfy.sh"; + private string channelNtfyTopic = ""; + private string channelNtfyAccessToken = ""; + private bool hasNtfyToken = false; + private bool clearNtfyToken = false; + private string channelNtfyUsername = ""; + private string channelNtfyPassword = ""; + private bool hasNtfyPassword = false; + private bool clearNtfyPassword = false; private string channelMinSeverity = "Warning"; private bool channelEnabled = true; private bool channelDigestEnabled = false; @@ -3262,6 +3325,14 @@ channelSmtpPort = channelSmtpStartTls ? 587 : 465; } + private string GetWebhookPlaceholder() => channelFormType switch + { + "Slack" => "https://hooks.slack.com/services/...", + "Discord" => "https://discord.com/api/webhooks/...", + "Teams" => "https://outlook.office.com/webhook/...", + _ => "https://example.com/webhook" + }; + private void StartAddChannel() { editingChannelId = null; @@ -3279,6 +3350,15 @@ channelWebhookSecret = ""; hasWebhookSecret = false; clearWebhookSecret = false; + channelNtfyServerUrl = "https://ntfy.sh"; + channelNtfyTopic = ""; + channelNtfyAccessToken = ""; + hasNtfyToken = false; + clearNtfyToken = false; + channelNtfyUsername = ""; + channelNtfyPassword = ""; + hasNtfyPassword = false; + clearNtfyPassword = false; channelMinSeverity = "Warning"; channelEnabled = true; channelDigestEnabled = false; @@ -3330,6 +3410,22 @@ channelWebhookSecret = ""; // Don't expose stored secret clearWebhookSecret = false; } + else if (ch.ChannelType == DeliveryChannelType.Ntfy) + { + var cfg = System.Text.Json.JsonSerializer.Deserialize(ch.ConfigJson); + if (cfg != null) + { + channelNtfyServerUrl = cfg.ServerUrl; + channelNtfyTopic = cfg.Topic; + channelNtfyUsername = cfg.Username ?? ""; + hasNtfyToken = !string.IsNullOrEmpty(cfg.AccessToken); + hasNtfyPassword = !string.IsNullOrEmpty(cfg.Password); + } + channelNtfyAccessToken = ""; + clearNtfyToken = false; + channelNtfyPassword = ""; + clearNtfyPassword = false; + } else { // Slack, Discord, Teams - just need the webhook URL @@ -3467,6 +3563,15 @@ } } } + else if (channelType == DeliveryChannelType.Ntfy) + { + if (string.IsNullOrWhiteSpace(channelNtfyTopic)) + { + alertChannelMessage = "Topic is required."; + alertChannelMessageClass = "warning"; + return; + } + } else { // Webhook, Slack, Discord, Teams all require a URL @@ -3577,6 +3682,33 @@ return System.Text.Json.JsonSerializer.Serialize(cfg); } + if (type == DeliveryChannelType.Ntfy) + { + string? token = clearNtfyToken + ? null + : !string.IsNullOrEmpty(channelNtfyAccessToken) + ? SecretDecryptor.Encrypt(channelNtfyAccessToken) + : GetExistingSecret(existingConfigJson, c => c.AccessToken); + if (string.IsNullOrEmpty(token)) token = null; + + string? password = clearNtfyPassword + ? null + : !string.IsNullOrEmpty(channelNtfyPassword) + ? SecretDecryptor.Encrypt(channelNtfyPassword) + : GetExistingSecret(existingConfigJson, c => c.Password); + if (string.IsNullOrEmpty(password)) password = null; + + var cfg = new NetworkOptimizer.Alerts.Delivery.NtfyChannelConfig + { + ServerUrl = channelNtfyServerUrl, + Topic = channelNtfyTopic, + AccessToken = token, + Username = string.IsNullOrWhiteSpace(channelNtfyUsername) ? null : channelNtfyUsername, + Password = password + }; + return System.Text.Json.JsonSerializer.Serialize(cfg); + } + // Slack, Discord, Teams all use webhook URL return System.Text.Json.JsonSerializer.Serialize(new { WebhookUrl = channelWebhookUrl }); } diff --git a/src/NetworkOptimizer.Web/Components/Pages/UpnpInspector.razor b/src/NetworkOptimizer.Web/Components/Pages/UpnpInspector.razor index d4d8f29b0..8cf5a9e88 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/UpnpInspector.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/UpnpInspector.razor @@ -172,8 +172,9 @@ @if (IsNoteSaving(GetNoteKey(rule))) diff --git a/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor b/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor index bd1eac629..33f3bccf0 100644 --- a/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor +++ b/src/NetworkOptimizer.Web/Components/Shared/SpeedTestDetails.razor @@ -184,8 +184,10 @@
@if (_notesSaving) @@ -482,6 +484,7 @@ // Notes state private string _notesInput = ""; + private bool _notesFocused; private bool _notesSaving; private bool _notesSaved; private System.Timers.Timer? _notesDebounceTimer; @@ -532,6 +535,13 @@ } else { + // Sync notes from Result if changed externally (e.g., edited in another SpeedTestDetails instance) + if (!_notesFocused && !_notesSaving) + { + var resultNotes = Result.Notes ?? ""; + if (_notesInput != resultNotes) + _notesInput = resultNotes; + } // Only use Result.PathAnalysis if no separate PathAnalysis was passed PathAnalysis ??= Result.PathAnalysis; } @@ -1101,14 +1111,14 @@ _notesDebounceTimer.Start(); } - private void OnNotesInput(ChangeEventArgs e) + private void OnNotesFocus() { - _notesInput = e.Value?.ToString() ?? ""; - DebounceSaveNotes(); + _notesFocused = true; } private async Task OnNotesBlur() { + _notesFocused = false; // Cancel any pending debounce and save immediately _notesDebounceTimer?.Stop(); _notesDebounceTimer?.Dispose(); @@ -1131,8 +1141,10 @@ try { - await OnNotesChanged.InvokeAsync((Result.Id, string.IsNullOrEmpty(newNotes) ? null : newNotes)); + // Update Result.Notes before invoking callback so other SpeedTestDetails instances + // see the new value when the parent re-renders during the await Result.Notes = string.IsNullOrEmpty(newNotes) ? null : newNotes; + await OnNotesChanged.InvokeAsync((Result.Id, string.IsNullOrEmpty(newNotes) ? null : newNotes)); _notesSaved = true; // Hide "Saved" after 2 seconds diff --git a/src/NetworkOptimizer.Web/Program.cs b/src/NetworkOptimizer.Web/Program.cs index 562abccc1..b31baf9a2 100644 --- a/src/NetworkOptimizer.Web/Program.cs +++ b/src/NetworkOptimizer.Web/Program.cs @@ -249,6 +249,11 @@ new NetworkOptimizer.Alerts.Delivery.TeamsDeliveryChannel( sp.GetRequiredService>(), sp.GetRequiredService().CreateClient())); +builder.Services.AddSingleton(sp => + new NetworkOptimizer.Alerts.Delivery.NtfyDeliveryChannel( + sp.GetRequiredService>(), + sp.GetRequiredService().CreateClient(), + sp.GetRequiredService())); // Register Threat Intelligence services builder.Services.AddSingleton(); diff --git a/tests/NetworkOptimizer.Alerts.Tests/Delivery/NtfyDeliveryChannelTests.cs b/tests/NetworkOptimizer.Alerts.Tests/Delivery/NtfyDeliveryChannelTests.cs new file mode 100644 index 000000000..ad69663ca --- /dev/null +++ b/tests/NetworkOptimizer.Alerts.Tests/Delivery/NtfyDeliveryChannelTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using NetworkOptimizer.Alerts.Delivery; +using NetworkOptimizer.Core.Enums; +using Xunit; + +namespace NetworkOptimizer.Alerts.Tests.Delivery; + +public class NtfyDeliveryChannelTests +{ + [Theory] + [InlineData(AlertSeverity.Critical, 5)] + [InlineData(AlertSeverity.Error, 4)] + [InlineData(AlertSeverity.Warning, 3)] + [InlineData(AlertSeverity.Info, 2)] + public void MapPriority_ReturnsExpectedNtfyPriority(AlertSeverity severity, int expected) + { + NtfyDeliveryChannel.MapPriority(severity).Should().Be(expected); + } + + [Theory] + [InlineData(AlertSeverity.Critical, "rotating_light")] + [InlineData(AlertSeverity.Error, "red_circle")] + [InlineData(AlertSeverity.Warning, "warning")] + [InlineData(AlertSeverity.Info, "information_source")] + public void MapTag_ReturnsExpectedEmojiShortcode(AlertSeverity severity, string expected) + { + NtfyDeliveryChannel.MapTag(severity).Should().Be(expected); + } + + [Fact] + public void MapPriority_CriticalIsHighest() + { + var critical = NtfyDeliveryChannel.MapPriority(AlertSeverity.Critical); + var error = NtfyDeliveryChannel.MapPriority(AlertSeverity.Error); + var warning = NtfyDeliveryChannel.MapPriority(AlertSeverity.Warning); + var info = NtfyDeliveryChannel.MapPriority(AlertSeverity.Info); + + critical.Should().BeGreaterThan(error); + error.Should().BeGreaterThan(warning); + warning.Should().BeGreaterThan(info); + } + + [Fact] + public void MapPriority_AllValuesInNtfyRange() + { + foreach (var severity in Enum.GetValues()) + { + var priority = NtfyDeliveryChannel.MapPriority(severity); + priority.Should().BeInRange(1, 5, $"ntfy priorities must be 1-5, got {priority} for {severity}"); + } + } + + [Fact] + public void MapTag_AllSeveritiesReturnNonEmpty() + { + foreach (var severity in Enum.GetValues()) + { + NtfyDeliveryChannel.MapTag(severity).Should().NotBeNullOrEmpty( + $"severity {severity} should map to a tag"); + } + } +} diff --git a/tests/NetworkOptimizer.Audit.Tests/Rules/UnusedPortRuleTests.cs b/tests/NetworkOptimizer.Audit.Tests/Rules/UnusedPortRuleTests.cs index 56b38b963..79c906f88 100644 --- a/tests/NetworkOptimizer.Audit.Tests/Rules/UnusedPortRuleTests.cs +++ b/tests/NetworkOptimizer.Audit.Tests/Rules/UnusedPortRuleTests.cs @@ -648,6 +648,66 @@ public void SetThresholds_ChangesNamedPortThreshold() #endregion + #region Evaluate Tests - Hardware-Disabled Ports (enable: false) + + [Fact] + public void Evaluate_HardwareDisabledPort_ReturnsNull() + { + // Arrange - Port with enable=false (hardware-disabled, e.g. prepped SFP+ port) + var port = CreatePort(portName: "SFP+ 2", isUp: false, forwardMode: "all", isEnabled: false); + var networks = CreateNetworkList(); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert + result.Should().BeNull("hardware-disabled port (enable=false) should not be flagged"); + } + + [Fact] + public void Evaluate_HardwareDisabledPort_NativeMode_ReturnsNull() + { + // Arrange - Disabled port with native forward mode + var port = CreatePort(portName: "Port 3", isUp: false, forwardMode: "native", isEnabled: false); + var networks = CreateNetworkList(); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert + result.Should().BeNull("hardware-disabled port should not be flagged regardless of forward mode"); + } + + [Fact] + public void Evaluate_HardwareEnabledPort_StillFlagged() + { + // Arrange - Enabled port that is down and not disabled via forward mode + var port = CreatePort(portName: "Port 5", isUp: false, forwardMode: "native", isEnabled: true); + var networks = CreateNetworkList(); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert + result.Should().NotBeNull("enabled port that is down should still be flagged"); + } + + [Fact] + public void Evaluate_IsEnabledDefaultsToTrue() + { + // Arrange - Port created without specifying IsEnabled (defaults to true) + var port = CreatePort(portName: "Port 5", isUp: false, forwardMode: "native"); + var networks = CreateNetworkList(); + + // Act + var result = _rule.Evaluate(port, networks); + + // Assert + result.Should().NotBeNull("port with default IsEnabled=true should still be flagged when unused"); + } + + #endregion + #region Intentional Unrestricted Profile Detection [Fact] @@ -736,7 +796,8 @@ private static PortInfo CreatePort( string? networkId = "default-net", string switchName = "Test Switch", long? lastConnectionSeen = null, - UniFiPortProfile? assignedProfile = null) + UniFiPortProfile? assignedProfile = null, + bool isEnabled = true) { var switchInfo = new SwitchInfo { @@ -749,6 +810,7 @@ private static PortInfo CreatePort( { PortIndex = portIndex, Name = portName, + IsEnabled = isEnabled, IsUp = isUp, ForwardMode = forwardMode, IsUplink = isUplink,