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; /// /api/v1 and /metrics with Windows authentication switched on and bearer tokens for machines. [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 _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().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().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); } } /// The /api/v1 read endpoints for findings, rules, reports and the pages, with paging, redaction and OpenAPI. [Collection(SqlServerTestGroup.Name)] [Trait("Category", "Integration")] public class ApiV1Tests(SqlServerFixture sql) : IAsyncLifetime { private readonly string _database = "SQLInsightApiV1_" + Guid.NewGuid().ToString("N")[..8]; private WebApplicationFactory _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().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 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().Endpoints .OfType() .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)); } }