using System.Net; using System.Net.Http.Headers; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; 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); Assert.Contains("sqlinsight_build_info{version=\"1.0.0\"", 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); } }