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);
}
}