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
160 lines
6.5 KiB
C#
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("<script>", 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());
|
|
}
|
|
}
|