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>
202 lines
13 KiB
C#
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();
|
|
}
|
|
}
|