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(() => 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 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 The_overview_knows_which_instances_are_paused_or_removed() { var now = DateTimeOffset.UtcNow; var paused = await _repository.AddConfiguredInstanceAsync(Instance("paused-sql"), null, now); var removed = await _repository.AddConfiguredInstanceAsync(Instance("removed-sql"), null, now); foreach (var name in new[] { "paused-sql", "removed-sql", "before-1.3" }) { await _repository.RecordInstanceErrorAsync(name, "x", now); } await _repository.SetInstancePausedAsync(paused, true, null, now); await _repository.RemoveConfiguredInstanceAsync(removed, null, now); var overview = (await _repository.GetInstancesAsync()).ToDictionary(i => i.Name); Assert.Equal((true, false), (overview["paused-sql"].Paused, overview["paused-sql"].Removed)); Assert.Equal((false, true), (overview["removed-sql"].Paused, overview["removed-sql"].Removed)); Assert.True(overview["before-1.3"].IsMonitored); // Added again under the same name: monitored again, with its history. await _repository.AddConfiguredInstanceAsync(Instance("removed-sql"), null, now); Assert.True((await _repository.GetInstanceAsync("removed-sql"))!.IsMonitored); } [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(); } }