using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SQLInsight.Agent; using SQLInsight.Agent.Configuration; using SQLInsight.Connectivity; using SQLInsight.Core.Configuration; using SQLInsight.Repository; namespace SQLInsight.IntegrationTests; /// /// The agent's side of the instance configuration: requests from SQLInsight Web run as the agent's login against a /// real instance, passwords sealed by the web UI are opened only by the agent, and the configuration file keeps working. /// [Collection(SqlServerTestGroup.Name)] [Trait("Category", "Integration")] public sealed class AgentRequestTests(SqlServerFixture sql) : IAsyncLifetime { private readonly string _database = "SQLInsightReq_" + Guid.NewGuid().ToString("N")[..8]; private readonly string _keys = Path.Combine(Path.GetTempPath(), "sqlinsight-keys-" + Guid.NewGuid().ToString("N")); private InsightRepository _repository = null!; private AgentCredentialKey _key = null!; private AgentOptions _options = new(); public async Task InitializeAsync() { await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); await _repository.MigrateAsync(); _key = AgentCredentialKey.LoadOrCreate(_keys); await _repository.PublishAgentKeyAsync(_key.Describe(DateTimeOffset.UtcNow)); } public async Task DisposeAsync() { _key.Dispose(); Directory.Delete(_keys, recursive: true); await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); } private InstanceConfigurationStore Store() => new(Options.Create(_options), _repository, _key, NullLogger.Instance); private AgentRequestService Service() => new(_repository, Store(), _key, new TrustingFactory(), new RepositoryReadiness(), TimeProvider.System, NullLogger.Instance); /// What SQLInsight Web does: seal the password with the agent's published key. private async Task AddAsWebAsync(string name, string password, string? server = null) { var key = (await _repository.GetCurrentAgentKeyAsync())!; server ??= sql.Server; var cipher = CredentialEnvelope.Seal(password, key.KeyId, key.PublicKey, CredentialEnvelope.Context(server, SqlServerFixture.AgentLogin)); return await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance(name, server, null, "SqlLogin", SqlServerFixture.AgentLogin, cipher, key.KeyId, "Mandatory", TrustServerCertificate: true, MonitorNewDatabases: false), "anna", DateTimeOffset.UtcNow); } private async Task RunAsync(string kind, int? instanceId, string? parameters = null) { var id = await _repository.AddAgentRequestAsync(kind, instanceId, parameters, "anna", DateTimeOffset.UtcNow); var claimed = await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow); Assert.Equal(id, claimed!.RequestId); await Service().HandleAsync(claimed, CancellationToken.None); return (await _repository.GetAgentRequestAsync(id))!; } [Fact] public async Task Connection_test_and_discovery_run_as_the_agent_with_the_password_only_it_can_open() { var id = await AddAsWebAsync("prod-sql01", SqlServerFixture.AgentPassword); var test = await RunAsync(AgentRequestKind.ConnectionTest, id); var discovery = await RunAsync(AgentRequestKind.DatabaseDiscovery, id); Assert.Equal(AgentRequestStatus.Done, test.Status); var result = AgentRequestJson.Deserialize(test.Result)!; Assert.True(result.Connected); Assert.Equal((SqlServerFixture.AgentLogin, false, true), (result.Login, result.IsSysadmin, result.PermissionsChecked)); Assert.Empty(result.MissingRequired); Assert.DoesNotContain(SqlServerFixture.AgentPassword, test.Result, StringComparison.Ordinal); Assert.Equal(AgentRequestStatus.Done, discovery.Status); var databases = await _repository.GetConfiguredDatabasesAsync(id); Assert.Contains(databases, d => d.DatabaseName == SqlServerFixture.TestDatabase && !d.Monitored && d.HasAccess); Assert.Contains(databases, d => d.DatabaseName == "master" && d.Monitored); } [Fact] public async Task A_rejected_login_is_a_result_with_a_hint_and_a_moved_server_needs_the_password_again() { var wrong = await AddAsWebAsync("wrong-password", "Not-The-Password-1!"); var test = AgentRequestJson.Deserialize((await RunAsync(AgentRequestKind.ConnectionTest, wrong)).Result)!; Assert.False(test.Connected); Assert.Contains("adgangskoden blev afvist", test.Hint, StringComparison.Ordinal); // Someone points the instance at another server without typing the password again: the agent will not send it. var moved = await AddAsWebAsync("moved", SqlServerFixture.AgentPassword); await _repository.UpdateConfiguredInstanceAsync(moved, new ConfiguredInstanceChange("evil.example", null, "SqlLogin", SqlServerFixture.AgentLogin, null, null, false, "Mandatory", true), "mallory", DateTimeOffset.UtcNow); var refused = await RunAsync(AgentRequestKind.ConnectionTest, moved); Assert.Equal(AgentRequestStatus.Failed, refused.Status); Assert.Contains("kan ikke dekrypteres", refused.Error, StringComparison.Ordinal); Assert.NotNull((await _repository.GetConfiguredInstanceAsync(moved))!.CredentialError); } [Fact] public async Task Instance_discovery_on_the_agent_computer_completes() { var request = await RunAsync(AgentRequestKind.InstanceDiscovery, null, AgentRequestJson.Serialize(new InstanceDiscoveryParameters(null))); Assert.Equal(AgentRequestStatus.Done, request.Status); Assert.Equal(Environment.MachineName, AgentRequestJson.Deserialize(request.Result)!.Host); } [Fact] public async Task Configuration_file_instances_keep_monitoring_every_database_and_web_choices_take_effect() { _options = new AgentOptions(); _options.Instances.Add(new MonitoredInstance { Name = "from-file", Server = sql.Server, Authentication = AuthenticationMode.SqlLogin, UserName = "u", Password = "file-secret" }); var store = Store(); await store.SyncConfigFileAsync(DateTimeOffset.UtcNow, CancellationToken.None); var web = await AddAsWebAsync("from-web", SqlServerFixture.AgentPassword); await _repository.SaveDiscoveredDatabasesAsync(web, [new DiscoveredDatabase { Name = "Sales", HasAccess = true }, new DiscoveredDatabase { Name = "Archive", HasAccess = true }], DateTimeOffset.UtcNow); await _repository.SetDatabaseChoicesAsync(web, [new DatabaseChoice("Sales", true, false)], "anna", DateTimeOffset.UtcNow); await _repository.SetInstancePausedAsync(web, true, "anna", DateTimeOffset.UtcNow); var loaded = (await store.LoadAsync(CancellationToken.None)).ToDictionary(i => i.Instance.Name); var file = loaded["from-file"]; Assert.True(file.FromConfigFile && file.Databases.IsEverything && !file.Paused); Assert.Equal("file-secret", file.Instance.Password); Assert.DoesNotContain(await _repository.GetConfiguredInstancesAsync(), r => r.Name == "from-file" && r.HasPassword); var fromWeb = loaded["from-web"]; Assert.True(fromWeb.Paused); Assert.Equal(SqlServerFixture.AgentPassword, fromWeb.Instance.Password); Assert.True(fromWeb.Databases.Includes("Sales")); Assert.False(fromWeb.Databases.Includes("Archive")); Assert.True(fromWeb.Databases.Includes("msdb")); } /// The test container's certificate is self-signed; the instance rows ask to trust it, and so does this. private sealed class TrustingFactory : ISqlConnectionFactory { private readonly SqlConnectionFactory _inner = new(); public Task OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default) => _inner.OpenAsync(target with { Provider = ConnectionProvider.SqlClient, ConnectTimeout = TimeSpan.FromSeconds(5) }, cancellationToken); } }