Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
- ASP.NET Core Blazor static SSR: overview with KPI tiles, instance page (findings, CPU/batch/PLE trends, top waits, file latency, blocking, deadlocks with .xdl download, not-measurable rules, resolved), finding detail with explanation, evidence, fix script and references - Light-first design (sidebar, cards, self-hosted Inter, teal accent), optional dark theme via toggle; strict CSP, no inline styles - Windows authentication (Negotiate/IIS) with optional group restriction; refuses to run without authentication outside Development - Hosting: IIS (in-process ASP.NET Core Module), Windows service with Kestrel, or systemd; web reads the repository with db_datareader only - docs/installation.md for repository, permissions, agent and web - Fix: CPU used by other processes is not derived on Linux (SystemIdle is unreliable there); deadlocks are not re-reported or stored twice after an agent restart; Dapper sends datetime2 so time comparisons are exact Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
252 lines
12 KiB
C#
252 lines
12 KiB
C#
using Microsoft.Data.SqlClient;
|
|
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Core.Instances;
|
|
using SQLInsight.Repository;
|
|
using SQLInsight.Rules.Engine;
|
|
|
|
namespace SQLInsight.IntegrationTests;
|
|
|
|
[Collection(SqlServerTestGroup.Name)]
|
|
[Trait("Category", "Integration")]
|
|
public class RepositoryTests(SqlServerFixture sql) : IAsyncLifetime
|
|
{
|
|
private readonly string _database = "SQLInsightRepo_" + Guid.NewGuid().ToString("N")[..8];
|
|
private InsightRepository _repository = null!;
|
|
|
|
private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
|
|
|
|
private static readonly InstanceInfo Info = new()
|
|
{
|
|
ServerName = "SQL01",
|
|
Version = SqlServerVersion.Parse("16.0.4135.4"),
|
|
ProductLevel = "RTM",
|
|
ProductUpdateLevel = "CU14",
|
|
Edition = "Enterprise Edition",
|
|
EngineEdition = EngineEdition.Enterprise,
|
|
};
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
|
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
|
|
await _repository.MigrateAsync();
|
|
}
|
|
|
|
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
|
|
|
[Fact]
|
|
public async Task Migrations_are_idempotent_and_safe_to_run_concurrently()
|
|
{
|
|
var applied = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => _repository.MigrateAsync()));
|
|
|
|
Assert.All(applied, count => Assert.Equal(0, count));
|
|
Assert.Equal(SchemaMigrator.Scripts().Count, await ScalarAsync<int>("SELECT COUNT(*) FROM insight.SchemaVersions"));
|
|
}
|
|
|
|
private static Finding Finding(string subject, Severity severity = Severity.High) => new()
|
|
{
|
|
RuleId = "SQLI-TEST-001",
|
|
Severity = severity,
|
|
Title = $"Problem med {subject}",
|
|
Subject = subject,
|
|
Explanation = "Forklaring",
|
|
Recommendation = "Anbefaling",
|
|
Evidence = [new Evidence("Måling", "42")],
|
|
References = [new Reference("Docs", new Uri("https://learn.microsoft.com/sql/"))],
|
|
FixScript = "SELECT 1;",
|
|
};
|
|
|
|
private static RuleResult Result(RuleOutcome outcome, params Finding[] findings) => new()
|
|
{
|
|
RuleId = "SQLI-TEST-001",
|
|
RuleTitle = "Testregel",
|
|
Outcome = outcome,
|
|
Findings = findings,
|
|
Reason = outcome == RuleOutcome.NotMeasurable ? "Kræver Query Store" : null,
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Findings_are_tracked_across_evaluations()
|
|
{
|
|
var id = await _repository.UpsertInstanceAsync("sql01", Info, "SqlClient", false, Now);
|
|
|
|
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("database: A"), Finding("database: B"))], Now);
|
|
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("database: A", Severity.Critical))], Now.AddMinutes(15));
|
|
|
|
var active = await _repository.GetActiveFindingsAsync(id);
|
|
var a = Assert.Single(active);
|
|
Assert.Equal("database: A", a.Subject);
|
|
Assert.Equal(Severity.Critical, a.Severity);
|
|
Assert.Equal(Now.UtcDateTime, a.FirstSeenAt);
|
|
Assert.Equal(Now.AddMinutes(15).UtcDateTime, a.LastSeenAt);
|
|
Assert.Equal("42", Assert.Single(a.Evidence).Value);
|
|
Assert.Equal("SELECT 1;", a.FixScript);
|
|
|
|
var resolved = Assert.Single(await _repository.GetRecentlyResolvedAsync(id, 3650));
|
|
Assert.Equal("database: B", resolved.Subject);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Not_measurable_does_not_resolve_findings()
|
|
{
|
|
var id = await _repository.UpsertInstanceAsync("sql02", Info, "SqlClient", false, Now);
|
|
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("database: A"))], Now);
|
|
|
|
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.NotMeasurable)], Now.AddMinutes(15));
|
|
|
|
Assert.Single(await _repository.GetActiveFindingsAsync(id));
|
|
var state = Assert.Single(await _repository.GetRuleStatesAsync(id));
|
|
Assert.Equal(RuleOutcome.NotMeasurable, state.Outcome);
|
|
Assert.Equal("Kræver Query Store", state.Reason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Returning_finding_starts_a_new_occurrence()
|
|
{
|
|
var id = await _repository.UpsertInstanceAsync("sql03", Info, "SqlClient", false, Now);
|
|
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("x"))], Now);
|
|
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Passed)], Now.AddHours(1));
|
|
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("x"))], Now.AddHours(2));
|
|
|
|
var active = Assert.Single(await _repository.GetActiveFindingsAsync(id));
|
|
Assert.Equal(Now.AddHours(2).UtcDateTime, active.FirstSeenAt);
|
|
Assert.Equal(2, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.Findings WHERE InstanceId = {id}"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Overview_counts_findings_and_not_measurable_rules()
|
|
{
|
|
var id = await _repository.UpsertInstanceAsync("sql04", Info, "Odbc", true, Now);
|
|
await _repository.SaveRuleResultsAsync(id,
|
|
[
|
|
Result(RuleOutcome.Findings, Finding("a", Severity.Critical), Finding("b", Severity.Low)),
|
|
Result(RuleOutcome.NotMeasurable) with { RuleId = "SQLI-TEST-002" },
|
|
], Now);
|
|
|
|
var overview = Assert.Single(await _repository.GetInstancesAsync(), i => i.Name == "sql04");
|
|
|
|
Assert.Equal((1, 1, 1), (overview.Critical, overview.Low, overview.NotMeasurable));
|
|
Assert.Equal(Severity.Critical, overview.WorstSeverity);
|
|
Assert.True(overview.UsedLegacyFallback);
|
|
Assert.Equal("16.0.4135.4", overview.ProductVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Instance_errors_are_recorded_and_shown_as_failing()
|
|
{
|
|
await _repository.UpsertInstanceAsync("sql05", Info, "SqlClient", false, Now);
|
|
await _repository.RecordInstanceErrorAsync("sql05", "Login failed", Now.AddMinutes(5));
|
|
|
|
var overview = Assert.Single(await _repository.GetInstancesAsync(), i => i.Name == "sql05");
|
|
|
|
Assert.True(overview.IsFailing);
|
|
Assert.Equal("Login failed", overview.LastError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Time_series_are_stored_once_and_aggregated()
|
|
{
|
|
var id = await _repository.UpsertInstanceAsync("sql06", Info, "SqlClient", false, Now);
|
|
var recent = DateTimeOffset.UtcNow;
|
|
var cpu = new CpuUtilization
|
|
{
|
|
CapturedAt = recent,
|
|
Samples = Enumerable.Range(0, 10).Select(i => new CpuSample(recent.AddMinutes(-10 + i), 40 + i, 50)).ToList(),
|
|
};
|
|
var waits = new WaitStatsDelta { From = recent.AddMinutes(-5), To = recent, Waits = [new WaitStat("LCK_M_X", 5, 3000, 10), new WaitStat("WRITELOG", 100, 300, 5)] };
|
|
var waitSample = new CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>(
|
|
new WaitStatsSnapshot { CapturedAt = recent, SqlServerStartTime = DateTime.UtcNow.AddDays(-1), Waits = [] }, waits);
|
|
|
|
await _repository.SaveCollectedAsync(id, "cpu", cpu);
|
|
await _repository.SaveCollectedAsync(id, "cpu", cpu); // the ring buffer overlaps between collections
|
|
await _repository.SaveCollectedAsync(id, "wait-stats", waitSample);
|
|
await _repository.SaveCollectedAsync(id, "wait-stats", waitSample);
|
|
await _repository.RunMaintenanceAsync(new RetentionOptions(), DateTimeOffset.UtcNow);
|
|
|
|
var series = await _repository.GetMetricAsync(id, Metrics.CpuSqlServer, recent.AddHours(-1));
|
|
Assert.Equal(10, series.Count);
|
|
Assert.Equal(49, series[^1].Value);
|
|
Assert.Equal(20, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}"));
|
|
Assert.True(await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricHourly WHERE InstanceId = {id}") >= 2);
|
|
|
|
var top = await _repository.GetTopWaitsAsync(id, recent.AddHours(-1));
|
|
Assert.Equal(["LCK_M_X", "WRITELOG"], top.Select(w => w.WaitType));
|
|
Assert.Equal(3000, top[0].WaitTimeMs);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Maintenance_deletes_data_past_retention()
|
|
{
|
|
var id = await _repository.UpsertInstanceAsync("sql07", Info, "SqlClient", false, Now);
|
|
await ExecuteAsync($"""
|
|
INSERT insight.MetricSamples (InstanceId, Metric, SampledAt, Value) VALUES ({id}, 'cpu.sqlserver', DATEADD(day, -20, SYSUTCDATETIME()), 1), ({id}, 'cpu.sqlserver', DATEADD(day, -1, SYSUTCDATETIME()), 2);
|
|
INSERT insight.MetricHourly (InstanceId, Metric, [Hour], AvgValue, MaxValue, Samples) VALUES ({id}, 'cpu.sqlserver', DATEADD(day, -500, SYSUTCDATETIME()), 1, 1, 1);
|
|
INSERT insight.Deadlocks (InstanceId, OccurredAt, ProcessesJson, GraphXml) VALUES ({id}, DATEADD(day, -100, SYSUTCDATETIME()), '[]', '<deadlock/>');
|
|
""");
|
|
|
|
await _repository.RunMaintenanceAsync(new RetentionOptions(), DateTimeOffset.UtcNow);
|
|
|
|
Assert.Equal(1, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}"));
|
|
Assert.Equal(0, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricHourly WHERE InstanceId = {id} AND [Hour] < DATEADD(day, -400, SYSUTCDATETIME())"));
|
|
Assert.Equal(0, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.Deadlocks WHERE InstanceId = {id}"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Events_and_snapshots_round_trip()
|
|
{
|
|
var id = await _repository.UpsertInstanceAsync("sql08", Info, "SqlClient", false, Now);
|
|
var at = DateTimeOffset.UtcNow;
|
|
var deadlocks = new DeadlockSnapshot
|
|
{
|
|
CapturedAt = at,
|
|
NewDeadlocks = [new DeadlockEvent { Time = at, GraphXml = "<deadlock/>", Processes = [new DeadlockProcess { Id = "p1", IsVictim = true, SessionId = 60 }] }],
|
|
};
|
|
var blocking = new BlockingSnapshot
|
|
{
|
|
CapturedAt = at,
|
|
Sessions =
|
|
[
|
|
new SessionInfo { SessionId = 51, Status = "sleeping", OpenTransactionCount = 1 },
|
|
new SessionInfo { SessionId = 60, BlockingSessionId = 51, WaitTimeMs = 45_000 },
|
|
new SessionInfo { SessionId = 70 },
|
|
new SessionInfo { SessionId = 71, BlockingSessionId = 70, WaitTimeMs = 1_000 }, // too short to store
|
|
],
|
|
};
|
|
var config = new ConfigurationSnapshot { CapturedAt = at, Options = [new ConfigurationOption("max degree of parallelism", 8, 8)] };
|
|
|
|
await _repository.SaveCollectedAsync(id, "deadlocks", deadlocks);
|
|
await _repository.SaveCollectedAsync(id, "deadlocks", deadlocks); // duplicates are ignored
|
|
await _repository.SaveCollectedAsync(id, "blocking", blocking);
|
|
await _repository.SaveCollectedAsync(id, "configuration", config);
|
|
|
|
var deadlock = Assert.Single(await _repository.GetDeadlocksAsync(id, at.AddHours(-1)));
|
|
Assert.Equal(InsightRepository.UtcMilliseconds(at), (await _repository.GetLatestDeadlockTimeAsync(id))!.Value.UtcDateTime);
|
|
Assert.True(Assert.Single(deadlock.Processes).IsVictim);
|
|
Assert.Equal("<deadlock/>", (await _repository.GetDeadlockAsync(deadlock.DeadlockId))!.GraphXml);
|
|
|
|
var chain = Assert.Single(await _repository.GetBlockingAsync(id, at.AddHours(-1)));
|
|
Assert.Equal((51, 1, 45_000L), (chain.HeadSessionId, chain.BlockedCount, chain.MaxWaitMs));
|
|
|
|
var stored = await _repository.GetSnapshotAsync<ConfigurationSnapshot>(id, "configuration");
|
|
Assert.Equal(8, stored!.Find("max degree of parallelism")!.ValueInUse);
|
|
}
|
|
|
|
private async Task<T> ScalarAsync<T>(string query)
|
|
{
|
|
await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
|
|
await connection.OpenAsync();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = query;
|
|
return (T)Convert.ChangeType((await command.ExecuteScalarAsync())!, typeof(T), System.Globalization.CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private async Task ExecuteAsync(string query)
|
|
{
|
|
await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
|
|
await connection.OpenAsync();
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = query;
|
|
await command.ExecuteNonQueryAsync();
|
|
}
|
|
}
|