sqlinsight-pro/tests/SQLInsight.IntegrationTests/ServerApiTests.cs
Søren Eggert Lundsteen Olsen 1c18bcfab8 Web: Windows-servere and Syntetiske tjek pages, and the same in API v1 (scope config:write)
- Menu group "Servere" with Windows-servere (/servere) and Syntetiske tjek
  (/syntetiske-tjek).
- /servere: every server with status (overvåges, delvist målbar, kan ikke læses),
  CPU, free memory, the fullest disk and the nearest "fuld om ca. N dage", findings;
  add application/AOS/file servers. /servere/{id}: findings and what is not
  measurable and why, CPU/memory for the last day, disks with forecasts, watched
  and stopped services, grouped event log errors, scheduled tasks, rights per area
  with the latest test, the least-privilege PowerShell script (copy or download,
  UTF-8 with BOM), role and service lists, pause and remove. /servere/fund/{id}.
- /syntetiske-tjek: checks with status and response time; one form for every kind
  (TCP, HTTP(S), SQL login + query, DNS, AX logon) with an optional server and
  service; a SQL login password is sealed for the agent at once and never shown.
  /syntetiske-tjek/{id}: latest result (incl. "kun porten er testet"), findings,
  response times, history, edit, run now, pause, remove.
- API v1: /windows-servers (+ test, grant-script, performance, disks, services,
  events, tasks, rule-states), /server-findings, /synthetic-checks (+ run, results,
  rule-states); all in OpenAPI. Passwords are write-only; event samples, failure
  messages and sensitive evidence are "[udeladt]" unless
  SQLInsight:Web:ApiIncludesSensitiveEvidence is on. /api/v1/rules lists the new rules.

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

232 lines
15 KiB
C#

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;
/// <summary>Windows servers and synthetic checks through API v1, with Windows authentication on and tokens with and without config:write.</summary>
[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<Program> _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<Program>().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<JsonElement> 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);
}
}
}