sqlinsight-pro/tests/SQLInsight.IntegrationTests/WebApiTests.cs
Søren Eggert Lundsteen Olsen 30d89acbd7 API v1: findings, rules, reports and page data as JSON, with OpenAPI
- /api/v1/findings gains status (active/resolved/all), from/to, database,
  ruleId, category, sort and paging (limit + X-Continuation header; the
  response stays an array). /findings/{id} with fix script and history.
- /rules (built-in, community, local) and /instances/{name}/rule-states.
- /reports and /reports/{driftsrapport|analyse|rettigheder} as JSON or PDF,
  built from the same models as the pages and PDFs.
- Page data: analysis, top-sql, disks, configuration, privileges (one or
  all instances) and wait types. Statement texts, logins, names and
  service accounts are "[udeladt]" unless ApiIncludesSensitiveEvidence.
- OpenAPI at /api/v1/openapi.json; a test fails when an endpoint is
  missing from it.
- API tokens get scopes ("read", "support:write"); the old form is read
  only, and new-api-token takes --scope. Token names show as token:<name>.

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

300 lines
16 KiB
C#

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 }], 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 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}"))
.Distinct()
.ToList();
Assert.NotEmpty(endpoints);
Assert.All(endpoints, e => Assert.Contains(e, described));
}
}