On SQL Server 2005 twenty rules stood as "not measurable" (Query Store, system_health, memory columns …) between the real gaps, and counted in the overview's "regler ikke målbare". They are not gaps: the version simply lacks what they look at. - New RuleOutcome.NotApplicable (last in the enum: outcomes are stored as numbers, so existing rows keep their meaning). - A rule or collector missing only capabilities inherent to the instance (version-gated, plan cache memory columns, or Azure-managed) is not applicable, with the version each needs: "Ikke relevant for SQL Server 2005: kræver QueryStore (fra SQL Server 2016)". Missing settings or rights (HADR not enabled …), collector failures and permission errors stay "not measurable". CollectedData.MarkNotApplicable carries this from collectors to the rules that use their data; community rules below their minimumVersion are not applicable too. - Counts are unchanged in shape: the overview, KPI, JSON API and Prometheus count outcomes 2 and 3 only. The instance page lists the rest in a collapsed "Ikke relevant for SQL Server 2005 (n)" (details/summary, no JS) after the real gaps. Report controls whose core rule does not apply count neither as met nor as not measured. Tests: engine (version vs. setting vs. collected data), inherent capabilities, the existing version tests now expect not applicable, report control status, and the web page and overview count. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
183 lines
8.6 KiB
C#
183 lines
8.6 KiB
C#
using System.Net;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Core.Instances;
|
|
using SQLInsight.Repository;
|
|
using SQLInsight.Rules.Engine;
|
|
|
|
namespace SQLInsight.IntegrationTests;
|
|
|
|
[Collection(SqlServerTestGroup.Name)]
|
|
[Trait("Category", "Integration")]
|
|
public class WebTests(SqlServerFixture sql) : IAsyncLifetime
|
|
{
|
|
private readonly string _database = "SQLInsightWeb_" + Guid.NewGuid().ToString("N")[..8];
|
|
private WebApplicationFactory<Program> _factory = null!;
|
|
private InsightRepository _repository = null!;
|
|
private long _findingId;
|
|
private long _deadlockId;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
|
var connectionString = sql.DatabaseConnectionString(_database);
|
|
_repository = new InsightRepository(connectionString);
|
|
await _repository.MigrateAsync();
|
|
|
|
var info = new InstanceInfo
|
|
{
|
|
ServerName = "SQL01",
|
|
Version = SqlServerVersion.Parse("15.0.4355.3"),
|
|
ProductLevel = "RTM",
|
|
ProductUpdateLevel = "CU28",
|
|
Edition = "Standard Edition",
|
|
EngineEdition = EngineEdition.Standard,
|
|
};
|
|
var id = await _repository.UpsertInstanceAsync("prod-sql01", info, "SqlClient", false, DateTimeOffset.UtcNow);
|
|
await _repository.SaveRuleResultsAsync(id,
|
|
[
|
|
new RuleResult
|
|
{
|
|
RuleId = "SQLI-CFG-001",
|
|
RuleTitle = "max server memory er ikke sat",
|
|
Outcome = RuleOutcome.Findings,
|
|
Findings =
|
|
[
|
|
new Finding
|
|
{
|
|
RuleId = "SQLI-CFG-001",
|
|
Severity = Severity.High,
|
|
Title = "max server memory er ikke sat",
|
|
Explanation = "SQL Server må bruge al hukommelse <script>alert(1)</script>",
|
|
Recommendation = "Sæt max server memory.",
|
|
Evidence = [new Evidence("max server memory (MB)", "2147483647")],
|
|
FixScript = "EXEC sys.sp_configure N'max server memory (MB)', 58983;",
|
|
References = [new Reference("Server memory", new Uri("https://learn.microsoft.com/sql/"))],
|
|
},
|
|
],
|
|
},
|
|
new RuleResult { RuleId = "SQLI-DB-011", RuleTitle = "VLF'er", Outcome = RuleOutcome.NotMeasurable, Reason = "Kræver SQL Server 2016 SP2" },
|
|
], DateTimeOffset.UtcNow);
|
|
await _repository.SaveCollectedAsync(id, "deadlocks", new DeadlockSnapshot
|
|
{
|
|
CapturedAt = DateTimeOffset.UtcNow,
|
|
NewDeadlocks = [new DeadlockEvent { Time = DateTimeOffset.UtcNow, GraphXml = "<deadlock><victim-list /></deadlock>", Processes = [new DeadlockProcess { Id = "p1", IsVictim = true, SessionId = 60, ClientApp = "Sales.Api" }] }],
|
|
});
|
|
_findingId = (await _repository.GetActiveFindingsAsync(id)).Single().FindingId;
|
|
_deadlockId = (await _repository.GetDeadlocksAsync(id, DateTimeOffset.UtcNow.AddHours(-1))).Single().DeadlockId;
|
|
|
|
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
|
{
|
|
b.UseEnvironment("Development");
|
|
b.UseSetting("SQLInsight:Repository:ConnectionString", connectionString);
|
|
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
|
b.UseSetting("SQLInsight:Web:TimeZone", "Europe/Copenhagen");
|
|
});
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _factory.DisposeAsync();
|
|
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Overview_lists_instances_and_urgent_findings()
|
|
{
|
|
var html = await _factory.CreateClient().GetStringAsync("/");
|
|
|
|
Assert.Contains("prod-sql01", html, StringComparison.Ordinal);
|
|
Assert.Contains("SQL Server 2019", html, StringComparison.Ordinal);
|
|
Assert.Contains("max server memory er ikke sat", html, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Instance_page_shows_findings_and_not_measurable_rules()
|
|
{
|
|
var html = await _factory.CreateClient().GetStringAsync("/instanser/prod-sql01");
|
|
|
|
Assert.Contains("Aktive fund", html, StringComparison.Ordinal);
|
|
Assert.Contains("SQLI-DB-011", html, StringComparison.Ordinal);
|
|
Assert.Contains("Kræver SQL Server 2016 SP2", html, StringComparison.Ordinal);
|
|
Assert.Contains($"/deadlocks/{_deadlockId}.xdl", html, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rules_not_applicable_to_the_version_are_collapsed_and_not_counted_as_not_measurable()
|
|
{
|
|
var id = (await _repository.GetInstanceAsync("prod-sql01"))!.InstanceId;
|
|
await _repository.SaveRuleResultsAsync(id,
|
|
[
|
|
new RuleResult { RuleId = "SQLI-QS-001", RuleTitle = "Query Store er slået fra", Outcome = RuleOutcome.NotApplicable, Reason = "Ikke relevant for SQL Server 2019: kræver noget nyere." },
|
|
new RuleResult { RuleId = "SQLI-QS-002", RuleTitle = "Query Store er skrivebeskyttet", Outcome = RuleOutcome.NotApplicable, Reason = "Ikke relevant for SQL Server 2019: kræver noget nyere." },
|
|
], DateTimeOffset.UtcNow);
|
|
|
|
var overview = (await _repository.GetInstancesAsync()).Single(i => i.Name == "prod-sql01");
|
|
Assert.Equal(1, overview.NotMeasurable); // only SQLI-DB-011, a real gap
|
|
|
|
var html = await _factory.CreateClient().GetStringAsync("/instanser/prod-sql01");
|
|
var notMeasurable = html[html.IndexOf("Ikke målbart på denne instans", StringComparison.Ordinal)..];
|
|
var collapsed = notMeasurable[notMeasurable.IndexOf("<details class=\"not-applicable\">", StringComparison.Ordinal)..];
|
|
Assert.Contains("Ikke relevant for SQL Server 2019 (2)", collapsed, StringComparison.Ordinal);
|
|
Assert.Contains("SQLI-QS-001", collapsed, StringComparison.Ordinal);
|
|
// The real gap stays visible, before and outside the collapsed list.
|
|
Assert.DoesNotContain("SQLI-DB-011", collapsed, StringComparison.Ordinal);
|
|
Assert.Contains("SQLI-DB-011", notMeasurable, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Finding_page_shows_explanation_evidence_and_script_html_encoded()
|
|
{
|
|
var html = await _factory.CreateClient().GetStringAsync($"/fund/{_findingId}");
|
|
|
|
Assert.Contains("Hvorfor er det et problem?", html, StringComparison.Ordinal);
|
|
Assert.Contains("2147483647", html, StringComparison.Ordinal);
|
|
Assert.Contains("sp_configure", html, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("<script>alert(1)</script>", html, StringComparison.Ordinal);
|
|
Assert.Contains("<script>", html, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Deadlock_graph_downloads_as_xdl()
|
|
{
|
|
var response = await _factory.CreateClient().GetAsync($"/deadlocks/{_deadlockId}.xdl");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal($"deadlock-{_deadlockId}.xdl", response.Content.Headers.ContentDisposition?.FileName);
|
|
Assert.StartsWith("<deadlock>", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Security_headers_are_set()
|
|
{
|
|
var response = await _factory.CreateClient().GetAsync("/");
|
|
|
|
Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single(), StringComparison.Ordinal);
|
|
Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unknown_instance_and_finding_are_handled()
|
|
{
|
|
var client = _factory.CreateClient();
|
|
|
|
Assert.Contains("Ukendt instans", await client.GetStringAsync("/instanser/findes-ikke"), StringComparison.Ordinal);
|
|
Assert.Contains("Ukendt fund", await client.GetStringAsync("/fund/999999"), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Refuses_to_run_without_authentication_outside_development()
|
|
{
|
|
using var production = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
|
{
|
|
b.UseEnvironment("Production");
|
|
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
|
|
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
|
});
|
|
|
|
var ex = Assert.ThrowsAny<Exception>(() => production.CreateClient());
|
|
Assert.Contains("kun tilladt i Development", ex.ToString(), StringComparison.Ordinal);
|
|
}
|
|
}
|