Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
- Repository reports which findings opened and resolved per evaluation - Agent notifies on new and resolved findings and on instance unreachable/recovered transitions, with links to the web UI - Channels: e-mail (MailKit, HTML-encoded), Microsoft Teams Workflows webhook (Adaptive Card), generic JSON webhook signed with HMAC-SHA256 - Per-channel minimum severity (outages always sent), one message per batch, hourly cap per channel; a failing channel never stops monitoring - Tested with a real SMTP server (Mailpit) and the real agent pipeline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
112 lines
5.3 KiB
C#
112 lines
5.3 KiB
C#
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<Notification> Received { get; } = [];
|
|
public string Name => "recorder";
|
|
public Severity MinSeverity => Severity.Info;
|
|
|
|
public Task SendAsync(IReadOnlyList<Notification> 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<string, TimeSpan>(), new SqlConnectionFactory(), new RuleEngine(BuiltInRules.Create()),
|
|
_repository, NullLogger.Instance,
|
|
new NotificationDispatcher([recorder], new NotificationOptions(), TimeProvider.System, NullLogger<NotificationDispatcher>.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<JsonElement>("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();
|
|
}
|
|
}
|
|
}
|