- Menu group "Servere" with Windows-servere (/servere) and Syntetiske tjek
(/syntetiske-tjek).
- /servere: every server with status (overvåges, delvist målbar, kan ikke læses),
CPU, free memory, the fullest disk and the nearest "fuld om ca. N dage", findings;
add application/AOS/file servers. /servere/{id}: findings and what is not
measurable and why, CPU/memory for the last day, disks with forecasts, watched
and stopped services, grouped event log errors, scheduled tasks, rights per area
with the latest test, the least-privilege PowerShell script (copy or download,
UTF-8 with BOM), role and service lists, pause and remove. /servere/fund/{id}.
- /syntetiske-tjek: checks with status and response time; one form for every kind
(TCP, HTTP(S), SQL login + query, DNS, AX logon) with an optional server and
service; a SQL login password is sealed for the agent at once and never shown.
/syntetiske-tjek/{id}: latest result (incl. "kun porten er testet"), findings,
response times, history, edit, run now, pause, remove.
- API v1: /windows-servers (+ test, grant-script, performance, disks, services,
events, tasks, rule-states), /server-findings, /synthetic-checks (+ run, results,
rule-states); all in OpenAPI. Passwords are write-only; event samples, failure
messages and sensitive evidence are "[udeladt]" unless
SQLInsight:Web:ApiIncludesSensitiveEvidence is on. /api/v1/rules lists the new rules.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
230 lines
15 KiB
C#
230 lines
15 KiB
C#
using System.Net;
|
|
using System.Security.Cryptography;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using SQLInsight.Core.Configuration;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Core.Servers;
|
|
using SQLInsight.Core.SyntheticChecks;
|
|
using SQLInsight.Repository;
|
|
using SQLInsight.Rules.Engine;
|
|
|
|
namespace SQLInsight.IntegrationTests;
|
|
|
|
/// <summary>Windows-servere and Syntetiske tjek in SQLInsight Web: the menu, the pages, the forms, the grant script.</summary>
|
|
[Collection(SqlServerTestGroup.Name)]
|
|
[Trait("Category", "Integration")]
|
|
public sealed partial class ServerWebTests(SqlServerFixture sql) : IAsyncLifetime
|
|
{
|
|
private readonly string _database = "SQLInsightSrvWeb_" + Guid.NewGuid().ToString("N")[..8];
|
|
private readonly RSA _agentKey = RSA.Create(3072);
|
|
private WebApplicationFactory<Program> _factory = null!;
|
|
private InsightRepository _repository = null!;
|
|
|
|
[GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")]
|
|
private static partial Regex AntiforgeryField();
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
|
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
|
|
await _repository.MigrateAsync();
|
|
await _repository.PublishAgentKeyAsync(new AgentKeyRow
|
|
{
|
|
KeyId = "agent-test", PublicKey = _agentKey.ExportSubjectPublicKeyInfo(), MachineName = "AGENT01", ServiceAccount = @"NT SERVICE\SQLInsight Agent",
|
|
Domain = "FIRMA", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
|
|
});
|
|
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
|
{
|
|
b.UseEnvironment("Development");
|
|
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
|
|
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
|
});
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
_agentKey.Dispose();
|
|
await _factory.DisposeAsync();
|
|
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
|
}
|
|
|
|
private HttpClient Client() => _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
|
|
|
private static async Task<HttpResponseMessage> PostAsync(HttpClient client, string page, string action, Dictionary<string, string> fields)
|
|
{
|
|
var html = await client.GetStringAsync(page);
|
|
var values = new List<KeyValuePair<string, string>> { new("__RequestVerificationToken", WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value)) };
|
|
values.AddRange(fields);
|
|
using var content = new FormUrlEncodedContent(values);
|
|
return await client.PostAsync(action, content);
|
|
}
|
|
|
|
private static int IdFrom(HttpResponseMessage response, string page) =>
|
|
int.Parse(Regex.Match(response.Headers.Location!.OriginalString, page + @"/(\d+)").Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
|
|
|
|
[Fact]
|
|
public async Task The_menu_has_a_servers_group_that_opens_on_its_pages()
|
|
{
|
|
var page = await Client().GetStringAsync("/servere");
|
|
var checks = await Client().GetStringAsync("/syntetiske-tjek");
|
|
|
|
Assert.Contains("<span>Servere</span>", page, StringComparison.Ordinal);
|
|
Assert.Contains("href=\"/servere\"", page, StringComparison.Ordinal);
|
|
Assert.Contains("href=\"/syntetiske-tjek\"", page, StringComparison.Ordinal);
|
|
Assert.Matches("<details class=\"nav-group\" open[^>]*data-nav-group=\"servere\"", page);
|
|
Assert.Matches("<details class=\"nav-group\" open[^>]*data-nav-group=\"servere\"", checks);
|
|
Assert.Contains("Der er ingen Windows-servere endnu", page, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Admin_adds_a_server_sees_its_data_findings_rights_and_the_grant_script()
|
|
{
|
|
var client = Client();
|
|
var posted = await PostAsync(client, "/servere", "/servere/tilfoej", new() { ["host"] = "AOS01.firma.local", ["role"] = "Application", ["watched"] = "AOS60$01, W3SVC" });
|
|
|
|
Assert.Equal(HttpStatusCode.Redirect, posted.StatusCode);
|
|
var id = IdFrom(posted, "/servere");
|
|
var row = (await _repository.GetWindowsServerAsync(id))!;
|
|
Assert.Equal(("AOS01", "aos01.firma.local", ServerRoles.Application), (row.Name, row.Host, row.Role));
|
|
Assert.Equal(["AOS60$01", "W3SVC"], row.Watched);
|
|
Assert.Equal(AgentRequestStatus.Pending, (await _repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, id))!.Status);
|
|
|
|
// What the agent would have collected.
|
|
var now = DateTimeOffset.UtcNow;
|
|
await _repository.RecordServerContactAsync(id, new ServerOsInfo { Caption = "Microsoft Windows Server 2019 Standard", LastBootUpTime = now.AddDays(-3), TotalMemoryMb = 16384 },
|
|
"WS-Man", [new ServerAreaStatus(ServerArea.Events, false, "Adgang nægtet til hændelseslog: mangler Event Log Readers.", ServerArea.Right(ServerArea.Events))], now);
|
|
await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(now.AddMinutes(-2), 35, 6000, 16384, 1, 40));
|
|
await _repository.SaveServerPerformanceAsync(id, new ServerPerformanceSample(now.AddMinutes(-1), 45, 5900, 16384, 1, 40));
|
|
await _repository.SaveServerSnapshotAsync(id, ServerArea.Disks, new ServerDiskSnapshot
|
|
{
|
|
CapturedAt = now, Disks = [new ServerDisk(@"D:\", "Data", "NTFS", 500, 40)], Forecasts = [new DiskForecast(@"D:\", 12, 3.3, 300, 14, 0.9)],
|
|
}, now);
|
|
await _repository.SaveServerSnapshotAsync(id, ServerArea.Services, new ServerServiceSnapshot
|
|
{
|
|
CapturedAt = now, Services = [new ServerService("AOS60$01", "Microsoft Dynamics AX Object Server 6.0$01", "Stopped", "Auto", ExitCode: 1067)],
|
|
}, now);
|
|
await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, id,
|
|
[
|
|
new RuleResult
|
|
{
|
|
RuleId = "SQLI-WIN-006", RuleTitle = "Et drev bliver fuldt om få dage", Outcome = RuleOutcome.Findings,
|
|
Findings = [new Finding { RuleId = "SQLI-WIN-006", Severity = Severity.Medium, Title = @"Disken D:\ er fuld om ca. 12 dage", Explanation = "e", Recommendation = "r", Subject = @"forecast: D:\" }],
|
|
},
|
|
new RuleResult { RuleId = "SQLI-WIN-009", RuleTitle = "Fejl i Windows' hændelseslog", Outcome = RuleOutcome.NotMeasurable, Reason = "Adgang nægtet til hændelseslog: mangler Event Log Readers." },
|
|
], now);
|
|
var finding = Assert.Single(await _repository.GetServerFindingsAsync(FindingScope.Server, id));
|
|
|
|
var detail = await client.GetStringAsync($"/servere/{id}");
|
|
var list = await client.GetStringAsync("/servere");
|
|
var findingPage = await client.GetStringAsync($"/servere/fund/{finding.FindingId}");
|
|
var script = await client.GetAsync($"/servere/{id}/grant-windows-server.ps1");
|
|
|
|
Assert.Contains("Microsoft Windows Server 2019 Standard", detail, StringComparison.Ordinal);
|
|
Assert.Contains("fuld om ca. 12 dage", detail, StringComparison.Ordinal);
|
|
Assert.Contains("Microsoft Dynamics AX Object Server 6.0$01", detail, StringComparison.Ordinal);
|
|
Assert.Contains("Ikke målbar", detail, StringComparison.Ordinal);
|
|
Assert.Contains("mangler Event Log Readers", detail, StringComparison.Ordinal);
|
|
Assert.Contains(@"[string] $Account = 'FIRMA\AGENT01$'", detail, StringComparison.Ordinal);
|
|
Assert.Contains("aldrig være lokal administrator", detail, StringComparison.Ordinal);
|
|
Assert.Contains("Delvist målbar", list, StringComparison.Ordinal);
|
|
Assert.Contains(@"D:\ fuld om ca. 12 dage", list, StringComparison.Ordinal);
|
|
Assert.Contains(@"Disken D:\ er fuld om ca. 12 dage", findingPage, StringComparison.Ordinal);
|
|
Assert.Equal("text/plain; charset=utf-8", script.Content.Headers.ContentType!.ToString());
|
|
var bytes = await script.Content.ReadAsByteArrayAsync();
|
|
Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]);
|
|
Assert.Contains(@"$Account = 'FIRMA\AGENT01$'", System.Text.Encoding.UTF8.GetString(bytes), StringComparison.Ordinal);
|
|
|
|
var saved = await PostAsync(client, $"/servere/{id}", $"/servere/{id}/indstillinger", new() { ["role"] = "File", ["watched"] = "LanmanServer", ["ignored"] = "MyBatch*" });
|
|
Assert.Equal(HttpStatusCode.Redirect, saved.StatusCode);
|
|
Assert.Equal((ServerRoles.File, "LanmanServer", "MyBatch*"), ((await _repository.GetWindowsServerAsync(id))!.Role, (await _repository.GetWindowsServerAsync(id))!.Watched[0],
|
|
(await _repository.GetWindowsServerAsync(id))!.Ignored[0]));
|
|
await PostAsync(client, $"/servere/{id}", $"/servere/{id}/test", []);
|
|
Assert.Equal(2, (await _repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, id))!.RequestId);
|
|
await PostAsync(client, $"/servere/{id}", $"/servere/{id}/fjern", []);
|
|
Assert.Null(await _repository.GetWindowsServerAsync(id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bad_input_is_refused_with_a_reason_and_a_sql_host_cannot_be_removed()
|
|
{
|
|
var client = Client();
|
|
var bad = await PostAsync(client, "/servere", "/servere/tilfoej", new() { ["host"] = @"sql01\PROD" });
|
|
Assert.Contains("uden instansnavn", Uri.UnescapeDataString(bad.Headers.Location!.OriginalString), StringComparison.Ordinal);
|
|
|
|
await _repository.SyncSqlHostServersAsync([("sql01.firma.local", "SQL01")], DateTimeOffset.UtcNow);
|
|
var host = Assert.Single(await _repository.GetWindowsServersAsync());
|
|
var removed = await PostAsync(client, $"/servere/{host.ServerId}", $"/servere/{host.ServerId}/fjern", []);
|
|
Assert.Contains("SQL Server-host", Uri.UnescapeDataString(removed.Headers.Location!.OriginalString), StringComparison.Ordinal);
|
|
Assert.NotNull(await _repository.GetWindowsServerAsync(host.ServerId));
|
|
await PostAsync(client, $"/servere/{host.ServerId}", $"/servere/{host.ServerId}/pause", new() { ["paused"] = "true" });
|
|
Assert.True((await _repository.GetWindowsServerAsync(host.ServerId))!.Paused);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Admin_adds_checks_of_each_kind_and_a_sql_password_is_sealed_for_the_agent()
|
|
{
|
|
var client = Client();
|
|
var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("AOS01", "aos01", ServerRoles.Application, [], []), null, DateTimeOffset.UtcNow);
|
|
var ax = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new()
|
|
{
|
|
["name"] = "AOS01 AX-logon", ["kind"] = "AxLogon", ["serverId"] = server.ToString(System.Globalization.CultureInfo.InvariantCulture), ["serviceName"] = "AOS60$01",
|
|
["host"] = "aos01", ["company"] = "DAT", ["businessConnectorLogon"] = "true", ["intervalMinutes"] = "5", ["url"] = "https://ignoreres/",
|
|
});
|
|
var sqlCheck = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new()
|
|
{
|
|
["name"] = "SQL01 AX-login", ["kind"] = "Sql", ["server"] = @"sql01\AX", ["database"] = "AX2012", ["authentication"] = "SqlLogin", ["userName"] = "axapp",
|
|
["password"] = "Hemmelig-AX-1!", ["query"] = "SELECT COUNT(*) FROM dbo.DATAAREA", ["intervalMinutes"] = "10",
|
|
});
|
|
var http = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new()
|
|
{
|
|
["name"] = "Enterprise Portal", ["kind"] = "Http", ["url"] = "https://ep.firma.local/", ["expectedStatus"] = "200, 401", ["warnMilliseconds"] = "2000",
|
|
});
|
|
var writes = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new()
|
|
{
|
|
["name"] = "Skriver", ["kind"] = "Sql", ["server"] = "sql01", ["query"] = "UPDATE dbo.CustTable SET Name = 'x'",
|
|
});
|
|
|
|
var axId = IdFrom(ax, "/syntetiske-tjek");
|
|
var axRow = (await _repository.GetSyntheticCheckAsync(axId))!;
|
|
Assert.Equal((SyntheticCheckKind.AxLogon, (int?)server, "AOS60$01", "DAT", (Uri?)null, 300), (axRow.Kind, axRow.ServerId, axRow.Settings.ServiceName, axRow.Settings.Company,
|
|
axRow.Settings.Url, axRow.IntervalSeconds));
|
|
Assert.Equal(SyntheticCheckLimits.DefaultAxTimeoutSeconds, axRow.TimeoutSeconds);
|
|
Assert.Equal(AgentRequestStatus.Pending, (await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, axId))!.Status);
|
|
var sqlRow = (await _repository.GetSyntheticCheckAsync(IdFrom(sqlCheck, "/syntetiske-tjek")))!;
|
|
Assert.True(sqlRow.HasSecret);
|
|
Assert.Equal("Hemmelig-AX-1!", CredentialEnvelope.Open(sqlRow.SecretCipher!, "agent-test", _agentKey, sqlRow.Settings.SecretContext()));
|
|
Assert.DoesNotContain("Hemmelig-AX-1!", sqlRow.SettingsJson, StringComparison.Ordinal);
|
|
var httpRow = (await _repository.GetSyntheticCheckAsync(IdFrom(http, "/syntetiske-tjek")))!;
|
|
Assert.Equal([200, 401], httpRow.Settings.ExpectedStatus);
|
|
Assert.Contains("SELECT", Uri.UnescapeDataString(writes.Headers.Location!.OriginalString), StringComparison.Ordinal);
|
|
|
|
await _repository.RecordSyntheticCheckResultAsync(axId, new SyntheticCheckResult
|
|
{
|
|
At = DateTimeOffset.UtcNow, Success = true, DurationMs = 12, Partial = true,
|
|
Message = "AOS-porten svarer (12 ms). Kun porten er testet: .NET Business Connector (AX 2009/2012) er ikke installeret på agentens server, så et AX-logon kan ikke prøves.",
|
|
});
|
|
var page = await client.GetStringAsync($"/syntetiske-tjek/{axId}");
|
|
var list = await client.GetStringAsync("/syntetiske-tjek");
|
|
var edit = await client.GetStringAsync($"/syntetiske-tjek/{sqlRow.CheckId}");
|
|
Assert.Contains("Kun porten er testet", page, StringComparison.Ordinal);
|
|
Assert.Contains("AOS01 AX-logon", list, StringComparison.Ordinal);
|
|
Assert.Contains("aos01:2712", list, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("Hemmelig-AX-1!", edit, StringComparison.Ordinal);
|
|
Assert.Contains("tom: behold den gemte", edit, StringComparison.Ordinal);
|
|
|
|
// Moving the SQL check to another server needs the password again.
|
|
var moved = await PostAsync(client, $"/syntetiske-tjek/{sqlRow.CheckId}", $"/syntetiske-tjek/{sqlRow.CheckId}/ret", new()
|
|
{
|
|
["name"] = "SQL01 AX-login", ["kind"] = "Sql", ["server"] = "evil.example", ["authentication"] = "SqlLogin", ["userName"] = "axapp",
|
|
});
|
|
Assert.Contains("Indtast adgangskoden igen", Uri.UnescapeDataString(moved.Headers.Location!.OriginalString), StringComparison.Ordinal);
|
|
var firstRun = (await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, axId))!.RequestId;
|
|
var run = await PostAsync(client, $"/syntetiske-tjek/{axId}", $"/syntetiske-tjek/{axId}/koer", []);
|
|
Assert.Contains("koersel=", run.Headers.Location!.OriginalString, StringComparison.Ordinal);
|
|
Assert.True((await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, axId))!.RequestId > firstRun);
|
|
await PostAsync(client, $"/syntetiske-tjek/{axId}", $"/syntetiske-tjek/{axId}/fjern", []);
|
|
Assert.Null(await _repository.GetSyntheticCheckAsync(axId));
|
|
}
|
|
}
|