sqlinsight-pro/tests/SQLInsight.Core.Tests/WindowsServerTests.cs
Søren Eggert Lundsteen Olsen ae20954a91 Windows servers and synthetic checks core: data, disk forecast, event dedup, grant script
- Core/Servers: what the agent collects from a Windows server (CPU and memory from
  raw counters, disks, services, events, scheduled tasks), roles and sources, host
  validation and the computer behind a SQL Server data source.
- DiskForecaster: "fuld om ca. N dage" from a least-squares line over 14 days of
  SQLInsight's own samples, only since the volume last changed size, with sanity
  bounds (history, samples, R², max one year).
- ServerEventAnalysis: noise filter, grouping and a 24-hour window for System and
  Application events, like the SQL Server error log in 1.2.
- db/permissions/grant-windows-server.ps1: least-privilege rights for the agent's
  account (groups, WMI namespaces, SCM read), never local administrator; filled in
  by ServerGrantScript.
- Core/SyntheticChecks: kinds, settings, validation (read-only SQL), results.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:58:17 +02:00

331 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using SQLInsight.Core.Servers;
namespace SQLInsight.Core.Tests;
public class DiskForecastTests
{
private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
/// <summary>Hourly samples for <paramref name="days"/> days back, free space from a function of the age in days.</summary>
private static List<DiskHistoryPoint> History(int days, Func<double, double> free, double size = 100) =>
Enumerable.Range(0, (days * 24) + 1).Select(h => Now.AddHours(-h)).Select(at => new DiskHistoryPoint(at, size, free((Now - at).TotalDays))).Reverse().ToList();
[Fact]
public void Steady_growth_gives_days_until_full()
{
// 1 GB per day used, 20 GB free now: full in 20 days.
var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 20 + age), Now);
Assert.NotNull(forecast.DaysUntilFull);
Assert.Equal(20, forecast.DaysUntilFull!.Value, 1);
Assert.Equal(1, forecast.GrowthGbPerDay, 3);
Assert.Equal(1, forecast.Fit, 3);
Assert.Equal("fuld om ca. 20 dage", forecast.Describe());
Assert.Equal(Now.AddDays(20).Date, forecast.FullAt(Now)!.Value.Date);
}
[Fact]
public void Noise_around_a_trend_still_forecasts()
{
var random = new Random(7);
var forecast = DiskForecaster.Forecast(@"D:\", History(14, age => 30 + (2 * age) + (random.NextDouble() - 0.5)), Now);
Assert.InRange(forecast.DaysUntilFull!.Value, 14, 16);
}
[Fact]
public void A_flat_or_shrinking_volume_has_no_forecast()
{
Assert.Null(DiskForecaster.Forecast(@"C:\", History(14, _ => 40), Now).DaysUntilFull);
var emptying = DiskForecaster.Forecast(@"C:\", History(14, age => 40 - age), Now);
Assert.Null(emptying.DaysUntilFull);
Assert.Equal("fyldes ikke op", emptying.Note);
}
[Fact]
public void A_sawtooth_of_backups_written_and_deleted_is_not_a_trend()
{
// Fills 10 GB every night and is cleaned up every morning.
var forecast = DiskForecaster.Forecast(@"E:\", History(14, age => 50 - ((age % 1) * 10)), Now);
Assert.Null(forecast.DaysUntilFull);
}
[Fact]
public void Too_little_history_says_so()
{
var forecast = DiskForecaster.Forecast(@"D:\", History(1, age => 20 + (5 * age)), Now);
Assert.Null(forecast.DaysUntilFull);
Assert.Contains("for lidt historik", forecast.Note, StringComparison.Ordinal);
}
[Fact]
public void An_extended_volume_starts_a_new_trend()
{
// Was 100 GB and filling fast until 5 days ago; extended to 200 GB and now flat.
var history = History(14, age => age > 5 ? 5 + age : 100).Select(p => (Now - p.At).TotalDays > 5 ? p : p with { SizeGb = 200 }).ToList();
var forecast = DiskForecaster.Forecast(@"D:\", history, Now);
Assert.Null(forecast.DaysUntilFull);
Assert.InRange(forecast.HistoryDays, 4.9, 5.1);
}
[Fact]
public void A_full_volume_is_full_now_and_far_futures_are_not_forecasts()
{
Assert.Equal(0, DiskForecaster.Forecast(@"D:\", History(14, age => Math.Max(0, age - 1)), Now).DaysUntilFull);
var slow = DiskForecaster.Forecast(@"D:\", History(14, age => 500 + (0.5 * age)), Now, new DiskForecastSettings());
Assert.Null(slow.DaysUntilFull);
Assert.Equal("fuld om mere end et år", slow.Note);
}
[Fact]
public void Samples_older_than_the_history_window_are_ignored()
{
// Fast growth 20–40 days ago, flat for the last 14.
var history = Enumerable.Range(0, 40 * 24).Select(h => Now.AddHours(-h))
.Select(at => new DiskHistoryPoint(at, 100, (Now - at).TotalDays > 20 ? (Now - at).TotalDays : 30)).Reverse().ToList();
Assert.Null(DiskForecaster.Forecast(@"D:\", history, Now).DaysUntilFull);
}
}
public class ServerPerformanceTests
{
private static readonly DateTimeOffset T0 = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void Cpu_is_one_minus_the_idle_share_of_the_interval()
{
var first = new ServerPerformanceRaw { CapturedAt = T0, ProcessorIdle100Ns = 1_000_000_000, ProcessorTimestamp100Ns = 5_000_000_000 };
// One minute = 600,000,000 × 100 ns; idle 150,000,000 of it → 75 % busy.
var second = first with { CapturedAt = T0.AddMinutes(1), ProcessorIdle100Ns = 1_150_000_000, ProcessorTimestamp100Ns = 5_600_000_000 };
Assert.Equal(75, ServerPerformanceMath.Cpu(first, second)!.Value, 6);
Assert.Null(ServerPerformanceMath.Cpu(null, second));
Assert.Null(ServerPerformanceMath.Cpu(second, first)); // counters went backwards: rebooted
}
[Fact]
public void Pages_per_second_uses_the_performance_frequency()
{
var first = new ServerPerformanceRaw { CapturedAt = T0, Pages = 1000, MemoryPerfTime = 10_000_000, MemoryPerfFrequency = 10_000_000 };
var second = first with { CapturedAt = T0.AddMinutes(1), Pages = 61_000, MemoryPerfTime = 610_000_000 };
Assert.Equal(1000, ServerPerformanceMath.PagesPerSecond(first, second)!.Value, 6);
}
[Fact]
public void Sample_has_available_percent_and_commit()
{
var raw = new ServerPerformanceRaw { CapturedAt = T0, AvailableMb = 1024, CommittedBytes = 9, CommitLimit = 10 };
var sample = ServerPerformanceMath.Sample(null, raw, new ServerOsInfo { TotalMemoryMb = 8192 });
Assert.Equal(12.5, sample.AvailablePercent!.Value, 6);
Assert.Equal(90, sample.CommitPercent!.Value, 6);
Assert.Null(sample.CpuPercent);
}
[Fact]
public void Sustained_needs_most_of_the_period_covered()
{
var samples = Enumerable.Range(0, 15).Select(m => new ServerPerformanceSample(T0.AddMinutes(-m), 95, null, null, null, null)).Reverse().ToList();
Assert.Equal(95, ServerPerformanceMath.SustainedAverage(samples, s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1)));
Assert.Null(ServerPerformanceMath.SustainedAverage(samples.TakeLast(5).ToList(), s => s.CpuPercent, TimeSpan.FromMinutes(15), T0, TimeSpan.FromMinutes(1)));
}
[Fact]
public void The_window_keeps_an_hour()
{
IReadOnlyList<ServerPerformanceSample> window = [];
for (var m = 0; m < 90; m++)
{
window = ServerPerformanceMath.Append(window, new ServerPerformanceSample(T0.AddMinutes(m), 10, null, null, null, null));
}
Assert.Equal(60, window.Count);
Assert.Equal(T0.AddMinutes(89), window[^1].At);
}
}
public class ServerEventTests
{
private static readonly DateTimeOffset Now = new(2026, 10, 2, 12, 0, 0, TimeSpan.Zero);
private static ServerEventEntry Entry(string source, long id, int level = 2, string log = "System", int minutesAgo = 1, string? message = "tekst") =>
new(log, source, id, level, Now.AddMinutes(-minutesAgo), message);
[Theory]
[InlineData("Microsoft-Windows-Kernel-Power", 41, 1, ServerEventCategory.UnexpectedShutdown)]
[InlineData("EventLog", 6008, 2, ServerEventCategory.UnexpectedShutdown)]
[InlineData("disk", 153, 3, ServerEventCategory.Disk)]
[InlineData("storvsc", 129, 3, ServerEventCategory.Disk)]
[InlineData("Ntfs", 55, 2, ServerEventCategory.Disk)]
[InlineData("Srv", 2019, 2, ServerEventCategory.Resources)]
[InlineData("Service Control Manager", 7031, 2, ServerEventCategory.ServiceFailure)]
[InlineData("Application Error", 1000, 2, ServerEventCategory.ApplicationCrash)]
[InlineData("Microsoft Dynamics AX Object Server 6.0$01", 110, 2, ServerEventCategory.DynamicsAx)]
[InlineData("Some-Driver", 5, 1, ServerEventCategory.Critical)]
[InlineData("Some-App", 5, 2, ServerEventCategory.Error)]
public void Categorize(string source, long id, int level, ServerEventCategory expected) =>
Assert.Equal(expected, ServerEventAnalysis.Categorize(Entry(source, id, level)));
[Fact]
public void Repeated_events_are_one_group_with_the_newest_sample()
{
var (groups, ignored) = ServerEventAnalysis.Group(
[
Entry("Service Control Manager", 7031, minutesAgo: 30, message: "AOS stoppede (1)"),
Entry("Service Control Manager", 7031, minutesAgo: 5, message: "AOS stoppede (2)"),
Entry("service control manager", 7031, minutesAgo: 10, message: null),
Entry("Some-App", 9, log: "Application"),
Entry("Some-App", 9, level: 3, log: "Application"), // a warning: not collected
], ServerEventAnalysis.DefaultIgnore);
Assert.Equal(0, ignored);
Assert.Equal(2, groups.Count);
var scm = groups[0];
Assert.Equal(ServerEventCategory.ServiceFailure, scm.Category);
Assert.Equal(3, scm.Count);
Assert.Equal("AOS stoppede (2)", scm.Sample);
Assert.Equal(Now.AddMinutes(-30), scm.FirstAt);
Assert.Equal(Now.AddMinutes(-5), scm.LastAt);
}
[Fact]
public void Known_noise_and_sql_server_sources_are_left_out()
{
var (groups, ignored) = ServerEventAnalysis.Group(
[
Entry("Microsoft-Windows-DistributedCOM", 10016),
Entry("Schannel", 36887),
Entry("MSSQL$PROD", 17053, log: "Application"),
Entry("MSSQLSERVER", 823, log: "Application"),
Entry("Schannel", 36871),
], ServerEventAnalysis.DefaultIgnore);
Assert.Equal(4, ignored);
Assert.Equal(36871, Assert.Single(groups).EventId);
}
[Theory]
[InlineData("Some-App:9", true)]
[InlineData("Some-App", true)]
[InlineData("Some*", true)]
[InlineData("Some*:10", false)]
[InlineData("Some-App:*", true)]
[InlineData("Other:9", false)]
public void Ignore_patterns(string pattern, bool ignored) =>
Assert.Equal(ignored, ServerEventAnalysis.IsIgnored(Entry("Some-App", 9), [pattern]));
[Fact]
public void Recent_merges_groups_of_the_last_day_and_drops_older()
{
var (earlier, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 60 * 20), Entry("Some-App", 9, minutesAgo: 60 * 30)], []);
var (current, _) = ServerEventAnalysis.Group([Entry("disk", 153, minutesAgo: 2)], []);
var recent = ServerEventAnalysis.Recent(earlier, current, Now);
var disk = Assert.Single(recent);
Assert.Equal(2, disk.Count);
Assert.Equal(Now.AddHours(-20), disk.FirstAt);
Assert.Equal(Now.AddMinutes(-2), disk.LastAt);
}
[Fact]
public void Samples_are_cut()
{
var (groups, _) = ServerEventAnalysis.Group([Entry("Some-App", 9, message: new string('x', 2000))], []);
Assert.Equal(500, groups[0].Sample!.Length);
}
}
public class ServerConfigurationTests
{
[Theory]
[InlineData("aos01", null)]
[InlineData("aos01.firma.local", null)]
[InlineData("10.0.0.5", null)]
[InlineData("fe80::1", null)]
[InlineData(@"sql01\PROD", "uden instansnavn")]
[InlineData("aos01:2712", "uden instansnavn")]
[InlineData("", "Angiv")]
public void Hosts(string host, string? error)
{
var result = ServerHost.Validate(host);
if (error is null)
{
Assert.Null(result);
}
else
{
Assert.Contains(error, result, StringComparison.Ordinal);
}
}
[Theory]
[InlineData(@"tcp:SQL01.firma.local\PROD,1433", "sql01.firma.local", false)]
[InlineData("sql02,1500", "sql02", false)]
[InlineData(@".\SQLEXPRESS", "agent01", false)]
[InlineData("(local)", "agent01", false)]
[InlineData("localhost", "agent01", false)]
[InlineData("(localdb)\\MSSQLLocalDB", null, true)]
[InlineData("kunde.database.windows.net", null, true)]
public void The_computer_behind_a_sql_data_source(string dataSource, string? host, bool skipped)
{
var (found, reason) = ServerHost.FromDataSource(dataSource, "AGENT01");
Assert.Equal(host, found);
Assert.Equal(skipped, reason is not null);
}
[Fact]
public void Lists_of_service_names()
{
Assert.Equal(["AOS60$01", "W3SVC"], ServerHost.ParseList("AOS60$01, W3SVC\nw3svc;"));
Assert.Null(ServerHost.ValidateServiceNames(["AOS60$01", "MSSQL$PROD"]));
Assert.NotNull(ServerHost.ValidateServiceNames([@"bad\name"]));
}
[Theory]
[InlineData(@"NT SERVICE\SQLInsight Agent", "aos01.firma.local", @"FIRMA\AGENT01$")]
[InlineData("LocalSystem", "aos01", @"FIRMA\AGENT01$")]
[InlineData(@"NT SERVICE\SQLInsight Agent", "agent01.firma.local", @"NT SERVICE\SQLInsight Agent")]
[InlineData(@"FIRMA\gmsa-sqlinsight$", "aos01", @"FIRMA\gmsa-sqlinsight$")]
public void The_account_the_agent_arrives_as(string service, string host, string expected) =>
Assert.Equal(expected, ServerGrantScript.AccountFor(service, host, "AGENT01", "FIRMA"));
[Fact]
public void Grant_script_is_filled_in_and_never_makes_the_agent_administrator()
{
var script = ServerGrantScript.For(@"FIRMA\AGENT01$");
Assert.Contains(@"[string] $Account = 'FIRMA\AGENT01$',", script, StringComparison.Ordinal);
Assert.DoesNotContain("DOMÆNE\\AGENTSERVER$', #", script, StringComparison.Ordinal);
Assert.Contains("S-1-5-32-558", script, StringComparison.Ordinal); // Performance Monitor Users
Assert.Contains("S-1-5-32-573", script, StringComparison.Ordinal); // Event Log Readers
Assert.DoesNotContain("S-1-5-32-544", script, StringComparison.Ordinal); // Administrators
Assert.Contains("0x21", script, StringComparison.Ordinal);
Assert.Contains("CCLCRPRC", script, StringComparison.Ordinal);
Assert.Equal(script, ServerGrantScript.For(@"FIRMA\AGENT01$"));
Assert.Contains("'O''Brien'", ServerGrantScript.For("O'Brien"), StringComparison.Ordinal);
}
[Theory]
[InlineData(0u, false)]
[InlineData(TaskResults.Running, false)]
[InlineData(TaskResults.HasNotRun, false)]
[InlineData(TaskResults.AlreadyRunning, false)]
[InlineData(1u, true)]
[InlineData(0x80070005u, true)]
[InlineData(TaskResults.TerminatedByUser, true)]
public void Task_results(uint result, bool failure)
{
Assert.Equal(failure, TaskResults.IsFailure(result));
Assert.False(string.IsNullOrWhiteSpace(TaskResults.Describe(result)));
}
}