Some checks are pending
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
CI / Build og unit tests (push) Waiting to run
Collectors (SQL Server 2000+, version-specific SQL, least privilege): server, configuration, trace flags, databases (settings, backups in UTC, CHECKDB, VLFs, Query Store, files, tempdb), suspect pages, plan cache, wait stats and file I/O as deltas with restart/clear detection, performance counters, CPU from the scheduler monitor ring buffer, blocking (sleeping head blockers with open transactions), and deadlocks from system_health continuing from the last file offset. Rules: configuration (memory, MAXDOP per Microsoft NUMA guidance, cost threshold, ad hoc bloat, risky options, pending changes), databases (state, settings, backups, log backups, CHECKDB, suspect pages, VLFs, autogrowth, max size, Query Store, tempdb), waits (THREADPOOL, signal waits, locks, I/O, WRITELOG, parallelism, PAGELATCH), I/O latency, CPU, memory grants, blocking and deadlocks. Missing data makes a rule not measurable with the collector's reason. Integration tests run every collector as the least-privilege login and reproduce real blocking and a real deadlock; green on 2017 and 2022. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
272 lines
10 KiB
C#
272 lines
10 KiB
C#
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Rules.Databases;
|
|
using SQLInsight.Rules.Engine;
|
|
using static SQLInsight.Rules.Tests.TestInstances;
|
|
|
|
namespace SQLInsight.Rules.Tests;
|
|
|
|
public class DatabaseRuleTests
|
|
{
|
|
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
|
|
|
|
private static RuleResult Run(IRule rule, params DatabaseInfo[] databases) => Run(rule, With(DatabaseList(databases)));
|
|
|
|
[Fact]
|
|
public void Healthy_database_passes_every_database_rule()
|
|
{
|
|
var rules = new IRule[]
|
|
{
|
|
new DatabaseStateRule(), new CompatibilityLevelRule(), new FullBackupRule(), new LogBackupRule(), new CheckDbRule(),
|
|
new VirtualLogFileRule(), new FileGrowthRule(), new FileMaxSizeRule(), new QueryStoreStateRule(),
|
|
}.Concat(SimpleDatabaseRules.Create());
|
|
|
|
foreach (var rule in rules)
|
|
{
|
|
Assert.Equal(RuleOutcome.Passed, Run(rule, Database()).Outcome);
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("SUSPECT", Severity.Critical)]
|
|
[InlineData("RECOVERY_PENDING", Severity.Critical)]
|
|
[InlineData("OFFLINE", Severity.Low)]
|
|
public void Unusable_database_state(string state, Severity severity)
|
|
{
|
|
var finding = Assert.Single(Run(new DatabaseStateRule(), Database(change: d => d with { State = state })).Findings);
|
|
|
|
Assert.Equal(severity, finding.Severity);
|
|
Assert.Equal("database: Sales", finding.Subject);
|
|
}
|
|
|
|
[Fact]
|
|
public void Settings_rules_skip_offline_databases()
|
|
{
|
|
var offline = Database(change: d => d with { State = "OFFLINE", IsAutoShrink = true });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(SimpleDatabaseRules.Create().Single(r => r.Id == "SQLI-DB-002"), offline).Outcome);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("SQLI-DB-002")]
|
|
[InlineData("SQLI-DB-003")]
|
|
[InlineData("SQLI-DB-004")]
|
|
[InlineData("SQLI-DB-005")]
|
|
public void Simple_database_rules_find_and_script_the_fix(string id)
|
|
{
|
|
var bad = Database("My]Db", d => d with { IsAutoShrink = true, IsAutoClose = true, PageVerify = "TORN_PAGE_DETECTION", IsTrustworthy = true });
|
|
|
|
var finding = Assert.Single(Run(SimpleDatabaseRules.Create().Single(r => r.Id == id), bad).Findings);
|
|
|
|
Assert.StartsWith("ALTER DATABASE [My]]Db]", finding.FixScript, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Trustworthy_msdb_is_by_design()
|
|
{
|
|
var msdb = Database("msdb", d => d with { DatabaseId = 4, IsTrustworthy = true });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(SimpleDatabaseRules.Create().Single(r => r.Id == "SQLI-DB-005"), msdb).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Old_compatibility_level_is_reported_for_user_databases_only()
|
|
{
|
|
var user = Database(change: d => d with { CompatibilityLevel = 130 });
|
|
var system = Database("master", d => d with { DatabaseId = 1, CompatibilityLevel = 130 });
|
|
|
|
Assert.Single(Run(new CompatibilityLevelRule(), user, system).Findings);
|
|
}
|
|
|
|
[Fact]
|
|
public void Never_backed_up_is_critical()
|
|
{
|
|
var finding = Assert.Single(Run(new FullBackupRule(), Database(change: d => d with { LastFullBackup = null })).Findings);
|
|
|
|
Assert.Equal(Severity.Critical, finding.Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Old_backup_is_high_but_medium_in_an_availability_group()
|
|
{
|
|
var old = Database(change: d => d with { LastFullBackup = Now.UtcDateTime.AddDays(-9) });
|
|
|
|
Assert.Equal(Severity.High, Assert.Single(Run(new FullBackupRule(), old).Findings).Severity);
|
|
Assert.Equal(Severity.Medium, Assert.Single(Run(new FullBackupRule(), old with { IsInAvailabilityGroup = true }).Findings).Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Backup_rule_skips_new_databases_model_and_secondaries()
|
|
{
|
|
var created = Database(change: d => d with { LastFullBackup = null, CreateDate = Now.UtcDateTime.AddHours(-2) });
|
|
var model = Database("model", d => d with { DatabaseId = 3, LastFullBackup = null });
|
|
var secondary = Database(change: d => d with { LastFullBackup = null, IsReplicaOrMirror = true });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new FullBackupRule(), created, model, secondary).Outcome);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("FULL", 30, RuleOutcome.Findings)]
|
|
[InlineData("FULL", 12, RuleOutcome.Passed)]
|
|
[InlineData("BULK_LOGGED", 30, RuleOutcome.Findings)]
|
|
[InlineData("SIMPLE", 30, RuleOutcome.Passed)]
|
|
public void Log_backups_in_full_recovery(string model, int hoursSinceLogBackup, RuleOutcome expected)
|
|
{
|
|
var db = Database(change: d => d with { RecoveryModel = model, LastLogBackup = Now.UtcDateTime.AddHours(-hoursSinceLogBackup) });
|
|
|
|
Assert.Equal(expected, Run(new LogBackupRule(), db).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Full_recovery_without_any_full_backup_is_not_a_log_backup_problem()
|
|
{
|
|
var db = Database(change: d => d with { LastFullBackup = null, LastLogBackup = null });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new LogBackupRule(), db).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckDb_never_run_is_high()
|
|
{
|
|
var db = Database(change: d => d with { LastGoodCheckDb = new DateTime(1900, 1, 1) });
|
|
|
|
var finding = Assert.Single(Run(new CheckDbRule(), db).Findings);
|
|
|
|
Assert.Equal(Severity.High, finding.Severity);
|
|
Assert.Contains("aldrig", finding.Title, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(10, Severity.Medium)]
|
|
[InlineData(40, Severity.High)]
|
|
public void CheckDb_age(int days, Severity severity)
|
|
{
|
|
var db = Database(change: d => d with { LastGoodCheckDb = Now.UtcDateTime.AddDays(-days) });
|
|
|
|
Assert.Equal(severity, Assert.Single(Run(new CheckDbRule(), db).Findings).Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckDb_is_not_measurable_before_2016_sp2()
|
|
{
|
|
var result = Run(new CheckDbRule(), WithVersion("13.0.4001.0", DatabaseList(Database())));
|
|
|
|
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Suspect_pages_ignore_repaired_pages()
|
|
{
|
|
var pages = new SuspectPageSnapshot(Now,
|
|
[
|
|
new SuspectPage("Sales", 1, 1234, 2, 1, Now.UtcDateTime),
|
|
new SuspectPage("Sales", 1, 5678, 3, 2, Now.UtcDateTime),
|
|
new SuspectPage("Hr", 1, 42, 5, 1, Now.UtcDateTime), // repaired
|
|
]);
|
|
|
|
var finding = Assert.Single(Run(new SuspectPagesRule(), With(pages)).Findings);
|
|
|
|
Assert.Equal(Severity.Critical, finding.Severity);
|
|
Assert.Equal("database: Sales", finding.Subject);
|
|
Assert.Equal(2, finding.Evidence.Count);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(999, RuleOutcome.Passed)]
|
|
[InlineData(1500, RuleOutcome.Findings)]
|
|
public void Vlf_threshold(int vlfs, RuleOutcome expected)
|
|
{
|
|
Assert.Equal(expected, Run(new VirtualLogFileRule(), Database(change: d => d with { VirtualLogFileCount = vlfs })).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void File_growth_flags_percent_and_tiny_fixed_growth_on_large_files()
|
|
{
|
|
static DatabaseFileInfo File(string name, long size, long growth, bool percent) => new()
|
|
{
|
|
LogicalName = name, Type = "ROWS", PhysicalName = name + ".mdf", SizeMb = size, Growth = growth, IsPercentGrowth = percent, MaxSizeMb = -1,
|
|
};
|
|
var db = Database(change: d => d with
|
|
{
|
|
Files =
|
|
[
|
|
File("percent", 50_000, 10, true),
|
|
File("tiny", 20_000, 1, false),
|
|
File("fine", 20_000, 512, false),
|
|
File("small-db", 100, 10, true), // too small to matter
|
|
File("fixed-size", 20_000, 0, false), // autogrowth off: deliberate
|
|
],
|
|
});
|
|
|
|
var findings = Run(new FileGrowthRule(), db).Findings;
|
|
|
|
Assert.Equal(["file: Sales/percent", "file: Sales/tiny"], findings.Select(f => f.Subject));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(950, 1000, Severity.High)]
|
|
[InlineData(1000, 1000, Severity.Critical)]
|
|
public void File_near_max_size(long size, long max, Severity severity)
|
|
{
|
|
var db = Database(change: d => d with
|
|
{
|
|
Files = [new DatabaseFileInfo { LogicalName = "log", Type = "LOG", PhysicalName = "x", SizeMb = size, Growth = 64, IsPercentGrowth = false, MaxSizeMb = max }],
|
|
});
|
|
|
|
Assert.Equal(severity, Assert.Single(Run(new FileMaxSizeRule(), db).Findings).Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Default_2tb_log_max_size_is_unlimited()
|
|
{
|
|
var db = Database(change: d => d with
|
|
{
|
|
Files = [new DatabaseFileInfo { LogicalName = "log", Type = "LOG", PhysicalName = "x", SizeMb = 2_000_000, Growth = 64, IsPercentGrowth = false, MaxSizeMb = 2_097_152 }],
|
|
});
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new FileMaxSizeRule(), db).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Query_store_that_switched_to_read_only_because_full()
|
|
{
|
|
var db = Database(change: d => d with { QueryStore = new QueryStoreState("READ_WRITE", "READ_ONLY", 65536, 1000, 1000) });
|
|
|
|
var finding = Assert.Single(Run(new QueryStoreStateRule(), db).Findings);
|
|
|
|
Assert.Contains("fuld", finding.Title, StringComparison.Ordinal);
|
|
Assert.Contains("MAX_STORAGE_SIZE_MB", finding.Recommendation, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Query_store_deliberately_read_only_passes()
|
|
{
|
|
var db = Database(change: d => d with { QueryStore = new QueryStoreState("READ_ONLY", "READ_ONLY", 0, 10, 1000) });
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new QueryStoreStateRule(), db).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tempdb_with_one_file_on_many_cpus()
|
|
{
|
|
var snapshot = DatabaseList() with
|
|
{
|
|
TempdbFiles = [new DatabaseFileInfo { LogicalName = "tempdev", Type = "ROWS", PhysicalName = "t", SizeMb = 8, Growth = 64, IsPercentGrowth = false, MaxSizeMb = -1 }],
|
|
};
|
|
|
|
var finding = Assert.Single(Run(new TempdbFilesRule(), With(snapshot, Server(cpus: 16))).Findings);
|
|
|
|
Assert.Contains("Brug 8", finding.Recommendation, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tempdb_files_of_different_size()
|
|
{
|
|
static DatabaseFileInfo File(string name, long size) => new() { LogicalName = name, Type = "ROWS", PhysicalName = name, SizeMb = size, Growth = 64, IsPercentGrowth = false, MaxSizeMb = -1 };
|
|
var snapshot = DatabaseList() with { TempdbFiles = [File("a", 1024), File("b", 1024), File("c", 1024), File("d", 4096)] };
|
|
|
|
var finding = Assert.Single(Run(new TempdbFilesRule(), With(snapshot, Server(cpus: 4))).Findings);
|
|
|
|
Assert.Equal(Severity.Low, finding.Severity);
|
|
}
|
|
}
|