using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using SQLInsight.Core.Findings; using SQLInsight.Core.Servers; using SQLInsight.Core.SyntheticChecks; using SQLInsight.Repository; using SQLInsight.Rules.Engine; using SQLInsight.Web.Api; namespace SQLInsight.IntegrationTests; /// Windows servers and synthetic checks through API v1, with Windows authentication on and tokens with and without config:write. [Collection(SqlServerTestGroup.Name)] [Trait("Category", "Integration")] public sealed class ServerApiTests(SqlServerFixture sql) : IAsyncLifetime { private const string ReadToken = "sqli_test-server-read"; private const string WriteToken = "sqli_test-server-write"; private readonly string _database = "SQLInsightSrvApi_" + Guid.NewGuid().ToString("N")[..8]; private WebApplicationFactory _factory = null!; private InsightRepository _repository = 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}];"); _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); await _repository.MigrateAsync(); using var rsa = RSA.Create(3072); await _repository.PublishAgentKeyAsync(new AgentKeyRow { KeyId = "agent-api", PublicKey = rsa.ExportSubjectPublicKeyInfo(), MachineName = "AGENT01", ServiceAccount = @"FIRMA\gmsa-sqlinsight$", Domain = "FIRMA", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow, }); _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:AdminGroups:0", @"FIRMA\SQL-Admins"); b.UseSetting("SQLInsight:Web:ApiTokens:grafana", "sha256:" + ApiTokenHandler.Hash(ReadToken)); b.UseSetting("SQLInsight:Web:ApiTokens:automation:hash", "sha256:" + ApiTokenHandler.Hash(WriteToken)); b.UseSetting("SQLInsight:Web:ApiTokens:automation:scopes:0", "read"); b.UseSetting("SQLInsight:Web:ApiTokens:automation:scopes:1", "config:write"); b.UseSetting("urls", $"http://127.0.0.1:{_port}"); }); _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; } private static StringContent Body(object value) => new(JsonSerializer.Serialize(value), Encoding.UTF8, "application/json"); private static async Task JsonAsync(HttpResponseMessage response) => JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; [Fact] public async Task Windows_servers_in_the_api_and_changing_needs_config_write() { var write = Client(WriteToken); var read = Client(ReadToken); var server = new { host = "aos01.firma.local", role = "Application", watchedServices = new[] { "AOS60$01" } }; Assert.Equal(HttpStatusCode.Unauthorized, (await Client(null).GetAsync("/api/v1/windows-servers")).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await read.PostAsync("/api/v1/windows-servers", Body(server))).StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PostAsync("/api/v1/windows-servers", Body(new { host = @"aos01\x" }))).StatusCode); var created = await write.PostAsync("/api/v1/windows-servers", Body(server)); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var body = await JsonAsync(created); var id = body.GetProperty("server").GetProperty("id").GetInt32(); Assert.Equal("AOS01", body.GetProperty("server").GetProperty("name").GetString()); Assert.True(body.GetProperty("serverTestRequestId").GetInt64() > 0); var now = DateTimeOffset.UtcNow; await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(now.AddMinutes(-1), 42, 6000, 16384, 3, 50)); await _repository.SaveServerSnapshotAsync(id, ServerArea.Disks, new ServerDiskSnapshot { CapturedAt = now, Disks = [new ServerDisk(@"D:\", "Data", "NTFS", 500, 40)], Forecasts = [new DiskForecast(@"D:\", 12, 3.3, 300, 14, 0.9)], }, now); await _repository.SaveServerSnapshotAsync(id, ServerArea.Services, new ServerServiceSnapshot { CapturedAt = now, Services = [new ServerService("AOS60$01", "AOS", "Running", "Auto")] }, now); await _repository.SaveServerSnapshotAsync(id, ServerArea.Tasks, new ServerTaskSnapshot { CapturedAt = now, Visible = 120, Tasks = [new ServerTask { Path = @"\", Name = "AX export", State = "Ready", LastTaskResult = 1 }], }, now); var (groups, _) = ServerEventAnalysis.Group([new ServerEventEntry("System", "disk", 153, 2, now.AddMinutes(-3), @"Disk 1 på \\fil01\hemmeligt")], []); await _repository.SaveServerEventsAsync(id, new ServerEventSnapshot { CapturedAt = now, ReadUntil = now, Groups = groups }); await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, id, [ new RuleResult { RuleId = "SQLI-WIN-009", RuleTitle = "Fejl i Windows' hændelseslog", Outcome = RuleOutcome.Findings, Findings = [new Finding { RuleId = "SQLI-WIN-009", Severity = Severity.High, Title = "Disk og filsystem: disk 153", Explanation = "e", Recommendation = "r", Subject = "event: System/disk/153", Evidence = [new Evidence("Kilde", "disk"), new Evidence("Eksempel", @"Disk 1 på \\fil01\hemmeligt", Sensitive: true)], }], }, ], now); var list = await JsonAsync(await read.GetAsync("/api/v1/windows-servers")); Assert.Equal("aos01.firma.local", Assert.Single(list.EnumerateArray()).GetProperty("host").GetString()); Assert.Equal(1, (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/performance"))).GetArrayLength()); var disks = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/disks")); Assert.Equal("fuld om ca. 12 dage", disks.GetProperty("disks")[0].GetProperty("forecast").GetProperty("text").GetString()); Assert.Equal("AOS60$01", (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/services"))).GetProperty("services")[0].GetProperty("name").GetString()); var tasks = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/tasks")); Assert.True(tasks.GetProperty("tasks")[0].GetProperty("failed").GetBoolean()); var events = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/events")); Assert.Equal(ApiV1Endpoints.Redacted, events[0].GetProperty("sample").GetString()); var findings = await JsonAsync(await read.GetAsync($"/api/v1/server-findings?scope=server&id={id}")); var finding = Assert.Single(findings.EnumerateArray()); Assert.DoesNotContain("hemmeligt", finding.ToString(), StringComparison.Ordinal); var findingId = finding.GetProperty("id").GetInt64(); Assert.Equal("SQLI-WIN-009", (await JsonAsync(await read.GetAsync($"/api/v1/server-findings/{findingId}"))).GetProperty("ruleId").GetString()); Assert.Equal(HttpStatusCode.BadRequest, (await read.GetAsync("/api/v1/server-findings?id=1")).StatusCode); var states = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/rule-states")); Assert.Equal("findings", Assert.Single(states.EnumerateArray()).GetProperty("outcome").GetString()); var grant = await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/grant-script")); Assert.Equal(@"FIRMA\gmsa-sqlinsight$", grant.GetProperty("account").GetString()); Assert.Contains(@"$Account = 'FIRMA\gmsa-sqlinsight$'", grant.GetProperty("script").GetString(), StringComparison.Ordinal); Assert.Equal(HttpStatusCode.Forbidden, (await read.PatchAsync($"/api/v1/windows-servers/{id}", Body(new { paused = true }))).StatusCode); var patched = await JsonAsync(await write.PatchAsync($"/api/v1/windows-servers/{id}", Body(new { paused = true, ignoredServices = Enumerable.Repeat("MyBatch", 1) }))); Assert.True(patched.GetProperty("paused").GetBoolean()); Assert.Equal("MyBatch", patched.GetProperty("ignoredServices")[0].GetString()); Assert.Equal("AOS60$01", patched.GetProperty("watchedServices")[0].GetString()); Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/windows-servers/{id}/test", Body(new { }))).StatusCode); Assert.Equal("Pending", (await JsonAsync(await read.GetAsync($"/api/v1/windows-servers/{id}/test"))).GetProperty("request").GetProperty("status").GetString()); await _repository.SyncSqlHostServersAsync([("sql01.firma.local", "SQL01")], now); var sqlHost = (await _repository.GetWindowsServersAsync()).Single(s => s.IsSqlHost).ServerId; Assert.Equal(HttpStatusCode.Conflict, (await write.DeleteAsync($"/api/v1/windows-servers/{sqlHost}")).StatusCode); Assert.Equal(HttpStatusCode.NoContent, (await write.DeleteAsync($"/api/v1/windows-servers/{id}")).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await read.GetAsync($"/api/v1/windows-servers/{id}")).StatusCode); } [Fact] public async Task Synthetic_checks_in_the_api_with_write_only_passwords_and_redacted_failures() { var write = Client(WriteToken); var read = Client(ReadToken); var check = new { name = "SQL01 login", kind = "Sql", intervalSeconds = 120, settings = new { server = @"sql01\AX", authentication = "SqlLogin", userName = "axapp", database = "AX2012" }, password = "Api-Hemmelig-2!", }; Assert.Equal(HttpStatusCode.Forbidden, (await read.PostAsync("/api/v1/synthetic-checks", Body(check))).StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PostAsync("/api/v1/synthetic-checks", Body(new { name = "x", kind = "Sql", settings = new { server = "sql01", query = "DELETE FROM dbo.x" } }))).StatusCode); var created = await write.PostAsync("/api/v1/synthetic-checks", Body(check)); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var body = await JsonAsync(created); Assert.DoesNotContain("Api-Hemmelig", body.ToString(), StringComparison.Ordinal); var id = body.GetProperty("check").GetProperty("id").GetInt32(); Assert.True(body.GetProperty("check").GetProperty("hasPassword").GetBoolean()); Assert.Equal(@"sql01\AX / AX2012", body.GetProperty("check").GetProperty("target").GetString()); Assert.True(body.GetProperty("runRequestId").GetInt64() > 0); await _repository.RecordSyntheticCheckResultAsync(id, new SyntheticCheckResult { At = DateTimeOffset.UtcNow, Success = false, DurationMs = 40, Message = "Login på SQL Server fejlede: Login failed for user 'axapp'.", Details = [new SyntheticCheckDetail("Server", @"sql01\AX"), new SyntheticCheckDetail("Fejl", "axapp", Sensitive: true)], }); var results = await JsonAsync(await read.GetAsync($"/api/v1/synthetic-checks/{id}/results")); var result = Assert.Single(results.EnumerateArray()); Assert.Equal(ApiV1Endpoints.Redacted, result.GetProperty("message").GetString()); Assert.Equal(@"sql01\AX", result.GetProperty("details")[0].GetProperty("value").GetString()); Assert.Equal(ApiV1Endpoints.Redacted, result.GetProperty("details")[1].GetProperty("value").GetString()); Assert.Equal(ApiV1Endpoints.Redacted, (await JsonAsync(await read.GetAsync($"/api/v1/synthetic-checks/{id}"))).GetProperty("lastMessage").GetString()); var changed = await write.PutAsync($"/api/v1/synthetic-checks/{id}", Body(new { name = "SQL01 login", settings = new { server = @"sql01\AX", authentication = "SqlLogin", userName = "axapp", database = "AX2012", warnMilliseconds = 500 }, })); Assert.Equal(HttpStatusCode.OK, changed.StatusCode); Assert.Equal(500, (await JsonAsync(changed)).GetProperty("settings").GetProperty("warnMilliseconds").GetInt32()); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PutAsync($"/api/v1/synthetic-checks/{id}", Body(new { name = "SQL01 login", settings = new { server = "sql02", authentication = "SqlLogin", userName = "axapp" }, }))).StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PutAsync($"/api/v1/synthetic-checks/{id}", Body(new { name = "SQL01 login", kind = "Tcp" }))).StatusCode); Assert.True((await JsonAsync(await write.PatchAsync($"/api/v1/synthetic-checks/{id}", Body(new { paused = true })))).GetProperty("paused").GetBoolean()); Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/synthetic-checks/{id}/run", Body(new { }))).StatusCode); Assert.Equal(HttpStatusCode.OK, (await read.GetAsync($"/api/v1/synthetic-checks/{id}/rule-states")).StatusCode); Assert.Single((await JsonAsync(await read.GetAsync("/api/v1/synthetic-checks"))).EnumerateArray()); Assert.Contains((await JsonAsync(await read.GetAsync("/api/v1/rules"))).EnumerateArray(), r => r.GetProperty("id").GetString() == "SQLI-SYN-001"); Assert.Equal(HttpStatusCode.Forbidden, (await read.DeleteAsync($"/api/v1/synthetic-checks/{id}")).StatusCode); Assert.Equal(HttpStatusCode.NoContent, (await write.DeleteAsync($"/api/v1/synthetic-checks/{id}")).StatusCode); } [Fact] public async Task Openapi_describes_the_server_and_check_endpoints() { var document = await JsonAsync(await Client(ReadToken).GetAsync("/api/v1/openapi.json")); var paths = document.GetProperty("paths").EnumerateObject().Select(p => p.Name).ToList(); foreach (var path in new[] { "/api/v1/windows-servers", "/api/v1/windows-servers/{id}/disks", "/api/v1/server-findings", "/api/v1/synthetic-checks/{id}/run", "/api/v1/synthetic-checks/{id}/results" }) { Assert.Contains(path, paths); } } }