sqlinsight-pro/tests/SQLInsight.IntegrationTests/ReportTests.cs
Claude d238580614
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
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
Fase 4: management and compliance reports as PDF
A PDF report for management and auditors (PDFsharp/MigraDoc, MIT, with
the Inter font embedded): key figures, a plain-language summary,
compliance controls backed by rules (met, not met, not measured),
instances and the critical and high findings. Downloadable from the
web UI (/rapporter) and mailed monthly by the agent with the PDF
attached, once per month, across daylight saving changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 10:09:10 +00:00

155 lines
8.5 KiB
C#

using System.Net.Http.Json;
using System.Text.Json;
using DotNet.Testcontainers.Builders;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SQLInsight.Agent.Reports;
using SQLInsight.Agent.Updates;
using SQLInsight.Core.Findings;
using SQLInsight.Notifications;
using SQLInsight.Core.Instances;
using SQLInsight.Reports;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
namespace SQLInsight.IntegrationTests;
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class ReportTests(SqlServerFixture sql) : IAsyncLifetime
{
private readonly string _database = "SQLInsightRpt_" + 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();
async Task Instance(string name, string version, string cu, params (string Rule, RuleOutcome Outcome, Severity Severity, string Title)[] rules)
{
var id = await _repository.UpsertInstanceAsync(name, new InstanceInfo
{
ServerName = name.ToUpperInvariant(), Version = SqlServerVersion.Parse(version), ProductLevel = "RTM", ProductUpdateLevel = cu,
Edition = "Enterprise Edition", EngineEdition = EngineEdition.Enterprise,
}, "SqlClient", false, DateTimeOffset.UtcNow);
await _repository.SaveRuleResultsAsync(id, rules.Select(r => new RuleResult
{
RuleId = r.Rule, RuleTitle = r.Title, Outcome = r.Outcome,
Findings = r.Outcome == RuleOutcome.Findings
? [new Finding { RuleId = r.Rule, Severity = r.Severity, Title = r.Title, Explanation = "Forklaring.", Recommendation = "Anbefaling." }]
: [],
}).ToList(), DateTimeOffset.UtcNow);
}
await Instance("prod-sql01", "15.0.4385.2", "CU20",
("SQLI-VER-001", RuleOutcome.Passed, Severity.Info, "Supportstatus"),
("SQLI-VER-002", RuleOutcome.Findings, Severity.High, "SQL Server 2019 kører 15.0.4385.2; seneste er CU32"),
("SQLI-DB-007", RuleOutcome.Findings, Severity.Critical, "Ingen fuld backup af Økonomi i 9 dage"),
("SQLI-DB-009", RuleOutcome.Passed, Severity.Info, "CHECKDB"),
("SQLI-CFG-008", RuleOutcome.Passed, Severity.Info, "xp_cmdshell"));
await Instance("legacy-sql05", "13.0.6300.2", "SP3",
("SQLI-VER-001", RuleOutcome.Findings, Severity.High, "SQL Server 2016 er ude af udvidet support om 3 uger"),
("SQLI-DB-007", RuleOutcome.Passed, Severity.Info, "Backup"),
("SQLI-DB-009", RuleOutcome.NotMeasurable, Severity.Info, "CHECKDB"));
}
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
private Task<ReportData> LoadAsync() => ReportData.LoadAsync(_repository, "Nordisk Logistik A/S", DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddMinutes(1),
TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen"), TimeProvider.System, CancellationToken.None);
[Fact]
public async Task Report_is_a_pdf_with_controls_and_a_plain_language_summary()
{
var data = await LoadAsync();
var pdf = ManagementReport.Render(data);
if (Environment.GetEnvironmentVariable("SQLINSIGHT_REPORT_OUT") is { Length: > 0 } path)
{
await File.WriteAllBytesAsync(path, pdf);
}
Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(pdf, 0, 5));
var summary = ManagementReport.SummarySentences(data);
Assert.Contains(summary, s => s.StartsWith("Der er 1 kritisk fund, som bør håndteres straks, og 2 fund med høj alvor.", StringComparison.Ordinal));
Assert.Contains(summary, s => s.StartsWith("1 af 2 instanser kører en SQL Server-version", StringComparison.Ordinal));
Assert.Contains(summary, s => s.StartsWith("1 instans har databaser uden en nylig fuld backup", StringComparison.Ordinal));
Assert.Contains(summary, s => s == "I perioden er der kommet 3 nye fund til (3 kritiske eller høje), og ingen er løst.");
var backup = ComplianceControl.All.Single(c => c.Name == "Fuld backup");
var integrity = ComplianceControl.All.Single(c => c.Name == "Integritetstjek");
Assert.Equal(ControlStatus.NotMet, backup.StatusOn("prod-sql01", data.RuleStates));
Assert.Equal(ControlStatus.Met, backup.StatusOn("legacy-sql05", data.RuleStates));
Assert.Equal(ControlStatus.Met, integrity.StatusOn("prod-sql01", data.RuleStates));
Assert.Equal(ControlStatus.NotMeasured, integrity.StatusOn("legacy-sql05", data.RuleStates));
Assert.Equal(ControlStatus.NotMeasured, ComplianceControl.All.Single(c => c.Name == "Log-backup").StatusOn("prod-sql01", data.RuleStates));
}
[Fact]
public async Task Web_serves_the_report_as_pdf()
{
await using var web = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
b.UseSetting("SQLInsight:Web:Organization", "Nordisk Logistik A/S");
});
var response = await web.CreateClient().GetAsync("/rapporter/driftsrapport.pdf?dage=90");
var page = await web.CreateClient().GetStringAsync("/rapporter");
Assert.Equal("application/pdf", response.Content.Headers.ContentType!.MediaType);
Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(await response.Content.ReadAsByteArrayAsync(), 0, 5));
Assert.Contains("Nordisk Logistik A/S", page, StringComparison.Ordinal);
}
[Fact]
public async Task Monthly_report_is_mailed_once_with_the_pdf_attached()
{
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();
var data = Path.Combine(Path.GetTempPath(), "sqlinsight-report-" + Guid.NewGuid().ToString("N"));
try
{
var notifications = new NotificationOptions();
notifications.Email.Host = mailpit.Hostname;
notifications.Email.Port = mailpit.GetMappedPublicPort(1025);
notifications.Email.Security = "None";
notifications.Email.From = "sqlinsight@test.local";
var reports = new ReportOptions { Organization = "Nordisk Logistik A/S", TimeZone = "Europe/Copenhagen" };
reports.Monthly.Enabled = true;
reports.Monthly.To.Add("it-chef@test.local");
var service = new MonthlyReportService(Options.Create(reports), Options.Create(notifications), Options.Create(new UpdateOptions { DataDirectory = data }),
_repository, TimeProvider.System, NullLogger<MonthlyReportService>.Instance);
Assert.True(await service.RunOnceAsync(CancellationToken.None));
Assert.False(await service.RunOnceAsync(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")).GetProperty("messages");
Assert.Equal(1, messages.GetArrayLength());
Assert.StartsWith("SQLInsight: Driftsrapport for Nordisk Logistik A/S, ", messages[0].GetProperty("Subject").GetString(), StringComparison.Ordinal);
var message = await http.GetFromJsonAsync<JsonElement>($"api/v1/message/{messages[0].GetProperty("ID").GetString()}");
var attachment = message.GetProperty("Attachments")[0];
Assert.Equal("application/pdf", attachment.GetProperty("ContentType").GetString());
Assert.Contains("kritisk fund", message.GetProperty("Text").GetString(), StringComparison.Ordinal);
}
finally
{
await mailpit.DisposeAsync();
if (Directory.Exists(data))
{
Directory.Delete(data, recursive: true);
}
}
}
}