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_before_2016_sp2_must_be_checked_by_hand() { var result = Run(new CheckDbRule(), WithVersion("13.0.4001.0", DatabaseList(Database()))); Assert.Equal(RuleOutcome.ManualCheck, result.Outcome); Assert.Contains("tjek manuelt", result.Reason, StringComparison.Ordinal); Assert.Contains("dbi_dbccLastKnownGood", result.Reason, StringComparison.Ordinal); } [Fact] public void Query_store_before_2016_is_not_relevant_rather_than_a_manual_check() => Assert.Equal(RuleOutcome.NotApplicable, Run(new QueryStoreStateRule(), WithVersion("12.0.6024.0", DatabaseList(Database()))).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); } }