API v1 returns logins and people's names as [udeladt] unless ApiIncludesSensitiveEvidence is on, but /monitored-instances and /agent-requests returned the SQL login, createdBy/updatedBy/requestedBy/selectionChangedBy, the login the agent arrived as and 'Login failed for user ...' texts, and the GRANT script filled in with the login. They are now redacted the same way (the GRANT script falls back to the shipped template). ChooseAsync also answers 404 for an unknown instance instead of 422. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
147 lines
9.3 KiB
C#
147 lines
9.3 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());
|
|
|
|
// 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());
|
|
}
|
|
}
|