sqlinsight-pro/tests/SQLInsight.IntegrationTests/InstanceConfigurationRepositoryTests.cs
Søren Eggert Lundsteen Olsen 4c65248727 Review 1.3: an instance removed from the configuration file is marked removed, not deleted
The agent deleted ConfigFile rows no longer in the file, and with them (cascade)
the pause and the database choices: commenting an instance out for a restart lost
them. It is now soft-deleted like a Web instance and brought back, choices and
all, when it returns to the file.

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

202 lines
13 KiB
C#

using Microsoft.Data.SqlClient;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
/// <summary>
/// The instance configuration in the repository (0016): what SQLInsight Web may change with its read-only login and
/// the few grants it has, the configuration file mirrored as read-only instances, discovery results and the request queue.
/// </summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class InstanceConfigurationRepositoryTests(SqlServerFixture sql) : IAsyncLifetime
{
private readonly string _database = "SQLInsightCfg_" + Guid.NewGuid().ToString("N")[..8];
private InsightRepository _repository = null!;
private string _webConnectionString = string.Empty;
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
}
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
private static NewConfiguredInstance Instance(string name, bool monitorNew = false) =>
new(name, "sql01\\PROD", 1450, "SqlLogin", "sqlinsight", [1, 2, 3], "key-1", "Mandatory", false, monitorNew);
/// <summary>A login with db_datareader only, like SQLInsight Web's.</summary>
private async Task<InsightRepository> ReadOnlyRepositoryAsync()
{
var login = "web_" + Guid.NewGuid().ToString("N")[..8];
const string password = "Read-Only-Login-1!";
await sql.ExecuteAsSaAsync($"""
CREATE LOGIN [{login}] WITH PASSWORD = N'{password}', CHECK_POLICY = OFF;
USE [{_database}]; CREATE USER [{login}] FOR LOGIN [{login}]; ALTER ROLE db_datareader ADD MEMBER [{login}];
""");
_webConnectionString = new SqlConnectionStringBuilder(sql.DatabaseConnectionString(_database))
{
UserID = login, Password = password, IntegratedSecurity = false,
}.ConnectionString;
return new InsightRepository(_webConnectionString);
}
private static DiscoveredDatabase Db(string name, bool ax = false, bool system = false) =>
new() { Name = name, DatabaseId = 5, State = "ONLINE", SizeMb = 100, RecoveryModel = "FULL", CompatibilityLevel = 150, HasAccess = true, IsSystem = system, IsDynamicsAx = ax, DynamicsAxVersion = ax ? "3.0" : null };
[Fact]
public async Task Web_login_can_change_the_configuration_and_queue_requests_but_nothing_the_agent_records()
{
var web = await ReadOnlyRepositoryAsync();
var now = DateTimeOffset.UtcNow;
var id = await web.AddConfiguredInstanceAsync(Instance("prod-sql01"), @"FIRMA\anna", now);
Assert.Equal(ConfigurationChangeOutcome.Changed, await web.UpdateConfiguredInstanceAsync(id,
new ConfiguredInstanceChange("sql02", null, "Windows", null, null, null, ClearPassword: true, "Optional", true), @"FIRMA\anna", now));
Assert.Equal(ConfigurationChangeOutcome.Changed, await web.SetInstancePausedAsync(id, true, @"FIRMA\anna", now));
Assert.Equal(ConfigurationChangeOutcome.Changed, await web.SetMonitorNewDatabasesAsync(id, true, @"FIRMA\anna", now));
var request = await web.AddAgentRequestAsync(AgentRequestKind.ConnectionTest, id, null, @"FIRMA\anna", now);
var row = (await _repository.GetConfiguredInstanceAsync(id))!;
Assert.Equal(("sql02", "Windows", false, true, true, InstanceSource.Web), (row.Server, row.Authentication, row.HasPassword, row.Paused, row.MonitorNewDatabases, row.Source));
Assert.Equal(AgentRequestStatus.Pending, (await web.GetAgentRequestAsync(request))!.Status);
// What only the agent does is refused for the web login.
await Assert.ThrowsAsync<SqlException>(() => web.CompleteAgentRequestAsync(request, true, "{}", null, now));
await Assert.ThrowsAsync<SqlException>(() => web.SaveDiscoveredDatabasesAsync(id, [Db("Sales")], now));
await Assert.ThrowsAsync<SqlException>(() => web.PublishAgentKeyAsync(new AgentKeyRow { KeyId = "x", PublicKey = [1], MachineName = "m", ServiceAccount = "a", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow }));
await Assert.ThrowsAsync<SqlException>(() => ExecuteAsWebAsync($"DELETE FROM insight.ConfiguredInstances WHERE ConfiguredInstanceId = {id}"));
await Assert.ThrowsAsync<SqlException>(() => ExecuteAsWebAsync($"UPDATE insight.ConfiguredInstances SET Source = 'ConfigFile' WHERE ConfiguredInstanceId = {id}"));
await Assert.ThrowsAsync<SqlException>(() => ExecuteAsWebAsync($"UPDATE insight.ConfiguredInstances SET Name = N'x' WHERE ConfiguredInstanceId = {id}"));
Assert.Equal(ConfigurationChangeOutcome.Changed, await web.RemoveConfiguredInstanceAsync(id, @"FIRMA\anna", now));
Assert.Null(await _repository.GetConfiguredInstanceAsync(id));
Assert.Empty(await _repository.GetConfiguredInstancesAsync());
}
[Fact]
public async Task Web_login_chooses_databases_the_agent_found_and_ax_to_hub_sticks_only_to_ax_databases()
{
var web = await ReadOnlyRepositoryAsync();
var now = DateTimeOffset.UtcNow;
var id = await web.AddConfiguredInstanceAsync(Instance("ax-sql"), null, now);
await _repository.SaveDiscoveredDatabasesAsync(id, [Db("master", system: true), Db("AX30", ax: true), Db("Sales")], now);
var unknown = await web.SetDatabaseChoicesAsync(id,
[
new DatabaseChoice("AX30", true, true), new DatabaseChoice("Sales", true, true), new DatabaseChoice("master", false, false), new DatabaseChoice("Nope", true, false),
], @"FIRMA\anna", now);
Assert.Equal(["Nope"], unknown);
var databases = (await _repository.GetConfiguredDatabasesAsync(id)).ToDictionary(d => d.DatabaseName);
Assert.Equal((true, true), (databases["AX30"].Monitored, databases["AX30"].SendDynamicsAxToHub));
Assert.Equal((true, false), (databases["Sales"].Monitored, databases["Sales"].SendDynamicsAxToHub)); // not AX: never to the hub
Assert.True(databases["master"].Monitored); // system databases are always monitored
Assert.Equal(@"FIRMA\anna", databases["AX30"].SelectionChangedBy);
await Assert.ThrowsAsync<SqlException>(() => ExecuteAsWebAsync($"UPDATE insight.ConfiguredDatabases SET IsDynamicsAx = 1 WHERE ConfiguredInstanceId = {id}"));
}
[Fact]
public async Task Discovery_adds_new_databases_by_the_instance_setting_and_marks_the_ones_gone()
{
var now = DateTimeOffset.UtcNow;
var manual = await _repository.AddConfiguredInstanceAsync(Instance("manual"), null, now);
var automatic = await _repository.AddConfiguredInstanceAsync(Instance("automatic", monitorNew: true), null, now);
var first = await _repository.SaveDiscoveredDatabasesAsync(manual, [Db("master", system: true), Db("Sales"), Db("Old")], now);
await _repository.SaveDiscoveredDatabasesAsync(automatic, [Db("Sales")], now);
var second = await _repository.SaveDiscoveredDatabasesAsync(manual, [Db("master", system: true), Db("Sales"), Db("New")], now.AddHours(1));
Assert.Equal(["master", "Sales", "Old"], first.New);
Assert.Equal(["New"], second.New);
Assert.Equal(["Old"], second.Gone);
var databases = (await _repository.GetConfiguredDatabasesAsync(manual)).ToDictionary(d => d.DatabaseName);
Assert.Equal((true, false, false), (databases["master"].Monitored, databases["Sales"].Monitored, databases["New"].Monitored));
Assert.True(databases["Old"].Gone);
Assert.True((await _repository.GetConfiguredDatabasesAsync(automatic)).Single().Monitored);
Assert.NotNull((await _repository.GetConfiguredInstanceAsync(manual))!.LastDiscoveryAt);
}
[Fact]
public async Task Configuration_file_instances_are_mirrored_read_only_and_win_over_a_web_instance_with_the_same_name()
{
var now = DateTimeOffset.UtcNow;
var web = await _repository.AddConfiguredInstanceAsync(Instance("prod-sql02"), null, now);
var ids = await _repository.SyncConfigFileInstancesAsync(
[
new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false),
new ConfigFileInstance("prod-sql02", "sql02", "SqlLogin", "mon", "Optional", true),
], now);
var rows = (await _repository.GetConfiguredInstancesAsync()).ToDictionary(r => r.Name);
Assert.Equal(2, rows.Count);
Assert.All(rows.Values, r => Assert.True(r.FromConfigFile && r.MonitorNewDatabases && !r.HasPassword));
Assert.Null(await _repository.GetConfiguredInstanceAsync(web));
Assert.Equal(ConfigurationChangeOutcome.FromConfigFile, await _repository.RemoveConfiguredInstanceAsync(ids["prod-sql01"], null, now));
Assert.Equal(ConfigurationChangeOutcome.FromConfigFile, await _repository.UpdateConfiguredInstanceAsync(ids["prod-sql01"],
new ConfiguredInstanceChange("evil", null, "Windows", null, null, null, false, "Mandatory", false), null, now));
Assert.Equal(ConfigurationChangeOutcome.Changed, await _repository.SetInstancePausedAsync(ids["prod-sql01"], true, null, now));
// Started again with one instance changed and one gone: the same row is kept (with its choices), the other marked removed.
var again = await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01b", "Windows", null, "Mandatory", false)], now);
Assert.Equal(ids["prod-sql01"], again["prod-sql01"]);
var row = Assert.Single(await _repository.GetConfiguredInstancesAsync());
Assert.Equal(("sql01b", true), (row.Server, row.Paused));
Assert.Null(await _repository.GetConfiguredInstanceAsync(ids["prod-sql02"]));
// Put back in the file: the same row comes back, nothing is lost and no second row is made.
var back = await _repository.SyncConfigFileInstancesAsync(
[
new ConfigFileInstance("prod-sql01", "sql01b", "Windows", null, "Mandatory", false),
new ConfigFileInstance("prod-sql02", "sql02", "SqlLogin", "mon", "Optional", true),
], now);
Assert.Equal(ids["prod-sql02"], back["prod-sql02"]);
Assert.Equal(2, (await _repository.GetConfiguredInstancesAsync()).Count);
}
[Fact]
public async Task A_request_is_claimed_once_and_stale_ones_are_failed_with_a_reason()
{
var now = DateTimeOffset.UtcNow;
var first = await _repository.AddAgentRequestAsync(AgentRequestKind.InstanceDiscovery, null, "{\"host\":null}", "anna", now);
var second = await _repository.AddAgentRequestAsync(AgentRequestKind.InstanceDiscovery, null, null, "anna", now);
var claimed = await Task.WhenAll(_repository.ClaimAgentRequestAsync(now), _repository.ClaimAgentRequestAsync(now));
Assert.Equal([first, second], claimed.Select(c => c!.RequestId).Order());
Assert.Null(await _repository.ClaimAgentRequestAsync(now));
await _repository.CompleteAgentRequestAsync(first, true, "{\"instances\":[]}", null, now);
var third = await _repository.AddAgentRequestAsync(AgentRequestKind.InstanceDiscovery, null, null, "anna", now.AddHours(-2));
await _repository.CleanUpAgentRequestsAsync(now.AddMinutes(30), TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(30), TimeSpan.FromDays(30));
Assert.Equal(AgentRequestStatus.Done, (await _repository.GetAgentRequestAsync(first))!.Status);
Assert.Contains("stoppet", (await _repository.GetAgentRequestAsync(second))!.Error, StringComparison.Ordinal);
Assert.Contains("tog ikke anmodningen op", (await _repository.GetAgentRequestAsync(third))!.Error, StringComparison.Ordinal);
Assert.Equal(third, (await _repository.GetLatestAgentRequestAsync(null, AgentRequestKind.InstanceDiscovery))!.RequestId);
}
[Fact]
public async Task The_newest_agent_key_is_the_one_passwords_are_sealed_with()
{
var old = DateTime.UtcNow.AddDays(-1);
await _repository.PublishAgentKeyAsync(new AgentKeyRow { KeyId = "old", PublicKey = [1], MachineName = "AGENT00", ServiceAccount = "a", CreatedAt = old, LastSeenAt = old });
await _repository.PublishAgentKeyAsync(new AgentKeyRow { KeyId = "new", PublicKey = [2], MachineName = "AGENT01", ServiceAccount = @"NT SERVICE\SQLInsight Agent", Domain = "FIRMA", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow });
var key = await (await ReadOnlyRepositoryAsync()).GetCurrentAgentKeyAsync();
Assert.Equal(("new", "AGENT01", "FIRMA"), (key!.KeyId, key.MachineName, key.Domain));
}
private async Task ExecuteAsWebAsync(string sql)
{
await using var connection = new SqlConnection(_webConnectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync();
}
}