diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs new file mode 100644 index 00000000..dfe7d925 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EmailChannelProviderTests.cs @@ -0,0 +1,248 @@ +using System.Net; +using System.Net.Http; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models.Notifications; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// E-mail as a notification-channel provider (D-332): dispatch routing (never the webhook path, +/// never the bot), recipient validation, the Global-Admin gate, the rendered message, and the +/// send against a stubbed provider endpoint. +/// +public class EmailChannelProviderTests +{ + private const int Email = (int)WebhookProviderType.Email; + private static readonly NotificationScope Platform = NotificationScope.Platform; + + private static Mock WebhookMock() + => new(new HttpClient(), NullLogger.Instance) { CallBase = false }; + + private static Mock TelegramMock() + => new(new HttpClient(), Mock.Of(), NullLogger.Instance) { CallBase = false }; + + private static NotificationChannel Channel(string? url, string id = "mail", bool enabled = true) + => new() { Id = id, Name = id, ProviderType = Email, Url = url, Enabled = enabled }; + + private static NotificationAlert Alert() => new() + { + EventType = "PushDeliveryFailed", + Title = "Ops Alert: Platform/PushDeliveryFailed", + Summary = "A push service refused the platform key", + Severity = NotificationSeverity.Error, + Facts = { new() { Name = "Category", Value = "Platform" }, new() { Name = "Tenant", Value = "11111111-1111-1111-1111-111111111111" } }, + Sections = { new() { Title = "What happened", Text = "status 401 " } }, + Actions = { new() { Type = "openUrl", Title = "Open alerts", Url = "https://portal.example.invalid/admin/settings/alerts" } }, + }; + + // ── Dispatch routing ────────────────────────────────────────────────── + + [Fact] + public async Task Email_channel_reaches_the_email_sender_with_its_recipients_and_never_the_webhook_path() + { + var webhook = WebhookMock(); + var telegram = TelegramMock(); + var email = new Mock(); + var dispatcher = new NotificationChannelDispatcher(webhook.Object, telegram.Object, Mock.Of(), email.Object); + var alert = Alert(); + + await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid; two@example.invalid") }, alert, Platform); + + email.Verify(e => e.SendOpsAlertAsync("ops@example.invalid; two@example.invalid", alert), Times.Once); + webhook.Verify(w => w.SendNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + telegram.Verify(t => t.SendOpsAlertAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Email_channel_without_recipients_is_skipped_and_the_test_path_reports_the_sender_result() + { + var email = new Mock(); + email.Setup(e => e.SendAlertWithResultAsync("ops@example.invalid", It.IsAny())) + .ReturnsAsync(new WebhookTestResult { Success = true, Message = "Sent to 1 recipient(s)." }); + var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of(), email.Object); + + await dispatcher.SendToChannelsAsync(new[] { Channel(""), Channel(null, "n") }, Alert(), Platform); + email.Verify(e => e.SendOpsAlertAsync(It.IsAny(), It.IsAny()), Times.Never); + + var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform); + Assert.True(result.Success); + Assert.Equal("Sent to 1 recipient(s).", result.Message); + } + + [Fact] + public async Task Dispatcher_without_an_email_transport_skips_the_channel_and_says_so_on_test() + { + var dispatcher = new NotificationChannelDispatcher(WebhookMock().Object, TelegramMock().Object, Mock.Of()); + + await dispatcher.SendToChannelsAsync(new[] { Channel("ops@example.invalid") }, Alert(), Platform); // no throw + var result = await dispatcher.SendWithResultAsync(Channel("ops@example.invalid"), Alert(), Platform); + + Assert.False(result.Success); + Assert.Contains("not registered", result.Message); + } + + // ── Validation and gate ─────────────────────────────────────────────── + + [Theory] + [InlineData("ops@example.invalid")] + [InlineData("ops@example.invalid; second@example.invalid")] + [InlineData("ops@example.invalid, second@example.invalid ,third@sub.example.invalid")] + [InlineData(" first.last+tag@example.invalid ")] + public void ValidateEmailDestination_accepts_one_to_five_plain_addresses(string recipients) + => Assert.Null(TenantConfigValidation.ValidateEmailDestination(recipients)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(";,")] + [InlineData("not-an-address")] + [InlineData("Ops Team ")] + [InlineData("ops@localhost")] + [InlineData("https://hooks.example.invalid/x")] + [InlineData("a@x.invalid;b@x.invalid;c@x.invalid;d@x.invalid;e@x.invalid;f@x.invalid")] + public void ValidateEmailDestination_rejects_blank_malformed_named_hostless_and_too_many(string? recipients) + => Assert.NotNull(TenantConfigValidation.ValidateEmailDestination(recipients)); + + [Fact] + public void ValidateNotificationChannels_uses_the_recipient_rule_not_the_ssrf_url_gate() + { + var ok = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") }); + Assert.Null(TenantConfigValidation.ValidateNotificationChannels(ok)); + + var bad = NotificationChannel.SerializeList(new[] { Channel("https://hooks.example.invalid/x") }); + Assert.Contains("not a valid e-mail address", TenantConfigValidation.ValidateNotificationChannels(bad)); + } + + [Fact] + public void Email_provider_is_global_admin_only() + { + Assert.True(NotificationChannel.IsGlobalAdminOnlyProvider(Email)); + var candidate = NotificationChannel.SerializeList(new[] { Channel("ops@example.invalid") }); + + Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: true)); + var refused = TenantConfigValidation.ValidateTelegramChannelGate(candidate, null, isGlobalAdmin: false); + Assert.Equal("E-mail channels can only be configured by a Global Administrator.", refused); + + // A GA-created channel survives a tenant admin's unrelated save; retargeting it does not. + Assert.Null(TenantConfigValidation.ValidateTelegramChannelGate(candidate, candidate, isGlobalAdmin: false)); + var retargeted = NotificationChannel.SerializeList(new[] { Channel("other@example.invalid") }); + Assert.NotNull(TenantConfigValidation.ValidateTelegramChannelGate(retargeted, candidate, isGlobalAdmin: false)); + } + + // ── Rendering ───────────────────────────────────────────────────────── + + [Fact] + public void Renderer_builds_a_prefixed_subject_and_escaped_html_with_facts_sections_links_and_payload() + { + var alert = Alert(); + alert.DataJson = "{\"service\":\"apple\",\"statusCode\":401,\"note\":\"