- SQLI-WIN-006 is "ikke målbar" (with the forecaster's note) until a volume has enough
history; titles "Drev X er fuldt (om ca. N dage | inden for et døgn)", as SQLI-WIN-005.
- SQLI-WIN-007/008: the startup grace period is "ikke målbar" instead of passed, so
open findings are not resolved and reopened at every restart. One matcher for both
rules (name or display name, prefix*). SQLI-WIN-007 is capped by
Services:MaxFindings (default 15, 1-100) with one summary finding for the rest.
- SQLI-WIN-009: stable order (category, oldest first, key) and one summary finding
over MaxFindings (now bounded 1-100).
- SQLI-WIN-010 only says "uden tegn på at det skete uventet" when the event log was
read past the boot; before that "ikke målbar", and Low "årsagen kan ikke ses" when
the log cannot be read.
- SQLI-WIN-012/013: Tasks:IgnoreUserAndUpdaterTasks (default true) leaves out per-user
and updater tasks; TaskThresholds.Watches is the one filter for the agent and the
rules. A SID is never in a title or subject, only in sensitive evidence.
- SQLI-SYN-001: neutral title "{navn} fejler ({n} gange i træk)", the failure message
only in sensitive evidence; "mindst N" and "... eller tidligere" when every result
given failed; "findes ikke" for a missing service; evidence "Tjenestens tilstand".
- SQLI-SYN-003: "udløber inden for et døgn" / "om 1 dag".
- Agent (minimal): the task filter is TaskThresholds.Watches, and performance samples
get the configured interval.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
528 lines
28 KiB
C#
528 lines
28 KiB
C#
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<string>? watched = null, IReadOnlyList<string>? 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<int, ServerPerformanceSample> 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>(T value)
|
|
where T : class
|
|
{
|
|
var data = new CollectedData();
|
|
data.Set(value);
|
|
return data;
|
|
}
|
|
|
|
private static RuleResult Run(IScopedRule<ServerRuleContext> 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<ServerDiskSnapshot>("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<string, Severity> { [@"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<int, int> 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<SyntheticCheckRuleContext> 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);
|
|
}
|
|
}
|