The grant script changes SCM and WMI security on customer servers. Before, a re-run replaced every ACE of the account on a WMI namespace (also a customer's Deny), -Remove took the account out of groups and ACEs it had before, only the SCM SDDL was backed up (and never used), Remote Management Users also opened PowerShell Remoting, and -EnableFirewall narrowed Windows' built-in rules for every other tool and was never undone. - Checks everything before changing anything: elevation, the account resolves (broad groups refused), Deny ACEs stop the script, -EnableFirewall needs -AgentAddress. - Backs up the original SDDLs (SCM, WMI namespaces, WinRM), group memberships and firewall rules before the first change; records what it added in a state file in an administrators-only folder; -Remove removes only that. - SDDL handled with RawSecurityDescriptor (GetSD/SetSD, sc sdshow/sdset): exactly one ACE added in canonical order, verified before writing and after; aliases (NS) work. - No Remote Management Users: -WinRM adds GENERIC_READ (no Execute) to WinRM's RootSDDL; DCOM is the default. - Firewall: its own "SQLInsight Agent: ..." rules only from the agent's address, removed again with -Remove; Windows' rules untouched. - -WhatIf, groups by SID, Danish messages, PowerShell 2.0 syntax. - ServerGrantScript.For does not fill in an account that could end the PowerShell string (typographic quotes, control characters); tests run the SDDL logic in Windows PowerShell against real default SDDLs. - docs/windows-servere.md: rights, safety net, firewall. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
315 lines
13 KiB
C#
315 lines
13 KiB
C#
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"));
|
||
|
||
[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)));
|
||
}
|
||
}
|