sqlinsight-pro/tests/SQLInsight.IntegrationTests/NotificationIntegrationTests.cs
Claude eac799a1f1
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
Fase 2: notifications by e-mail, Teams and webhook
- 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
2026-09-29 08:08:06 +00:00

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();
}
}
}