sqlinsight-pro/tests/SQLInsight.Notifications.Tests/NotificationTests.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

160 lines
6.5 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using SQLInsight.Core.Findings;
namespace SQLInsight.Notifications.Tests;
public class NotificationTests
{
private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
private sealed class ManualTime(DateTimeOffset now) : TimeProvider
{
public DateTimeOffset Now { get; set; } = now;
public override DateTimeOffset GetUtcNow() => Now;
}
private sealed class RecordingChannel(Severity min, bool fail = false) : INotificationChannel
{
public List<IReadOnlyList<Notification>> Batches { get; } = [];
public string Name => "recording";
public Severity MinSeverity => min;
public Task SendAsync(IReadOnlyList<Notification> notifications, CancellationToken cancellationToken)
{
if (fail)
{
throw new HttpRequestException("down");
}
Batches.Add(notifications);
return Task.CompletedTask;
}
}
private static Notification Opened(Severity severity, string title = "max server memory er ikke sat") =>
NotificationFactory.Opened("prod-sql01", 42, new Finding
{
RuleId = "SQLI-CFG-001", Severity = severity, Title = title,
Explanation = "SQL Server må bruge al hukommelse. Mere tekst.", Recommendation = "Sæt den.",
}, new Uri("https://sqlinsight.local/"), Now);
private static NotificationDispatcher Dispatcher(INotificationChannel channel, TimeProvider? time = null, int max = 20, bool resolved = true) =>
new([channel], new NotificationOptions { MaxMessagesPerHour = max, NotifyResolved = resolved }, time ?? new ManualTime(Now), NullLogger<NotificationDispatcher>.Instance);
[Fact]
public async Task Findings_below_the_channels_severity_are_not_sent_but_outages_always_are()
{
var channel = new RecordingChannel(Severity.High);
await Dispatcher(channel).DispatchAsync([Opened(Severity.Medium), Opened(Severity.Critical), NotificationFactory.Unreachable("legacy", "timeout", null, Now)], default);
var batch = Assert.Single(channel.Batches);
Assert.Equal([NotificationKind.FindingOpened, NotificationKind.InstanceUnreachable], batch.Select(n => n.Kind));
}
[Fact]
public async Task Resolved_can_be_switched_off()
{
var channel = new RecordingChannel(Severity.Low);
await Dispatcher(channel, resolved: false).DispatchAsync([NotificationFactory.Resolved("x", 1, "R", "t", Severity.High, null, Now)], default);
Assert.Empty(channel.Batches);
}
[Fact]
public async Task Messages_are_capped_per_hour()
{
var time = new ManualTime(Now);
var channel = new RecordingChannel(Severity.Low);
var dispatcher = Dispatcher(channel, time, max: 2);
for (var i = 0; i < 5; i++)
{
await dispatcher.DispatchAsync([Opened(Severity.High)], default);
}
Assert.Equal(2, channel.Batches.Count);
Assert.Equal(3, dispatcher.Suppressed["recording"]);
time.Now = Now.AddMinutes(61);
await dispatcher.DispatchAsync([Opened(Severity.High)], default);
Assert.Equal(3, channel.Batches.Count);
}
[Fact]
public async Task A_failing_channel_does_not_throw()
{
await Dispatcher(new RecordingChannel(Severity.Low, fail: true)).DispatchAsync([Opened(Severity.High)], default);
}
[Fact]
public void Opened_notification_links_to_the_finding_and_keeps_only_the_first_sentence()
{
var n = Opened(Severity.High);
Assert.Equal("https://sqlinsight.local/fund/42", n.Url!.ToString());
Assert.Equal("SQL Server må bruge al hukommelse.", n.Summary);
}
[Fact]
public void Email_is_html_encoded_and_has_a_useful_subject()
{
var options = new EmailOptions { From = "sqlinsight@x.local" };
options.To.Add("dba@x.local");
var message = EmailChannel.Build(options, [Opened(Severity.Critical, "<script>alert(1)</script>")]);
Assert.Equal("SQLInsight: [Kritisk] prod-sql01: <script>alert(1)</script>", message.Subject);
Assert.Contains("&lt;script&gt;", message.HtmlBody, StringComparison.Ordinal);
Assert.DoesNotContain("<script>", message.HtmlBody, StringComparison.Ordinal);
Assert.Contains("https://sqlinsight.local/fund/42", message.TextBody, StringComparison.Ordinal);
}
[Fact]
public void Teams_card_is_an_adaptive_card_message()
{
var json = JsonSerializer.Serialize(TeamsChannel.BuildCard([Opened(Severity.High)]));
using var doc = JsonDocument.Parse(json);
Assert.Equal("message", doc.RootElement.GetProperty("type").GetString());
var attachment = doc.RootElement.GetProperty("attachments")[0];
Assert.Equal("application/vnd.microsoft.card.adaptive", attachment.GetProperty("contentType").GetString());
Assert.Equal("AdaptiveCard", attachment.GetProperty("content").GetProperty("type").GetString());
Assert.Contains("Attention", json, StringComparison.Ordinal);
}
[Fact]
public async Task Webhook_posts_signed_json()
{
var port = Random.Shared.Next(20000, 40000);
using var listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{port}/hook/");
listener.Start();
var received = Task.Run(async () =>
{
var context = await listener.GetContextAsync();
using var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8);
var body = await reader.ReadToEndAsync();
var signature = context.Request.Headers[WebhookChannel.SignatureHeader];
context.Response.StatusCode = 204;
context.Response.Close();
return (body, signature);
});
using var http = new HttpClient();
await new WebhookChannel(new WebhookOptions { Url = new Uri($"http://localhost:{port}/hook/"), Secret = "s3cret" }, http)
.SendAsync([Opened(Severity.High)], default);
var (body, signature) = await received;
Assert.Equal(WebhookChannel.Sign("s3cret", body), signature);
using var doc = JsonDocument.Parse(body);
var n = doc.RootElement.GetProperty("notifications")[0];
Assert.Equal("FindingOpened", n.GetProperty("kind").GetString());
Assert.Equal("High", n.GetProperty("severity").GetString());
Assert.Equal("prod-sql01", n.GetProperty("instance").GetString());
}
}