using Microsoft.Data.SqlClient;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
///
/// 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.
///
[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);
/// A login with db_datareader only, like SQLInsight Web's.
private async Task 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(() => web.CompleteAgentRequestAsync(request, true, "{}", null, now));
await Assert.ThrowsAsync(() => web.SaveDiscoveredDatabasesAsync(id, [Db("Sales")], now));
await Assert.ThrowsAsync(() => web.PublishAgentKeyAsync(new AgentKeyRow { KeyId = "x", PublicKey = [1], MachineName = "m", ServiceAccount = "a", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow }));
await Assert.ThrowsAsync(() => web.SyncConfigFileInstancesAsync([], now));
await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"DELETE FROM insight.ConfiguredInstances WHERE ConfiguredInstanceId = {id}"));
await Assert.ThrowsAsync(() => ExecuteAsWebAsync($"UPDATE insight.ConfiguredInstances SET Source = 'ConfigFile' WHERE ConfiguredInstanceId = {id}"));
await Assert.ThrowsAsync(() => 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(() => 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 deleted.
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));
}
[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();
}
}