using SQLInsight.Core.Collection; using SQLInsight.Core.Findings; using SQLInsight.Core.Servers; using SQLInsight.Rules.Engine; using SQLInsight.Rules.Servers; namespace SQLInsight.Rules.Tests; public class WindowsServerRuleTests { private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero); private static ServerIdentity Server(IReadOnlyList? watched = null, IReadOnlyList? ignored = null) => new(1, "AOS01", "aos01.firma.local", ServerRoles.Application) { WatchedServices = watched ?? [], IgnoredServices = ignored ?? [] }; private static ServerRuleContext Context(CollectedData data, ServerIdentity? server = null, ServerRuleSettings? settings = null) => new(server ?? Server(), Now, data, settings ?? new ServerRuleSettings()); private static ServerPerformanceSnapshot Performance(Func sample, int minutes = 60, DateTimeOffset? boot = null) => new() { CapturedAt = Now, Os = new ServerOsInfo { Caption = "Windows Server 2022", TotalMemoryMb = 16384, LastBootUpTime = boot ?? Now.AddDays(-30), LogicalProcessors = 8 }, Samples = Enumerable.Range(0, minutes).Select(m => sample(m) with { At = Now.AddMinutes(-m) }).Reverse().ToList(), }; private static ServerPerformanceSample Sample(double? cpu = 20, double? availableMb = 8000, double? pages = 10, double? commit = 40) => new(Now, cpu, availableMb, 16384, pages, commit); private static CollectedData With(T value) where T : class { var data = new CollectedData(); data.Set(value); return data; } private static RuleResult Run(IScopedRule rule, ServerRuleContext context) => Assert.Single(ScopedRuleEngine.Evaluate([rule], context)); [Fact] public void Every_rule_has_a_unique_well_formed_id_and_a_title() { var all = ServerRuleCatalog.All; Assert.Equal(all.Count, all.Select(r => r.Id).Distinct().Count()); Assert.All(all, r => Assert.Matches("^SQLI-(WIN|SYN)-[0-9]{3}$", r.Id)); Assert.All(all, r => Assert.False(string.IsNullOrWhiteSpace(r.Title))); Assert.DoesNotContain(all, r => BuiltInRules.Create().Any(b => b.Id == r.Id)); } [Fact] public void Missing_data_is_not_measurable_with_the_reason() { var data = new CollectedData(); data.MarkUnavailable("Adgang nægtet til root\\cimv2."); var result = Run(new DiskFreeSpaceRule(), Context(data)); Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); Assert.Equal("Adgang nægtet til root\\cimv2.", result.Reason); } [Fact] public void Sustained_high_cpu_is_a_finding_a_burst_is_not() { var sustained = Run(new ServerCpuRule(), Context(With(Performance(m => Sample(cpu: 95))))); var burst = Run(new ServerCpuRule(), Context(With(Performance(m => Sample(cpu: m < 3 ? 100 : 30))))); var first = Run(new ServerCpuRule(), Context(With(Performance(_ => Sample(cpu: null))))); Assert.Equal(Severity.High, Assert.Single(sustained.Findings).Severity); Assert.Contains("95 %", sustained.Findings[0].Title, StringComparison.Ordinal); Assert.Equal(RuleOutcome.Passed, burst.Outcome); Assert.Equal(RuleOutcome.NotMeasurable, first.Outcome); } [Fact] public void Low_memory_needs_both_little_share_and_few_megabytes() { var low = Run(new ServerMemoryRule(), Context(With(Performance(_ => Sample(availableMb: 300))))); var bigServer = Run(new ServerMemoryRule(), Context(With(Performance(_ => new ServerPerformanceSample(Now, 10, 4000, 262144, 0, 40))))); Assert.Equal(Severity.Medium, Assert.Single(low.Findings).Severity); Assert.Equal(RuleOutcome.Passed, bigServer.Outcome); // 1,5 % of 256 GB, but 4 GB free } [Fact] public void Paging_only_counts_with_little_free_memory() { Assert.Single(Run(new ServerPagingRule(), Context(With(Performance(_ => Sample(availableMb: 900, pages: 5000))))).Findings); Assert.Equal(RuleOutcome.Passed, Run(new ServerPagingRule(), Context(With(Performance(_ => Sample(availableMb: 8000, pages: 5000))))).Outcome); Assert.Equal(Severity.High, Assert.Single(Run(new ServerCommitRule(), Context(With(Performance(_ => Sample(commit: 98))))).Findings).Severity); } [Fact] public void Disk_free_space_by_share_unless_there_is_plenty() { var disks = new ServerDiskSnapshot { CapturedAt = Now, Disks = [ new ServerDisk(@"C:\", "System", "NTFS", 100, 30), new ServerDisk(@"D:\", "Data", "NTFS", 500, 30), // 6 %: Medium new ServerDisk(@"E:\", "Log", "NTFS", 100, 3), // 3 %: High new ServerDisk(@"F:\", "Backup", "NTFS", 4000, 200), // 5 % but 200 GB: fine new ServerDisk(@"G:\", "Temp", "NTFS", 50, 0.5), // under 1 GB: Critical ], }; var findings = Run(new DiskFreeSpaceRule(), Context(With(disks))).Findings.ToDictionary(f => f.Subject!, f => f.Severity); Assert.Equal(new Dictionary { [@"disk: D:\"] = Severity.Medium, [@"disk: E:\"] = Severity.High, [@"disk: G:\"] = Severity.Critical }, findings); } [Fact] public void Forecast_findings_by_days_until_full() { var disks = new ServerDiskSnapshot { CapturedAt = Now, Disks = [new ServerDisk(@"D:\", null, "NTFS", 500, 100), new ServerDisk(@"E:\", null, "NTFS", 500, 100)], Forecasts = [ new DiskForecast(@"D:\", 12, 8, 300, 14, 0.95), new DiskForecast(@"E:\", 1.5, 60, 300, 14, 0.95), new DiskForecast(@"F:\", null, 0, 300, 14, 0.1, "ingen tydelig tendens"), new DiskForecast(@"G:\", 200, 0.5, 300, 14, 0.9), ], }; var findings = Run(new DiskForecastRule(), Context(With(disks))).Findings; Assert.Equal(2, findings.Count); Assert.Contains(findings, f => f.Title == @"Drev D:\ er fuldt om ca. 12 dage" && f.Severity == Severity.Medium); Assert.Contains(findings, f => f.Subject == @"forecast: E:\" && f.Severity == Severity.Critical); Assert.Equal(RuleOutcome.NotMeasurable, Run(new DiskForecastRule(), Context(With(disks with { Forecasts = [] }))).Outcome); var full = Run(new DiskForecastRule(), Context(With(disks with { Forecasts = [new DiskForecast(@"D:\", 0, 8, 300, 14, 0.95)] }))); Assert.Equal(@"Drev D:\ er fuldt", Assert.Single(full.Findings).Title); } [Fact] public void A_new_server_has_no_forecast_yet_and_says_so() { // What the agent builds for a server it started watching yesterday: one forecast per volume, none with enough history. var history = Enumerable.Range(0, 24).Select(h => new DiskHistoryPoint(Now.AddHours(-h), 500, 100 + h)).Reverse().ToList(); var disks = new ServerDiskSnapshot { CapturedAt = Now, Disks = [new ServerDisk(@"C:\", null, "NTFS", 500, 100), new ServerDisk(@"D:\", null, "NTFS", 500, 100)], Forecasts = [DiskForecaster.Forecast(@"C:\", history, Now), DiskForecaster.Forecast(@"D:\", [], Now)], }; var result = Run(new DiskForecastRule(), Context(With(disks))); Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome); Assert.Contains("for lidt historik", result.Reason, StringComparison.Ordinal); // Once one volume has enough history, the rule judges it (and passes, as it is not filling up). var enough = Enumerable.Range(0, 24 * 5).Select(h => new DiskHistoryPoint(Now.AddHours(-h), 500, 100)).Reverse().ToList(); Assert.Equal(RuleOutcome.Passed, Run(new DiskForecastRule(), Context(With(disks with { Forecasts = [DiskForecaster.Forecast(@"C:\", enough, Now), DiskForecaster.Forecast(@"D:\", [], Now)], }))).Outcome); } private static ServerServiceSnapshot Services(params ServerService[] services) => new() { CapturedAt = Now, Services = services }; [Fact] public void Stopped_automatic_services_except_the_ones_that_stop_by_design() { var data = With(Services( new ServerService("AOS60$01", "Microsoft Dynamics AX Object Server 6.0$01", "Stopped", "Auto", ExitCode: 1067), new ServerService("Spooler", "Print Spooler", "Running", "Auto"), new ServerService("sppsvc", "Software Protection", "Stopped", "Auto", DelayedAutoStart: true), new ServerService("clr_optimization_v4.0.30319_64", ".NET Optimization", "Stopped", "Auto"), new ServerService("MyBatch", "Batch", "Stopped", "Auto"), new ServerService("Manual1", "Manual", "Stopped", "Manual"))); data.Set(Performance(_ => Sample())); var result = Run(new StoppedAutomaticServiceRule(), Context(data, Server(ignored: ["MyBatch"]))); var finding = Assert.Single(result.Findings); Assert.Equal("service: AOS60$01", finding.Subject); Assert.Contains("1067", finding.Explanation, StringComparison.Ordinal); } [Fact] public void Right_after_a_restart_stopped_services_are_still_starting() { var data = With(Services(new ServerService("AOS60$01", "AOS", "Stopped", "Auto"))); data.Set(Performance(_ => Sample(), boot: Now.AddMinutes(-5))); // Not "passed": that would resolve the open findings at every restart and open them again later. var automatic = Run(new StoppedAutomaticServiceRule(), Context(data)); var watched = Run(new WatchedServiceRule(), Context(data, Server(watched: ["AOS60$01"]))); Assert.Equal(RuleOutcome.NotMeasurable, automatic.Outcome); Assert.Equal("Serveren er lige startet; tjenester vurderes igen efter 10 minutter.", automatic.Reason); Assert.Equal(RuleOutcome.NotMeasurable, watched.Outcome); } [Fact] public void Watched_and_automatic_services_are_matched_the_same_way() { var data = With(Services( new ServerService("AOS60$01", "Microsoft Dynamics AX Object Server 6.0$01", "Stopped", "Auto"), new ServerService("AOS60$02", "Microsoft Dynamics AX Object Server 6.0$02", "Stopped", "Auto"), new ServerService("ReportServer", "SQL Server Reporting Services", "Stopped", "Auto"))); data.Set(Performance(_ => Sample())); var server = Server(watched: ["AOS60$*", "SQL Server Reporting Services"]); var watched = Run(new WatchedServiceRule(), Context(data, server)); var automatic = Run(new StoppedAutomaticServiceRule(), Context(data, server)); // A prefix finds both AOS instances and a display name finds Reporting Services: none is "not found", and none is reported twice. Assert.Equal(["watched: AOS60$01", "watched: AOS60$02", "watched: ReportServer"], watched.Findings.Select(f => f.Subject!).Order(StringComparer.Ordinal).ToList()); Assert.All(watched.Findings, f => Assert.Equal(Severity.High, f.Severity)); Assert.Equal(RuleOutcome.Passed, automatic.Outcome); } [Fact] public void Stopped_automatic_services_are_capped_with_one_summary() { var data = With(Services(Enumerable.Range(1, 25).Select(i => new ServerService($"Svc{i:00}", $"Service {i}", "Stopped", "Auto")).Reverse().ToArray())); data.Set(Performance(_ => Sample())); var findings = Run(new StoppedAutomaticServiceRule(), Context(data)).Findings; Assert.Equal(16, findings.Count); Assert.Equal(Enumerable.Range(1, 15).Select(i => $"service: Svc{i:00}"), findings.Take(15).Select(f => f.Subject)); var summary = findings[^1]; Assert.Equal("service: (flere)", summary.Subject); Assert.Equal("10 automatiske tjenester mere kører ikke", summary.Title); Assert.Contains("Svc16", summary.Evidence.Single(e => e.Name == "Tjenester").Value, StringComparison.Ordinal); var settings = new ServerRuleSettings(); settings.Services.MaxFindings = 0; // bounded: at least one Assert.Equal(2, Run(new StoppedAutomaticServiceRule(), Context(data, settings: settings)).Findings.Count); } [Fact] public void Watched_services_must_run_and_exist() { var data = With(Services(new ServerService("AOS60$01", "AOS", "Stopped", "Manual"), new ServerService("W3SVC", "IIS", "Running", "Auto"))); var result = Run(new WatchedServiceRule(), Context(data, Server(watched: ["AOS60$01", "W3SVC", "ReportServer"]))); var none = Run(new WatchedServiceRule(), Context(data)); var automatic = Run(new StoppedAutomaticServiceRule(), Context(data, Server(watched: ["AOS60$01"]))); Assert.Equal(2, result.Findings.Count); Assert.Contains(result.Findings, f => f.Subject == "watched: AOS60$01" && f.Severity == Severity.High); Assert.Contains(result.Findings, f => f.Subject == "watched: ReportServer" && f.Severity == Severity.Medium); Assert.Equal(RuleOutcome.NotApplicable, none.Outcome); Assert.Equal(RuleOutcome.Passed, automatic.Outcome); // not reported twice } private static ServerEventGroup Group(string source, long id, ServerEventCategory category, int count, int level = 2) => new("System", source, id, level, category, count, Now.AddHours(-2), Now.AddMinutes(-5), "besked med \\\\fil01\\hemmeligt"); [Fact] public void Events_important_kinds_at_once_ordinary_errors_when_they_repeat() { var events = new ServerEventSnapshot { CapturedAt = Now, ReadUntil = Now, Recent = [ Group("disk", 153, ServerEventCategory.Disk, 1), Group("Service Control Manager", 7031, ServerEventCategory.ServiceFailure, 4), Group("Some-App", 9, ServerEventCategory.Error, 2), Group("Other-App", 3, ServerEventCategory.Error, 12), Group("EventLog", 6008, ServerEventCategory.UnexpectedShutdown, 1), ], }; var findings = Run(new WindowsEventLogRule(), Context(With(events))).Findings; Assert.Equal(["event: System/disk/153", "event: System/Service Control Manager/7031", "event: System/Other-App/3"], findings.Select(f => f.Subject).ToList()); Assert.Equal([Severity.High, Severity.High, Severity.Low], findings.Select(f => f.Severity).ToList()); Assert.True(findings[0].Evidence.Single(e => e.Name == "Eksempel").Sensitive); } [Fact] public void Event_findings_are_capped() { var events = new ServerEventSnapshot { CapturedAt = Now, ReadUntil = Now, Recent = Enumerable.Range(1, 40).Select(i => Group("App" + i, i, ServerEventCategory.ApplicationCrash, 1)).ToList(), }; var findings = Run(new WindowsEventLogRule(), Context(With(events))).Findings; Assert.Equal(16, findings.Count); Assert.Equal("event: (flere)", findings[^1].Subject); Assert.Equal("25 hændelser mere i Windows' hændelseslog", findings[^1].Title); Assert.Equal(Severity.Medium, findings[^1].Severity); } [Fact] public void Event_findings_over_the_cap_do_not_trade_places_as_counts_change() { ServerEventSnapshot Snapshot(Func count) => new() { CapturedAt = Now, ReadUntil = Now, Recent = Enumerable.Range(1, 20).Select(i => Group("App" + i, i, ServerEventCategory.ApplicationCrash, count(i)) with { FirstAt = Now.AddHours(-20).AddMinutes(i) }).ToList(), }; var before = Run(new WindowsEventLogRule(), Context(With(Snapshot(_ => 1)))).Findings.Select(f => f.Subject).ToList(); var after = Run(new WindowsEventLogRule(), Context(With(Snapshot(i => i)))).Findings.Select(f => f.Subject).ToList(); Assert.Equal(before, after); Assert.Equal("event: System/App1/1", before[0]); } [Fact] public void Restart_unexpected_with_a_crash_event_otherwise_info() { var data = With(Performance(_ => Sample(), boot: Now.AddHours(-3))); data.Set(new ServerEventSnapshot { CapturedAt = Now, ReadUntil = Now.AddMinutes(-10) }); var planned = Run(new ServerRestartRule(), Context(data)); data.Set(new ServerEventSnapshot { CapturedAt = Now, ReadUntil = Now, Recent = [Group("Microsoft-Windows-Kernel-Power", 41, ServerEventCategory.UnexpectedShutdown, 1, level: 1) with { LastAt = Now.AddHours(-3) }], }); var crash = Run(new ServerRestartRule(), Context(data)); var old = Run(new ServerRestartRule(), Context(With(Performance(_ => Sample(), boot: Now.AddDays(-3))))); Assert.Equal(Severity.Info, Assert.Single(planned.Findings).Severity); Assert.Equal(Severity.High, Assert.Single(crash.Findings).Severity); Assert.Contains("uventet", crash.Findings[0].Title, StringComparison.Ordinal); Assert.Equal(RuleOutcome.Passed, old.Outcome); } [Fact] public void A_restart_is_not_called_planned_before_the_event_log_is_read_past_it() { var data = With(Performance(_ => Sample(), boot: Now.AddMinutes(-4))); // The event log cannot be read at all: the cause is unknown, and the finding says so. var unreadable = Run(new ServerRestartRule(), Context(data)); Assert.Equal(Severity.Low, Assert.Single(unreadable.Findings).Severity); Assert.Contains("årsagen kan ikke ses", unreadable.Findings[0].Title, StringComparison.Ordinal); Assert.DoesNotContain("uden tegn", unreadable.Findings[0].Explanation, StringComparison.Ordinal); // Read last before the restart: not measurable until it is read again. data.Set(new ServerEventSnapshot { CapturedAt = Now.AddMinutes(-6), ReadUntil = Now.AddMinutes(-16) }); var notYet = Run(new ServerRestartRule(), Context(data)); Assert.Equal(RuleOutcome.NotMeasurable, notYet.Outcome); Assert.Contains("ikke læst efter genstarten", notYet.Reason, StringComparison.Ordinal); } [Fact] public void Uptime_rule_is_off_by_default() { var data = With(Performance(_ => Sample(), boot: Now.AddDays(-200))); Assert.Equal(RuleOutcome.NotApplicable, Run(new ServerUptimeRule(), Context(data)).Outcome); Assert.Equal(Severity.Low, Assert.Single(Run(new ServerUptimeRule(), Context(data, settings: new ServerRuleSettings { UptimeDays = 120 })).Findings).Severity); } [Fact] public void Scheduled_tasks_failed_and_missed() { var tasks = new ServerTaskSnapshot { CapturedAt = Now, Visible = 120, Tasks = [ new ServerTask { Path = @"\", Name = "AX export", State = "Ready", LastTaskResult = 1, LastRunTime = Now.AddHours(-1), NextRunTime = Now.AddHours(23) }, new ServerTask { Path = @"\", Name = "OK", State = "Ready", LastTaskResult = 0, LastRunTime = Now.AddHours(-1), NextRunTime = Now.AddHours(1) }, new ServerTask { Path = @"\Firma\", Name = "Late", State = "Ready", LastTaskResult = 0, LastRunTime = Now.AddDays(-2), NextRunTime = Now.AddHours(-5) }, new ServerTask { Path = @"\", Name = "Disabled", State = "Disabled", LastTaskResult = 1, NextRunTime = Now.AddHours(-5) }, new ServerTask { Path = @"\", Name = "Missed", State = "Ready", LastTaskResult = 0, MissedRuns = 2 }, new ServerTask { Path = @"\", Name = "Running", State = "Running", LastTaskResult = TaskResults.Running, NextRunTime = Now.AddHours(-5) }, ], }; var data = With(tasks); var failed = Run(new FailedScheduledTaskRule(), Context(data)); var missed = Run(new MissedScheduledTaskRule(), Context(data)); Assert.Equal(@"task: \AX export", Assert.Single(failed.Findings).Subject); Assert.Equal([@"task-missed: \Firma\Late", @"task-missed: \Missed"], missed.Findings.Select(f => f.Subject).ToList()); } [Theory] [InlineData(@"\OneDrive Standalone Update Task-S-1-5-21-1004336348-1177238915-682003330-1001", true)] [InlineData(@"\MicrosoftEdgeUpdateTaskMachineUA", true)] [InlineData(@"\GoogleUpdateTaskMachineCore{6B8F5D2E-1D0B-4C1B-9E1A-2F8E7A0C1D11}", true)] [InlineData(@"\User_Feed_Synchronization-{9A1B7C3D-0000-4E5F-8A9B-123456789ABC}", true)] [InlineData(@"\Firma\Sync for S-1-5-21-1-2-3-500", true)] [InlineData(@"\AX export", false)] [InlineData(@"\Firma\OneDrive-kopi af rapporter", false)] public void Per_user_and_updater_tasks_are_not_watched_by_default(string path, bool left) { Assert.Equal(left, !new TaskThresholds().Watches(path)); Assert.True(new TaskThresholds { IgnoreUserAndUpdaterTasks = false }.Watches(path)); } [Fact] public void A_users_sid_is_never_in_a_task_findings_title_or_subject() { const string sid = "S-1-5-21-1004336348-1177238915-682003330-1001"; var tasks = new ServerTaskSnapshot { CapturedAt = Now, Tasks = [new ServerTask { Path = @"\", Name = "Rapport-" + sid, State = "Ready", LastTaskResult = 1, LastRunTime = Now.AddDays(-2), NextRunTime = Now.AddHours(-5) }], }; var settings = new ServerRuleSettings(); // By default the task is left out … Assert.Equal(RuleOutcome.Passed, Run(new FailedScheduledTaskRule(), Context(With(tasks), settings: settings)).Outcome); // … and when it is watched anyway, the SID is only in sensitive evidence. settings.Tasks.IgnoreUserAndUpdaterTasks = false; var findings = Run(new FailedScheduledTaskRule(), Context(With(tasks), settings: settings)).Findings .Concat(Run(new MissedScheduledTaskRule(), Context(With(tasks), settings: settings)).Findings).ToList(); Assert.Equal(2, findings.Count); Assert.All(findings, f => { Assert.DoesNotContain(sid, f.Title, StringComparison.Ordinal); Assert.DoesNotContain(sid, f.Subject, StringComparison.Ordinal); Assert.Contains(@"\Rapport-bruger-", f.Title, StringComparison.Ordinal); Assert.True(f.Evidence.Single(e => e.Name == "Opgave").Sensitive); }); } } public class SyntheticCheckRuleTests { private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero); private static SyntheticCheckIdentity Check(string kind = Core.SyntheticChecks.SyntheticCheckKind.Tcp, string? service = null, int? warn = null, bool https = false) => new(7, "AOS01 port", kind, "aos01:2712") { ServerName = "AOS01", ServiceName = service, WarnMilliseconds = warn, Https = https }; private static Core.SyntheticChecks.SyntheticCheckResult Result(bool success, int minutesAgo, int ms = 20, string? service = null, DateTimeOffset? certificate = null) => new() { At = Now.AddMinutes(-minutesAgo), Success = success, DurationMs = ms, Message = success ? "ok" : "Forbindelsen blev afvist.", ServiceState = service, CertificateExpiresAt = certificate }; private static RuleResult Run(IScopedRule rule, SyntheticCheckIdentity check, params Core.SyntheticChecks.SyntheticCheckResult[] newestFirst) => Assert.Single(ScopedRuleEngine.Evaluate([rule], new SyntheticCheckRuleContext(check, Now, newestFirst, new SyntheticCheckRuleSettings()))); [Fact] public void One_failure_is_not_an_alarm_two_in_a_row_are() { Assert.Equal(RuleOutcome.Passed, Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(true, 5)).Outcome); var failing = Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(false, 5), Result(true, 10)); Assert.Equal(Severity.High, Assert.Single(failing.Findings).Severity); Assert.Equal("AOS01 port fejler (2 gange i træk)", failing.Findings[0].Title); Assert.Equal(RuleOutcome.NotMeasurable, Run(new SyntheticCheckFailingRule(), Check()).Outcome); } [Fact] public void Service_running_but_not_answering_is_said_plainly() { var running = Run(new SyntheticCheckFailingRule(), Check(service: "AOS60$01"), Result(false, 0, service: "Running"), Result(false, 5, service: "Running")); var stopped = Run(new SyntheticCheckFailingRule(), Check(service: "AOS60$01"), Result(false, 0, service: "Stopped"), Result(false, 5, service: "Stopped")); Assert.Equal("AOS01 port svarer ikke, selv om tjenesten AOS60$01 kører", running.Findings[0].Title); Assert.Contains("en tjenesteovervågning alene ikke ser", running.Findings[0].Explanation, StringComparison.Ordinal); Assert.Equal("AOS01 port svarer ikke: tjenesten AOS60$01 er stoppet", stopped.Findings[0].Title); Assert.Equal("AOS60$01: stoppet", stopped.Findings[0].Evidence.Single(e => e.Name == "Tjenestens tilstand").Value); } [Fact] public void A_service_that_does_not_exist_is_said_in_danish() { var missing = Run(new SyntheticCheckFailingRule(), Check(service: "AOS60$01"), Result(false, 0, service: "NotFound"), Result(false, 5, service: "NotFound")); var finding = Assert.Single(missing.Findings); Assert.Equal("AOS01 port svarer ikke: tjenesten AOS60$01 findes ikke", finding.Title); Assert.Contains("findes ikke på AOS01", finding.Explanation, StringComparison.Ordinal); Assert.DoesNotContain("NotFound", finding.Title + finding.Explanation, StringComparison.Ordinal); } [Fact] public void The_failure_message_is_never_in_the_title() { var failing = Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(false, 5), Result(false, 10), Result(true, 15)); var finding = Assert.Single(failing.Findings); Assert.DoesNotContain("afvist", finding.Title, StringComparison.Ordinal); Assert.True(finding.Evidence.Single(e => e.Name == "Fejl").Sensitive); Assert.Equal("3", finding.Evidence.Single(e => e.Name == "Fejlet i træk").Value); Assert.Equal("2026-10-02 11:50 UTC", finding.Evidence.Single(e => e.Name == "Fejler siden").Value); } [Fact] public void When_every_result_failed_the_run_may_be_longer() { var failing = Run(new SyntheticCheckFailingRule(), Check(), Result(false, 0), Result(false, 5), Result(false, 10)); var finding = Assert.Single(failing.Findings); Assert.Equal("AOS01 port fejler (mindst 3 gange i træk)", finding.Title); Assert.Equal("mindst 3", finding.Evidence.Single(e => e.Name == "Fejlet i træk").Value); Assert.StartsWith("2026-10-02 11:50 UTC eller tidligere", finding.Evidence.Single(e => e.Name == "Fejler siden").Value, StringComparison.Ordinal); } [Fact] public void Slow_answers_three_times_in_a_row() { Assert.Equal(RuleOutcome.NotApplicable, Run(new SyntheticCheckSlowRule(), Check(), Result(true, 0, 5000)).Outcome); var slow = Run(new SyntheticCheckSlowRule(), Check(warn: 1000), Result(true, 0, 1500), Result(true, 5, 2500), Result(true, 10, 1200)); Assert.Equal(Severity.Medium, Assert.Single(slow.Findings).Severity); Assert.Equal(RuleOutcome.Passed, Run(new SyntheticCheckSlowRule(), Check(warn: 1000), Result(true, 0, 1500), Result(true, 5, 500), Result(true, 10, 1200)).Outcome); } [Fact] public void Certificate_expiry() { var check = Check(Core.SyntheticChecks.SyntheticCheckKind.Http, https: true); Assert.Equal(RuleOutcome.Passed, Run(new CertificateExpiryRule(), check, Result(true, 0, certificate: Now.AddDays(60))).Outcome); Assert.Equal(Severity.Medium, Run(new CertificateExpiryRule(), check, Result(true, 0, certificate: Now.AddDays(20))).Findings[0].Severity); Assert.Equal(Severity.High, Run(new CertificateExpiryRule(), check, Result(true, 0, certificate: Now.AddDays(3))).Findings[0].Severity); Assert.Equal(Severity.Critical, Run(new CertificateExpiryRule(), check, Result(false, 0, certificate: Now.AddDays(-1))).Findings[0].Severity); Assert.Equal(RuleOutcome.NotApplicable, Run(new CertificateExpiryRule(), Check(), Result(true, 0)).Outcome); Assert.Equal(RuleOutcome.NotMeasurable, Run(new CertificateExpiryRule(), check, Result(false, 0)).Outcome); Assert.Equal("Certifikatet for AOS01 port udløber inden for et døgn", Run(new CertificateExpiryRule(), check, Result(true, 0, certificate: Now.AddHours(5))).Findings[0].Title); Assert.Equal("Certifikatet for AOS01 port udløber om 1 dag", Run(new CertificateExpiryRule(), check, Result(true, 0, certificate: Now.AddHours(30))).Findings[0].Title); } }