sqlinsight-pro/tests/SQLInsight.IntegrationTests/InstanceSetupApiTests.cs
Søren Eggert Lundsteen Olsen f3ef0cbec7 Web: Servere og databaser, and the same in API v1 (scope config:write)
A page under Overvågning lists the monitored servers (from SQLInsight Web and,
read-only, from the agent's configuration file), adds a server (name\instance,
optional port, Windows via the agent's account or a SQL login, encryption),
pauses, resumes and removes it, and finds instances via the agent (SQL Server
Browser). Each server's page shows the agent's connection test with the rights
it has and lacks, the least-privilege GRANT script for the login the agent
arrives as, and the discovered databases (state, size, recovery model,
compatibility level, Dynamics AX) to tick for monitoring, with "overvåg
automatisk nye databaser" and AX findings to the hub per database. Pages
refresh while the agent works on a request.

Only SQLInsight:Web:AdminGroups (new) may change anything; others see it.
SQL login passwords are sealed with the agent's public key on arrival and
never shown or returned; a new server or login needs the password again.

API v1: /monitored-instances (list, add, get, patch pause/monitor-new,
connection, delete), …/connection-test (start, latest with missing
permissions), …/discovery, …/databases (read, choose), …/grant-script,
/instance-discovery and /agent-requests/{id}. Changes need the new token scope
config:write, or AdminGroups for Windows users.

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

138 lines
8.5 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.Configuration;
using SQLInsight.Repository;
using SQLInsight.Web.Api;
namespace SQLInsight.IntegrationTests;
/// <summary>The configuration through API v1 with Windows authentication on and bearer tokens with and without config:write.</summary>
[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<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\svc-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 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());
}
}