using System.Net.Http.Json; using System.Text.Json; using DotNet.Testcontainers.Builders; using Microsoft.Extensions.Logging.Abstractions; using SQLInsight.Agent; using SQLInsight.Agent.Monitoring; using SQLInsight.Collectors; using SQLInsight.Connectivity; using SQLInsight.Core.Findings; using SQLInsight.Notifications; using SQLInsight.Repository; using SQLInsight.Rules; using SQLInsight.Rules.Engine; namespace SQLInsight.IntegrationTests; [Collection(SqlServerTestGroup.Name)] [Trait("Category", "Integration")] public class NotificationIntegrationTests(SqlServerFixture sql) : IAsyncLifetime { private readonly string _database = "SQLInsightNotify_" + Guid.NewGuid().ToString("N")[..8]; private InsightRepository _repository = null!; public async Task InitializeAsync() { await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); await _repository.MigrateAsync(); } public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); private sealed class Recorder : INotificationChannel { public List Received { get; } = []; public string Name => "recorder"; public Severity MinSeverity => Severity.Info; public Task SendAsync(IReadOnlyList notifications, CancellationToken cancellationToken) { Received.AddRange(notifications); return Task.CompletedTask; } } private InstanceMonitor Monitor(string name, Recorder recorder, string? password = null) => new( new MonitoredInstance { Name = name, Server = sql.Server, Authentication = AuthenticationMode.SqlLogin, UserName = SqlServerFixture.AgentLogin, Password = password ?? SqlServerFixture.AgentPassword, Provider = ConnectionProvider.SqlClient, TrustServerCertificate = true, }, BuiltInCollectors.Create(), new Dictionary(), new SqlConnectionFactory(), new RuleEngine(BuiltInRules.Create()), _repository, NullLogger.Instance, new NotificationDispatcher([recorder], new NotificationOptions(), TimeProvider.System, NullLogger.Instance), new Uri("https://sqlinsight.test/")); [Fact] public async Task New_findings_are_notified_once_and_outages_on_transition() { var recorder = new Recorder(); var monitor = Monitor("notify", recorder); var now = DateTimeOffset.UtcNow; await monitor.RunCycleAsync(now, CancellationToken.None); var first = recorder.Received.Count(n => n.Kind == NotificationKind.FindingOpened); await monitor.RunCycleAsync(now.AddMinutes(16), CancellationToken.None); Assert.True(first > 0); Assert.Contains(recorder.Received, n => n.RuleId == "SQLI-CFG-001" && n.Url!.ToString().StartsWith("https://sqlinsight.test/fund/", StringComparison.Ordinal)); // Findings still present in the second cycle are not notified again. Assert.Single(recorder.Received, n => n.Kind == NotificationKind.FindingOpened && n.RuleId == "SQLI-CFG-001"); var down = new Recorder(); var broken = Monitor("notify-down", down, password: "wrong"); await broken.RunCycleAsync(now, CancellationToken.None); await broken.RunCycleAsync(now.AddMinutes(1), CancellationToken.None); Assert.Single(down.Received, n => n.Kind == NotificationKind.InstanceUnreachable); } [Fact] public async Task Email_is_delivered_through_a_real_smtp_server() { var mailpit = new ContainerBuilder("axllent/mailpit:latest") .WithPortBinding(1025, true) .WithPortBinding(8025, true) .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(8025).ForPath("/livez"))) .Build(); await mailpit.StartAsync(); try { var options = new EmailOptions { Host = mailpit.Hostname, Port = mailpit.GetMappedPublicPort(1025), Security = "None", From = "sqlinsight@test.local" }; options.To.Add("dba@test.local"); var notification = NotificationFactory.Opened("prod-sql01", 7, new Finding { RuleId = "SQLI-DB-007", Severity = Severity.Critical, Title = "master har aldrig haft en fuld backup", Explanation = "Uden backup kan databasen ikke gendannes.", Recommendation = "Tag backup.", }, new Uri("https://sqlinsight.test/"), DateTimeOffset.UtcNow); await new EmailChannel(options).SendAsync([notification], CancellationToken.None); using var http = new HttpClient { BaseAddress = new Uri($"http://{mailpit.Hostname}:{mailpit.GetMappedPublicPort(8025)}/") }; var messages = await http.GetFromJsonAsync("api/v1/messages"); var message = messages.GetProperty("messages")[0]; Assert.Equal("SQLInsight: [Kritisk] prod-sql01: master har aldrig haft en fuld backup", message.GetProperty("Subject").GetString()); Assert.Equal("dba@test.local", message.GetProperty("To")[0].GetProperty("Address").GetString()); } finally { await mailpit.DisposeAsync(); } } }