sqlinsight-pro/tests/SQLInsight.IntegrationTests/AgentPipelineTests.cs
Claude 7f640e74e5
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Fase 4: community and local rules written as SQL
Rules can now be data: a JSON file with one read-only SELECT where every
row is a finding, with {column} templates, server or per-database scope,
minimum version, sensitive columns and references. Community rules live
in rules/community (reviewed via pull request), ship with every build
and with the signed rule pack; local rules are read from the agent's
data folder every five minutes. Queries are checked for anything that
could write, run with the least-privilege login, and a missing
permission makes the rule not measurable rather than failing. The
rule engine picks up catalog changes without a restart. Authors get
'rules validate' and 'rules test --instance'. Four example community
rules (sa active, orphaned database owners, default trace off,
untrusted foreign keys).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 10:23:01 +00:00

127 lines
5.5 KiB
C#

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;
/// <summary>
/// The agent's real pipeline end to end: least-privilege connection, all collectors, all rules, repository.
/// </summary>
[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<string, TimeSpan>(),
new SqlConnectionFactory(),
new RuleEngine(BuiltInRules.Create(), Community),
_repository,
NullLogger.Instance);
/// <summary>The community rules that ship with the build run in the same cycle as the built-in collectors.</summary>
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);
}
}