The agent mirrors SQLInsight:Instances into the repository at start (read-only "fra konfigurationsfil" rows, every database monitored as before) and reads the configuration on every tick: instances added, paused, removed or changed in SQLInsight Web, and database choices, take effect without a restart. If the repository cannot be read before the first load, the configuration file alone is used. AgentRequestService runs connection tests (with the permission check), database discovery and instance discovery (SQL Server Browser, registry) as the agent's own identity, and refreshes every instance's database list hourly. The agent keeps an RSA key (DPAPI LocalMachine, restricted folder) and publishes its public key and identity; SQL login passwords sealed by the web UI are opened only here. Dynamics AX findings and AX rule outcomes are sent to SQLInsight Hub only for databases where it is turned on (default off). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
149 lines
8.3 KiB
C#
149 lines
8.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<InstanceConfigurationStore>.Instance);
|
|
|
|
private AgentRequestService Service() =>
|
|
new(_repository, Store(), _key, new TrustingFactory(), new RepositoryReadiness(), TimeProvider.System, NullLogger<AgentRequestService>.Instance);
|
|
|
|
/// <summary>What SQLInsight Web does: seal the password with the agent's published key.</summary>
|
|
private async Task<int> 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<AgentRequestRow> 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<ConnectionTestResult>(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<ConnectionTestResult>((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<InstanceDiscoveryResult>(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"));
|
|
}
|
|
|
|
/// <summary>The test container's certificate is self-signed; the instance rows ask to trust it, and so does this.</summary>
|
|
private sealed class TrustingFactory : ISqlConnectionFactory
|
|
{
|
|
private readonly SqlConnectionFactory _inner = new();
|
|
|
|
public Task<OpenedConnection> OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default) =>
|
|
_inner.OpenAsync(target with { Provider = ConnectionProvider.SqlClient, ConnectTimeout = TimeSpan.FromSeconds(5) }, cancellationToken);
|
|
}
|
|
}
|