using Microsoft.Extensions.Logging.Abstractions; using SQLInsight.Agent; using SQLInsight.Agent.Monitoring; using SQLInsight.Collectors; using SQLInsight.Connectivity; using SQLInsight.Core.Findings; using SQLInsight.Repository; using SQLInsight.Rules; using SQLInsight.Rules.Engine; namespace SQLInsight.IntegrationTests; /// /// The agent's real pipeline end to end: least-privilege connection, all collectors, all rules, repository. /// [Collection(SqlServerTestGroup.Name)] [Trait("Category", "Integration")] public class AgentPipelineTests(SqlServerFixture sql) : IAsyncLifetime { private readonly string _database = "SQLInsightAgent_" + Guid.NewGuid().ToString("N")[..8]; private InsightRepository _repository = null!; 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 InstanceMonitor Monitor(string name, string? password = null) => new( new MonitoredInstance { Name = name, Server = sql.Server, Authentication = AuthenticationMode.SqlLogin, UserName = SqlServerFixture.AgentLogin, Password = password ?? SqlServerFixture.AgentPassword, Provider = ConnectionProvider.SqlClient, TrustServerCertificate = true, }, BuiltInCollectors.Create(Community), new Dictionary(), new SqlConnectionFactory(), new RuleEngine(BuiltInRules.Create(), Community), _repository, NullLogger.Instance); /// The community rules that ship with the build run in the same cycle as the built-in collectors. private static readonly SQLInsight.Core.CustomRules.CustomRuleCatalog Community = CommunityCatalog(); private static SQLInsight.Core.CustomRules.CustomRuleCatalog CommunityCatalog() { var catalog = new SQLInsight.Core.CustomRules.CustomRuleCatalog(); catalog.Replace(SQLInsight.Rules.Lifecycle.RulePack.Embedded().CommunityRules); return catalog; } private static int RuleCount => BuiltInRules.Create().Count + Community.Rules.Count; [Fact] public async Task Two_cycles_store_findings_rule_states_and_time_series() { var monitor = Monitor("container"); var start = DateTimeOffset.UtcNow; var first = await monitor.RunCycleAsync(start, CancellationToken.None); await sql.ExecuteAsSaAsync("SELECT COUNT(*) FROM sys.objects a CROSS JOIN sys.objects b;"); var nothingDue = await monitor.RunCycleAsync(start.AddSeconds(10), CancellationToken.None); var second = await monitor.RunCycleAsync(start.AddMinutes(16), CancellationToken.None); Assert.NotNull(first); Assert.Null(nothingDue); Assert.NotNull(second); Assert.Equal(RuleCount, second.Count); Assert.DoesNotContain(second, r => r.Outcome == RuleOutcome.Error); var instance = await _repository.GetInstanceAsync("container"); Assert.NotNull(instance); Assert.False(instance.IsFailing); var findings = await _repository.GetActiveFindingsAsync(instance.InstanceId); // A fresh container has well-known problems: default max server memory and master never backed up. Assert.Contains(findings, f => f.RuleId == "SQLI-CFG-001" && f.Severity == Severity.High); Assert.Contains(findings, f => f.RuleId == "SQLI-DB-007" && f.Subject == "database: master"); var states = await _repository.GetRuleStatesAsync(instance.InstanceId); Assert.Equal(RuleCount, states.Count); Assert.Contains(findings, f => f.RuleId == "COM-SEC-001"); // Wait stats need two samples; after the second cycle they are measurable. Assert.NotEqual(RuleOutcome.NotMeasurable, states.Single(s => s.RuleId == "SQLI-WAIT-003").Outcome); Assert.NotEmpty(await _repository.GetTopWaitsAsync(instance.InstanceId, start.AddHours(-1))); } [Fact] public async Task Restarted_agent_does_not_report_or_store_deadlocks_again() { var now = DateTimeOffset.UtcNow; await Monitor("restart").RunCycleAsync(now, CancellationToken.None); var instance = await _repository.GetInstanceAsync("restart"); var stored = (await _repository.GetDeadlocksAsync(instance!.InstanceId, now.AddDays(-2))).Count; // A new monitor is what the agent has after a restart: no memory of where it stopped. var results = await Monitor("restart").RunCycleAsync(now.AddMinutes(1), CancellationToken.None); Assert.Equal(stored, (await _repository.GetDeadlocksAsync(instance.InstanceId, now.AddDays(-2))).Count); if (stored > 0) { Assert.Equal(RuleOutcome.Passed, results!.Single(r => r.RuleId == "SQLI-DLK-001").Outcome); } } [Fact] public async Task Unreachable_instance_is_recorded_as_failing() { var monitor = Monitor("wrong-password", password: "not-the-password"); var result = await monitor.RunCycleAsync(DateTimeOffset.UtcNow, CancellationToken.None); Assert.Null(result); var instance = await _repository.GetInstanceAsync("wrong-password"); Assert.True(instance!.IsFailing); Assert.Contains("Login failed", instance.LastError, StringComparison.Ordinal); } }