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.Configuration; using SQLInsight.Repository; using SQLInsight.Web.Api; namespace SQLInsight.IntegrationTests; /// The configuration through API v1 with Windows authentication on and bearer tokens with and without config:write. [Collection(SqlServerTestGroup.Name)] [Trait("Category", "Integration")] public sealed class InstanceSetupApiTests(SqlServerFixture sql) : IAsyncLifetime { private const string ReadToken = "sqli_test-config-read"; private const string WriteToken = "sqli_test-config-write"; private readonly string _database = "SQLInsightCfgApi_" + 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\svc-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 Everything_on_the_page_is_in_the_api_and_changing_needs_config_write() { var write = Client(WriteToken); var read = Client(ReadToken); var instance = new { server = @"sql01\PROD", authentication = "SqlLogin", userName = "mon", password = "Api-Hemmelig-1!", trustServerCertificate = true }; Assert.Equal(HttpStatusCode.Unauthorized, (await Client(null).GetAsync("/api/v1/monitored-instances")).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await read.PostAsync("/api/v1/monitored-instances", Body(instance))).StatusCode); var created = await write.PostAsync("/api/v1/monitored-instances", Body(instance)); Assert.Equal(HttpStatusCode.Created, created.StatusCode); var body = await JsonAsync(created); var id = body.GetProperty("instance").GetProperty("id").GetInt32(); var requestId = body.GetProperty("connectionTestRequestId").GetInt64(); Assert.True(body.GetProperty("instance").GetProperty("hasPassword").GetBoolean()); Assert.DoesNotContain("Api-Hemmelig", body.ToString(), StringComparison.Ordinal); var list = await JsonAsync(await read.GetAsync("/api/v1/monitored-instances")); Assert.Equal(@"sql01\PROD", Assert.Single(list.EnumerateArray()).GetProperty("name").GetString()); Assert.Equal("Pending", (await JsonAsync(await read.GetAsync($"/api/v1/agent-requests/{requestId}"))).GetProperty("status").GetString()); Assert.Equal(HttpStatusCode.Forbidden, (await read.PatchAsync($"/api/v1/monitored-instances/{id}", Body(new { paused = true }))).StatusCode); Assert.True((await JsonAsync(await write.PatchAsync($"/api/v1/monitored-instances/{id}", Body(new { paused = true })))).GetProperty("paused").GetBoolean()); Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/monitored-instances/{id}/connection-test", Body(new { }))).StatusCode); Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/monitored-instances/{id}/discovery", Body(new { }))).StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PutAsync($"/api/v1/monitored-instances/{id}/databases", Body(new { databases = new[] { new { name = "Sales", monitored = true } } }))).StatusCode); await _repository.SaveDiscoveredDatabasesAsync(id, [new DiscoveredDatabase { Name = "Sales", HasAccess = true }, new DiscoveredDatabase { Name = "AX30", HasAccess = true, IsDynamicsAx = true }], DateTimeOffset.UtcNow); var chosen = await JsonAsync(await write.PutAsync($"/api/v1/monitored-instances/{id}/databases", Body(new { monitorNewDatabases = true, databases = new object[] { new { name = "Sales", monitored = true }, new { name = "AX30", monitored = true, sendDynamicsAxToHub = true } } }))); Assert.Contains(chosen.EnumerateArray(), d => d.GetProperty("name").GetString() == "AX30" && d.GetProperty("sendDynamicsAxToHub").GetBoolean()); Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.MonitorNewDatabases); Assert.Equal(2, (await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}/databases"))).GetArrayLength()); var grant = await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}/grant-script")); Assert.Equal("mon", grant.GetProperty("login").GetString()); Assert.Contains("SET @LoginName = N'mon';", grant.GetProperty("script").GetString(), StringComparison.Ordinal); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PostAsync("/api/v1/instance-discovery", Body(new { host = @"sql01\PROD" }))).StatusCode); Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync("/api/v1/instance-discovery", Body(new { host = "sql01.firma.local" }))).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await read.DeleteAsync($"/api/v1/monitored-instances/{id}")).StatusCode); Assert.Equal(HttpStatusCode.NoContent, (await write.DeleteAsync($"/api/v1/monitored-instances/{id}")).StatusCode); Assert.Empty((await JsonAsync(await read.GetAsync("/api/v1/monitored-instances"))).EnumerateArray()); } [Fact] public async Task A_configuration_file_instance_cannot_be_changed_or_removed_through_the_api() { var id = (await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false)], DateTimeOffset.UtcNow))["prod-sql01"]; var write = Client(WriteToken); Assert.Equal(HttpStatusCode.Conflict, (await write.DeleteAsync($"/api/v1/monitored-instances/{id}")).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await write.PutAsync($"/api/v1/monitored-instances/{id}/connection", Body(new { server = "evil", authentication = "Windows" }))).StatusCode); Assert.Equal("configFile", (await JsonAsync(await write.GetAsync($"/api/v1/monitored-instances/{id}"))).GetProperty("source").GetString()); } }