sqlinsight-pro/tests/SQLInsight.IntegrationTests/WebApiTests.cs
Søren Eggert Lundsteen Olsen 0dc7866808 Separate "check by hand" from "not relevant" for version-gated rules
A rule whose topic applies but whose way of measuring needs a newer
version or patch now gives the new outcome ManualCheck with concrete
Danish guidance, instead of NotApplicable. It is marked explicitly per
rule (IRule.ManualCheck) and only used when every missing capability is
a measurement capability (InstanceCapabilities.IsMeasurement): CHECKDB
status (SQLI-DB-009), VLFs (SQLI-DB-011), spills and memory grants
(SQLI-PLAN-004/005) and deadlocks on 2005 (SQLI-DLK-001). Query Store,
AG and Azure-managed topics stay "not relevant".

Shown in a visible "Skal tjekkes manuelt" section on the instance page,
a KPI on the overview, a "Tjek manuelt" column in the management
report's controls, manualChecks in /api/v1/instances and manualCheck in
rule-states; not counted as not measurable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:59:50 +02:00

319 lines
17 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
using SQLInsight.Web.Api;
namespace SQLInsight.IntegrationTests;
/// <summary>/api/v1 and /metrics with Windows authentication switched on and bearer tokens for machines.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class WebApiTests(SqlServerFixture sql) : IAsyncLifetime
{
private const string Token = "sqli_test-token-for-prometheus";
private readonly string _database = "SQLInsightApi_" + Guid.NewGuid().ToString("N")[..8];
private WebApplicationFactory<Program> _factory = null!;
private int _port;
public async Task InitializeAsync()
{
using (var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0))
{
probe.Start();
_port = ((IPEndPoint)probe.LocalEndpoint).Port;
}
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
var repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await repository.MigrateAsync();
var id = await repository.UpsertInstanceAsync("prod \"sql01\"", new InstanceInfo
{
ServerName = "SQL01", Version = SqlServerVersion.Parse("16.0.4175.1"), ProductLevel = "RTM", ProductUpdateLevel = "CU20",
Edition = "Enterprise Edition", EngineEdition = EngineEdition.Enterprise,
}, "SqlClient", false, DateTimeOffset.UtcNow);
await repository.SaveRuleResultsAsync(id,
[
new RuleResult
{
RuleId = "SQLI-BLK-001", RuleTitle = "Blocking", Outcome = RuleOutcome.Findings,
Findings =
[
new Finding
{
RuleId = "SQLI-BLK-001", Severity = Severity.High, Title = "Session 87 blokerer 14 sessioner", Explanation = "e", Recommendation = "r",
Subject = "session: 87",
Evidence = [new Evidence("Blokerede sessioner", "14"), new Evidence("Head blockers SQL", "UPDATE dbo.Kunder SET Cpr = '010101-1234'", Sensitive: true)],
},
],
},
], DateTimeOffset.UtcNow);
await sql.ExecuteAsSaAsync($"INSERT [{_database}].insight.MetricSamples (InstanceId, Metric, SampledAt, Value) VALUES ({id}, 'cpu.sqlserver', SYSUTCDATETIME(), 42.5);");
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "Windows");
b.UseSetting("SQLInsight:Web:AllowedGroups:0", @"DOMÆNE\SQL-Admins");
b.UseSetting("SQLInsight:Web:ApiTokens:prometheus", "sha256:" + ApiTokenHandler.Hash(Token));
b.UseSetting("urls", $"http://127.0.0.1:{_port}");
});
// Negotiate needs a real server (Kestrel), not the in-memory test server.
_factory.UseKestrel();
_factory.StartServer();
}
public async Task DisposeAsync()
{
await _factory.DisposeAsync();
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
}
private HttpClient Client(string? token)
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri($"http://127.0.0.1:{_port}") });
if (token is not null)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
return client;
}
[Fact]
public async Task Metrics_are_served_in_prometheus_format_with_a_valid_token()
{
var response = await Client(Token).GetAsync("/metrics");
var text = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.StartsWith("text/plain", response.Content.Headers.ContentType!.MediaType, StringComparison.Ordinal);
Assert.Contains("sqlinsight_instance_up{instance=\"prod \\\"sql01\\\"\"} 1", text, StringComparison.Ordinal);
Assert.Contains("sqlinsight_findings{instance=\"prod \\\"sql01\\\"\",severity=\"high\"} 1", text, StringComparison.Ordinal);
Assert.Contains("sqlinsight_rule_findings{instance=\"prod \\\"sql01\\\"\",rule=\"SQLI-BLK-001\",severity=\"high\"} 1", text, StringComparison.Ordinal);
Assert.Contains("sqlinsight_metric{instance=\"prod \\\"sql01\\\"\",metric=\"cpu.sqlserver\"} 42.5", text, StringComparison.Ordinal);
Assert.Contains("# TYPE sqlinsight_findings gauge", text, StringComparison.Ordinal);
// The version from Directory.Build.props (or the release tag), whatever it currently is.
var version = typeof(IntegrationEndpoints).Assembly
.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
.Cast<System.Reflection.AssemblyInformationalVersionAttribute>().Single().InformationalVersion.Split('+')[0];
Assert.Contains($"sqlinsight_build_info{{version=\"{version}\"", text, StringComparison.Ordinal);
}
[Fact]
public async Task Findings_api_leaves_out_sensitive_evidence()
{
var json = await Client(Token).GetStringAsync("/api/v1/findings?minSeverity=High");
using var document = JsonDocument.Parse(json);
var finding = Assert.Single(document.RootElement.EnumerateArray());
Assert.Equal("SQLI-BLK-001", finding.GetProperty("ruleId").GetString());
Assert.Contains("[udeladt]", json, StringComparison.Ordinal);
Assert.DoesNotContain("010101-1234", json, StringComparison.Ordinal);
Assert.Equal(0, JsonDocument.Parse(await Client(Token).GetStringAsync("/api/v1/findings?minSeverity=Critical")).RootElement.GetArrayLength());
}
[Theory]
[InlineData(null, "/metrics")]
[InlineData("forkert-token", "/metrics")]
[InlineData("forkert-token", "/api/v1/instances")]
[InlineData(Token, "/")]
[InlineData(Token, "/fund")]
public async Task Without_a_valid_token_or_outside_the_api_access_is_refused(string? token, string path)
{
var response = await Client(token).GetAsync(path);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
/// <summary>The /api/v1 read endpoints for findings, rules, reports and the pages, with paging, redaction and OpenAPI.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class ApiV1Tests(SqlServerFixture sql) : IAsyncLifetime
{
private readonly string _database = "SQLInsightApiV1_" + Guid.NewGuid().ToString("N")[..8];
private WebApplicationFactory<Program> _factory = null!;
private InsightRepository _repository = null!;
private int _instanceId;
private const string Name = @"AX01\AX30";
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
var now = DateTimeOffset.UtcNow;
_instanceId = await _repository.UpsertInstanceAsync(Name, new InstanceInfo
{
ServerName = "AX01", Version = SqlServerVersion.Parse("16.0.4135.4"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
}, "SqlClient", false, now);
var findings = Enumerable.Range(1, 5).Select(i => new Finding
{
RuleId = "SQLI-DB-007", Severity = i == 1 ? Severity.Critical : Severity.Medium, Title = $"Ingen backup af Db{i}", Explanation = "e", Recommendation = "r",
Subject = $"database: Db{i}", FixScript = "BACKUP DATABASE …",
Evidence = [new Evidence("Database", $"Db{i}"), new Evidence("Login", @"KUNDE\anne", Sensitive: true)],
}).ToArray();
await _repository.SaveRuleResultsAsync(_instanceId,
[new RuleResult { RuleId = "SQLI-DB-007", RuleTitle = "Fuld backup", Outcome = RuleOutcome.Findings, Findings = findings },
new RuleResult { RuleId = "LOCAL-AX-001", RuleTitle = "Lokal regel", Outcome = RuleOutcome.Passed },
new RuleResult { RuleId = "SQLI-DB-009", RuleTitle = "CHECKDB", Outcome = RuleOutcome.ManualCheck, Reason = "Kan ikke måles på SQL Server 2014 – tjek manuelt. Se dbi_dbccLastKnownGood." }], now);
await _repository.SaveCollectedAsync(_instanceId, "wait-sampling", WaitSampling.Aggregate(
[new WaitingRequest(60, "LCK_M_U", 3000, "AXDB", "0xAB", "UPDATE NUMBERSEQUENCETABLE SET NEXTREC = 1", 55)], now.AddMinutes(-1)));
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
}
public async Task DisposeAsync()
{
await _factory.DisposeAsync();
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
}
private async Task<JsonElement> GetJsonAsync(string path)
{
var response = await _factory.CreateClient().GetAsync(path);
Assert.True(response.IsSuccessStatusCode, $"{path}: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone();
}
[Fact]
public async Task Findings_are_filtered_and_paged_with_a_continuation()
{
var client = _factory.CreateClient();
var first = await client.GetAsync("/api/v1/findings?status=all&instance=" + Uri.EscapeDataString(Name) + "&limit=2&sort=firstSeen");
var continuation = first.Headers.GetValues("X-Continuation").Single();
var page1 = JsonDocument.Parse(await first.Content.ReadAsStringAsync()).RootElement;
var page2 = await GetJsonAsync($"/api/v1/findings?status=all&limit=2&sort=firstSeen&continuation={continuation}");
var critical = await GetJsonAsync("/api/v1/findings?minSeverity=Critical");
var byDatabase = await GetJsonAsync("/api/v1/findings?database=Db3&category=DB");
var resolved = await GetJsonAsync("/api/v1/findings?status=resolved");
Assert.Equal(2, page1.GetArrayLength());
Assert.Equal(2, page2.GetArrayLength());
Assert.NotEqual(page1[0].GetProperty("id").GetInt64(), page2[0].GetProperty("id").GetInt64());
Assert.Equal("Ingen backup af Db1", Assert.Single(critical.EnumerateArray()).GetProperty("title").GetString());
Assert.Equal("database: Db3", Assert.Single(byDatabase.EnumerateArray()).GetProperty("subject").GetString());
Assert.Equal(0, resolved.GetArrayLength());
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/findings?status=nogle")).StatusCode);
}
[Fact]
public async Task One_finding_has_explanation_fix_script_history_and_no_sensitive_values()
{
var list = await GetJsonAsync("/api/v1/findings?minSeverity=Critical");
var id = list[0].GetProperty("id").GetInt64();
var finding = await GetJsonAsync($"/api/v1/findings/{id}");
var text = finding.GetRawText();
Assert.Equal("BACKUP DATABASE …", finding.GetProperty("fixScript").GetString());
Assert.Equal("active", finding.GetProperty("history").GetProperty("status").GetString());
Assert.Contains("[udeladt]", text, StringComparison.Ordinal);
Assert.DoesNotContain("anne", text, StringComparison.Ordinal);
Assert.Equal(HttpStatusCode.NotFound, (await _factory.CreateClient().GetAsync("/api/v1/findings/999999")).StatusCode);
}
[Fact]
public async Task Rules_and_rule_states_list_built_in_community_and_local_rules()
{
var rules = await GetJsonAsync("/api/v1/rules");
var states = await GetJsonAsync($"/api/v1/instances/{Uri.EscapeDataString(Name)}/rule-states");
Assert.Contains(rules.EnumerateArray(), r => r.GetProperty("id").GetString() == "SQLI-SEC-001" && r.GetProperty("source").GetString() == "indbygget");
Assert.Contains(rules.EnumerateArray(), r => r.GetProperty("id").GetString() == "COM-FT-005" && r.GetProperty("source").GetString() == "community");
Assert.Contains(rules.EnumerateArray(), r => r.GetProperty("id").GetString() == "LOCAL-AX-001" && r.GetProperty("source").GetString() == "lokal");
Assert.Contains(states.EnumerateArray(), s => s.GetProperty("ruleId").GetString() == "SQLI-DB-007" && s.GetProperty("outcome").GetString() == "findings");
}
[Theory]
[InlineData("driftsrapport", "")]
[InlineData("analyse", "&instance=AX01%5CAX30")]
[InlineData("rettigheder", "&instance=AX01%5CAX30")]
public async Task Every_report_comes_as_json_and_pdf(string type, string parameters)
{
var client = _factory.CreateClient();
var json = await GetJsonAsync($"/api/v1/reports/{type}?format=json{parameters}");
var pdf = await client.GetAsync($"/api/v1/reports/{type}?format=pdf{parameters}");
var list = await GetJsonAsync("/api/v1/reports");
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.Equal("application/pdf", pdf.Content.Headers.ContentType?.MediaType);
Assert.Contains(list.EnumerateArray(), r => r.GetProperty("type").GetString() == type);
}
[Fact]
public async Task Page_data_is_served_with_statement_texts_left_out()
{
var name = Uri.EscapeDataString(Name);
var waits = await GetJsonAsync($"/api/v1/instances/{name}/wait-types/LCK_M_U");
var analysis = await GetJsonAsync($"/api/v1/instances/{name}/analysis");
var topSql = await GetJsonAsync($"/api/v1/instances/{name}/top-sql");
var disks = await GetJsonAsync($"/api/v1/instances/{name}/disks");
var configuration = await GetJsonAsync($"/api/v1/instances/{name}/configuration?days=30");
var privileges = await GetJsonAsync($"/api/v1/instances/{name}/privileges");
var all = await GetJsonAsync("/api/v1/privileges?days=7");
Assert.Equal("Venter på en lås", waits.GetProperty("explanation").GetProperty("title").GetString());
var statement = Assert.Single(waits.GetProperty("statements").EnumerateArray());
Assert.Equal("[udeladt]", statement.GetProperty("text").GetString());
Assert.Equal(1, statement.GetProperty("samples").GetInt64());
Assert.True(analysis.TryGetProperty("metrics", out _));
Assert.True(topSql.TryGetProperty("queries", out _));
Assert.True(disks.TryGetProperty("drives", out _));
Assert.True(configuration.TryGetProperty("changes", out _));
Assert.Equal(Name, privileges.GetProperty("instance").GetString());
Assert.Equal(1, all.GetProperty("instances").GetArrayLength());
}
[Fact]
public async Task Manual_checks_are_their_own_outcome_on_pages_and_in_the_api()
{
var client = _factory.CreateClient();
var states = await GetJsonAsync($"/api/v1/instances/{Uri.EscapeDataString(Name)}/rule-states");
var instances = await GetJsonAsync("/api/v1/instances");
var page = await client.GetStringAsync($"/instanser/{Uri.EscapeDataString(Name)}");
var home = await client.GetStringAsync("/");
Assert.Contains(states.EnumerateArray(), s => s.GetProperty("ruleId").GetString() == "SQLI-DB-009" && s.GetProperty("outcome").GetString() == "manualCheck");
var instance = Assert.Single(instances.EnumerateArray());
Assert.Equal(1, instance.GetProperty("manualChecks").GetInt32());
Assert.Equal(0, instance.GetProperty("notMeasurable").GetInt32());
Assert.Contains("Skal tjekkes manuelt", page, StringComparison.Ordinal);
Assert.Contains("dbi_dbccLastKnownGood", page, StringComparison.Ordinal);
Assert.Contains("skal tjekkes manuelt", home, StringComparison.Ordinal);
}
[Fact]
public async Task Openapi_describes_every_api_v1_endpoint()
{
var document = await GetJsonAsync("/api/v1/openapi.json");
var described = document.GetProperty("paths").EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
var endpoints = _factory.Services.GetRequiredService<Microsoft.AspNetCore.Routing.EndpointDataSource>().Endpoints
.OfType<Microsoft.AspNetCore.Routing.RouteEndpoint>()
.Select(e => "/" + e.RoutePattern.RawText!.TrimStart('/'))
.Where(p => p.StartsWith("/api/v1/", StringComparison.OrdinalIgnoreCase) && !p.EndsWith("openapi.json", StringComparison.OrdinalIgnoreCase))
.Select(p => System.Text.RegularExpressions.Regex.Replace(p, @"\{([^}:]+)(:[^}]+)?\}", "{$1}").TrimEnd('/'))
.Distinct()
.ToList();
Assert.NotEmpty(endpoints);
Assert.All(endpoints, e => Assert.Contains(e, described));
}
}