- POST/GET /api/v1/support-cases[/{id}] and POST .../{id}/close in
SQLInsight Web, sharing the /support form's logic: same checks, finding
prefill without sensitive evidence, Idempotency-Key as the request id,
202 Accepted with Location, problem+json 403 without the support:write
scope or a support license. RequestedBy is the Windows user or
token:<name>.
- Closing: "Luk sag" on /support and the API record CloseRequested
(migration 0012, column-level UPDATE for the read-only web login); the
agent sends it to the hub with backoff and sets Closed when confirmed;
a pending case is closed once it has a number; an older hub list never
turns CloseRequested back to Open.
- Hub: POST /api/v1/cases/{reference}/close for the license's own cases
(others 404), idempotent, ClosedBy/ClosedComment (EF migration
CaseClosedBy). Portal closing records "SEOSoft: <user>"; the customer
page shows who closed and why; the support report and CSV get "Lukket af".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
300 lines
16 KiB
C#
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}").TrimEnd('/'))
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
Assert.NotEmpty(endpoints);
|
|
Assert.All(endpoints, e => Assert.Contains(e, described));
|
|
}
|
|
}
|