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());
// Logins and people's names are redacted as in the rest of API v1 (ApiIncludesSensitiveEvidence is off).
var grant = await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}/grant-script"));
Assert.Equal(ApiV1Endpoints.Redacted, grant.GetProperty("login").GetString());
Assert.DoesNotContain("N'mon'", grant.GetProperty("script").GetString(), StringComparison.Ordinal);
Assert.Contains("SET @LoginName = N'", grant.GetProperty("script").GetString(), StringComparison.Ordinal);
var shown = await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}"));
Assert.Equal(ApiV1Endpoints.Redacted, shown.GetProperty("userName").GetString());
// Asking again while the agent has not taken the test yet returns the waiting one; the queue does not grow.
var again = await JsonAsync(await write.PostAsync($"/api/v1/monitored-instances/{id}/connection-test", Body(new { })));
var once = await JsonAsync(await write.PostAsync($"/api/v1/monitored-instances/{id}/connection-test", Body(new { })));
Assert.Equal(again.GetProperty("id").GetInt64(), once.GetProperty("id").GetInt64());
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());
}
}