Review 1.5: server and check pages: one overview query, paused checks are not run, bad numbers refused, seconds kept

- /servere reads the overview in one query and says which paused servers and checks are left out of the active
  findings; admin notes use InstanceSetup.WhoMayChangeText as main.
- The check form refuses a port, interval, timeout or limit that is not a number (Danish message) and clamps
  minutes before multiplying; an interval set to part of a minute through the API is shown and kept in seconds.
- "Kør nu" is refused for a paused check (message, no button); ?koersel= shows that run.
- A finding of a removed server or check does not link to a missing page and says why it is not active.
- The forecast text uses the history the forecasts were computed from; the grant script text matches the new
  script (-WhatIf first, -WinRM, -EnableFirewall -AgentAddress, -Remove only what it added).
- HTTP addresses are at most 2000 characters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 15:52:46 +02:00
parent 20829890e9
commit 18cd879dbb
10 changed files with 220 additions and 45 deletions

View file

@ -162,6 +162,9 @@ public static class SyntheticCheckLimits
public static class SyntheticCheckValidation
{
/// <summary>The longest HTTP address a check may have.</summary>
public const int MaxUrlLength = 2000;
/// <summary>Why the check cannot be saved (Danish), or null. <paramref name="hasServer"/>: the check is tied to a Windows server.</summary>
public static string? Validate(string? name, string? kind, SyntheticCheckSettings settings, int intervalSeconds, int timeoutSeconds, bool hasServer)
{
@ -245,6 +248,11 @@ public static class SyntheticCheckValidation
return "Angiv en adresse, der starter med http:// eller https://.";
}
if (url.AbsoluteUri.Length > MaxUrlLength || url.OriginalString.Length > MaxUrlLength)
{
return $"Adressen må højst være {MaxUrlLength} tegn.";
}
if (!string.IsNullOrEmpty(url.UserInfo))
{
return "Adressen må ikke indeholde brugernavn eller adgangskode.";

View file

@ -14,14 +14,30 @@
<p class="crumbs">
@if (_finding.Scope == FindingScope.Server)
{
<a href="@ServerSetup.ServersPage">Windows-servere</a> <text>/</text> <a href="@ServerSetup.ServerPage(_finding.ScopeId)">@_finding.ScopeName</a>
<a href="@ServerSetup.ServersPage">Windows-servere</a> <text>/</text>
}
else
{
<a href="@ServerSetup.ChecksPage">Syntetiske tjek</a> <text>/</text> <a href="@ServerSetup.CheckPage(_finding.ScopeId)">@_finding.ScopeName</a>
<a href="@ServerSetup.ChecksPage">Syntetiske tjek</a> <text>/</text>
}
@if (_finding.ScopeRemoved)
{
<span>@_finding.ScopeName</span>
}
else
{
<a href="@(_finding.Scope == FindingScope.Server ? ServerSetup.ServerPage(_finding.ScopeId) : ServerSetup.CheckPage(_finding.ScopeId))">@_finding.ScopeName</a>
}
/ @_finding.RuleId
</p>
@if (_finding.ResolvedAt is null && _finding.ScopeRemoved)
{
<p class="notice" role="note">@(_finding.Scope == FindingScope.Server ? "Serveren" : "Tjekket") er fjernet og overvåges ikke længere. Fundet er historik og løses inden for en time.</p>
}
else if (_finding.ResolvedAt is null && _finding.ScopePaused)
{
<p class="notice" role="note">@(_finding.Scope == FindingScope.Server ? "Serveren" : "Tjekket") er sat på pause, så fundet er ikke med blandt de aktive fund og løses inden for en time.</p>
}
<h1 data-finding-id="@_finding.FindingId"><SeverityBadge Severity="_finding.Severity" /> @_finding.Title</h1>
<p class="lead">
@if (_finding.ResolvedAt is { } resolved)

View file

@ -25,7 +25,7 @@
<h1>@_check.Name</h1>
<p class="lead">
@SyntheticCheckKind.Display(_check.Kind) · <span class="mono">@_settings.Target(_check.Kind)</span> · <CheckStatus Check="_check" />
· hvert @(_check.IntervalSeconds / 60). minut, timeout @_check.TimeoutSeconds s
· @ServerSetup.IntervalText(_check.IntervalSeconds), timeout @_check.TimeoutSeconds s
@if (_check.ServerId is { } serverId)
{
<text> · </text><a href="@ServerSetup.ServerPage(serverId)">@_check.ServerName</a>@(_settings.ServiceName is { } svc ? " (" + svc + ")" : string.Empty)
@ -92,7 +92,11 @@
}
</table>
}
@if (_mayChange)
@if (_mayChange && _check.Paused)
{
<p class="muted">Tjekket er sat på pause og køres ikke, heller ikke på bestilling. Genoptag det under Indstillinger.</p>
}
else if (_mayChange)
{
<form method="post" action="/syntetiske-tjek/@Id/koer"><AntiforgeryToken /><button type="submit" class="button">Kør nu</button></form>
}
@ -188,7 +192,10 @@
.Where(r => r.Success).OrderBy(r => r.RunAt).Select(r => new MetricPoint(r.RunAt, r.DurationMs)).ToList();
_findings = await Repository.GetServerFindingsAsync(FindingScope.Check, Id);
_servers = await Repository.GetWindowsServersAsync();
_run = Koersel is not null || Tilfoejet == 1 ? await Repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, Id) : null;
// The run asked for (?koersel=), if it is about this check; after adding, the first run.
_run = Koersel is { } run
? await Repository.GetAgentRequestAsync(run) is { Kind: ServerRequestKind.SyntheticCheckRun } request && request.TargetId == Id ? request : null
: Tilfoejet == 1 ? await Repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, Id) : null;
_mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value);
}
}

View file

@ -19,7 +19,7 @@
}
@if (!_mayChange)
{
<p class="notice" role="note">Du kan se tjekkene, men kun administratorer kan tilføje og ændre dem (<code>SQLInsight:Web:AdminGroups</code>).</p>
<p class="notice" role="note">Du kan se tjekkene, men kun administratorer kan tilføje og ændre dem. @InstanceSetup.WhoMayChangeText(Options.Value)</p>
}
<section class="card">

View file

@ -127,7 +127,7 @@
}
</tbody>
</table>
<p class="muted">Prognosen er en ret linje gennem SQLInsights egne målinger af ledig plads de seneste @(_forecastDays) dage, siden drevet sidst skiftede størrelse. Svinger pladsen meget, gives ingen prognose. Målt @Display.Time(_disks.CapturedAt.UtcDateTime).</p>
<p class="muted">Prognosen er en ret linje gennem SQLInsights egne målinger af ledig plads @ForecastPeriod, siden drevet sidst skiftede størrelse. Svinger pladsen meget, gives ingen prognose. Målt @Display.Time(_disks.CapturedAt.UtcDateTime).</p>
}
</section>
@ -334,7 +334,12 @@
}
<h3>Script, der giver rettighederne</h3>
<p>Kør det som administrator på <span class="mono">@_server.Host</span>. Det giver agentens konto læseadgang (Performance Monitor Users, Event Log Readers, Distributed COM Users, Remote Management Users, WMI Remote Enable på root\cimv2 og TaskScheduler, læseret på Service Control Manager) og intet andet. SQLInsight kører det aldrig selv. <code>-Remove</code> fjerner rettighederne igen, og <code>-EnableFirewall</code> slår Windows' egne firewallregler til.</p>
<p>Kør det som administrator på <span class="mono">@_server.Host</span> – først med <code>-WhatIf</code>, der viser, hvad det vil gøre, uden at ændre noget, og gerne først på en server, der ikke er i produktion. Det giver agentens konto læseadgang (Performance Monitor Users, Event Log Readers, Distributed COM Users, WMI Enable Account og Remote Enable på root\cimv2 og TaskScheduler, læseret på Service Control Manager) og intet andet, og det gemmer de oprindelige indstillinger, før det ændrer noget. SQLInsight kører det aldrig selv.</p>
<ul class="small">
<li><code>-WinRM</code> giver også kontoen læseadgang til WinRM, så WMI kan læses over WinRM; uden den bruger agenten DCOM.</li>
<li><code>-EnableFirewall -AgentAddress &lt;ip&gt;</code> opretter scriptets egne firewallregler ("SQLInsight Agent: …") kun for agentens adresse. Windows' egne regler røres ikke.</li>
<li><code>-Remove</code> fjerner præcis det, scriptet selv har tilføjet. Rettigheder, kontoen havde i forvejen, bevares.</li>
</ul>
@if (_grant.Script is { } script)
{
<p>Konto: <span class="mono">@_grant.Account</span> · <a href="/servere/@Id/grant-windows-server.ps1">Hent som fil</a></p>
@ -423,7 +428,15 @@
private WindowsServerTestResult? _testResult;
private (string? Account, string? Script, string? Note) _grant;
private bool _mayChange;
private int _forecastDays = 14;
/// <summary>
/// The history the forecasts are computed from, as the agent used it (SQLInsight:Servers:Disks:ForecastHistoryDays,
/// capped by what has been collected): the longest history among the volumes.
/// </summary>
private string ForecastPeriod =>
_disks?.Forecasts.Count > 0 && _disks.Forecasts.Max(f => f.HistoryDays) is var days and >= 1
? "de seneste " + Display.Number(Math.Round(days), 0) + " dage"
: "de seneste dage";
protected override async Task OnInitializedAsync()
{

View file

@ -20,7 +20,7 @@
}
@if (!_mayChange)
{
<p class="notice" role="note">Du kan se serverne, men kun administratorer kan tilføje og ændre dem (<code>SQLInsight:Web:AdminGroups</code>).</p>
<p class="notice" role="note">Du kan se serverne, men kun administratorer kan tilføje og ændre dem. @InstanceSetup.WhoMayChangeText(Options.Value)</p>
}
<section class="card">
@ -36,9 +36,9 @@
<tbody>
@foreach (var s in _servers)
{
var disks = _disks.GetValueOrDefault(s.ServerId);
var overview = _overviews.GetValueOrDefault(s.ServerId);
var counts = _counts.GetValueOrDefault((FindingScope.Server, s.ServerId));
var latest = _latest.GetValueOrDefault(s.ServerId);
var latest = overview?.Latest;
<tr>
<td><a href="@ServerSetup.ServerPage(s.ServerId)">@s.Name</a><div class="muted mono small">@s.Host</div></td>
<td>@ServerRoles.Display(s.Role)@(s.IsSqlHost ? " · fra SQL Server-instanser" : string.Empty)</td>
@ -46,14 +46,14 @@
<td class="num">@(latest?.CpuPercent is { } cpu ? Display.Number(cpu) + " %" : "–")</td>
<td class="num">@(latest?.AvailableMb is { } mb ? Display.Number(mb / 1024, 1) + " GB" : "–")</td>
<td>
@if (disks is null)
@if (overview?.Disks is null)
{
<span class="muted">–</span>
}
else
{
var worst = disks.Disks.OrderBy(d => d.FreePercent).FirstOrDefault();
var soonest = disks.Forecasts.Where(f => f.DaysUntilFull is not null).OrderBy(f => f.DaysUntilFull).FirstOrDefault();
var worst = overview.WorstDisk;
var soonest = overview.SoonestFull;
@if (worst is not null)
{
<span>@worst.Volume @Display.Number(worst.FreePercent) % ledig</span>
@ -86,6 +86,10 @@
<section class="panel">
<h2>Aktive fund på servere og syntetiske tjek</h2>
@if (_paused.Count > 0)
{
<p class="muted">Servere og tjek sat på pause er ikke med: @string.Join(", ", _paused). Deres seneste fund står på deres egen side.</p>
}
<ServerFindingList Findings="_findings" ShowScope="true" Empty="Ingen aktive fund." />
</section>
@ -122,28 +126,19 @@
private IReadOnlyList<WindowsServerRow> _servers = [];
private IReadOnlyList<StoredServerFinding> _findings = [];
private IReadOnlyDictionary<(string, int), IReadOnlyDictionary<Severity, int>> _counts = new Dictionary<(string, int), IReadOnlyDictionary<Severity, int>>();
private readonly Dictionary<int, ServerDiskSnapshot> _disks = [];
private readonly Dictionary<int, StoredServerPerformance> _latest = [];
private IReadOnlyDictionary<int, WindowsServerOverview> _overviews = new Dictionary<int, WindowsServerOverview>();
private IReadOnlyList<string> _paused = [];
private bool _mayChange;
protected override async Task OnInitializedAsync()
{
// Paused (and removed) servers and checks are not looked at now: their findings are not in the active list.
_servers = await Repository.GetWindowsServersAsync();
_findings = await Repository.GetServerFindingsAsync();
_counts = await Repository.GetServerFindingCountsAsync();
foreach (var s in _servers.Where(s => !s.Paused))
{
if (await Repository.GetServerSnapshotAsync<ServerDiskSnapshot>(s.ServerId, ServerArea.Disks) is { } disks)
{
_disks[s.ServerId] = disks;
}
if ((await Repository.GetServerPerformanceAsync(s.ServerId, DateTimeOffset.UtcNow.AddMinutes(-10))).LastOrDefault() is { } latest)
{
_latest[s.ServerId] = latest;
}
}
_overviews = await Repository.GetWindowsServerOverviewsAsync(DateTimeOffset.UtcNow.AddMinutes(-10));
_paused = _servers.Where(s => s.Paused).Select(s => s.Name)
.Concat((await Repository.GetSyntheticChecksAsync()).Where(c => c.Paused).Select(c => c.Name)).ToList();
_mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value);
}
}

View file

@ -21,7 +21,15 @@
<input type="hidden" name="kind" value="@Check.Kind" />
<label>Type <input value="@SyntheticCheckKind.Display(Check.Kind)" disabled /></label>
}
<label>Interval (minutter) <input name="intervalMinutes" inputmode="numeric" pattern="[0-9]*" maxlength="4" value="@((Check?.IntervalSeconds ?? SyntheticCheckLimits.DefaultIntervalSeconds) / 60)" /></label>
@if (Check is { IntervalSeconds: var seconds } && seconds % 60 != 0)
{
@* Set to a part of a minute through the API: kept in seconds, not rounded on the next save. *@
<label>Interval (sekunder) <input name="intervalSeconds" inputmode="numeric" pattern="[0-9]*" maxlength="5" value="@seconds" /></label>
}
else
{
<label>Interval (minutter) <input name="intervalMinutes" inputmode="numeric" pattern="[0-9]*" maxlength="4" value="@((Check?.IntervalSeconds ?? SyntheticCheckLimits.DefaultIntervalSeconds) / 60)" /></label>
}
<label>Timeout (sekunder, tom: standard) <input name="timeoutSeconds" inputmode="numeric" pattern="[0-9]*" maxlength="3" value="@Check?.TimeoutSeconds" /></label>
</div>
<div class="support-contact">

View file

@ -43,8 +43,28 @@ public static class ServerSetupEndpoints
return InstanceSetup.MayChange(http.User, options) ? null : Results.Redirect(WithMessage(back, "fejl", ServerSetup.NotAdminText));
}
private static int? Int(IFormCollection form, string name) =>
int.TryParse(form[name].ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
/// <summary>A whole number from the form: null when the field is empty, <c>Bad</c> when it is not a number.</summary>
private static (int? Value, bool Bad) Number(IFormCollection form, string name)
{
var text = form[name].ToString().Trim();
if (text.Length == 0)
{
return (null, false);
}
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? (value, false) : (null, true);
}
/// <summary>The check form's number fields and what to say when one is not a number (as for the instances' port).</summary>
private static readonly (string Field, string Message)[] NumberFields =
[
("intervalMinutes", "Intervallet skal være et helt antal minutter."),
("intervalSeconds", "Intervallet skal være et helt antal sekunder."),
("timeoutSeconds", "Timeout skal være et helt antal sekunder."),
("serverId", "Vælg Windows-serveren på listen."),
("port", "Porten skal være et tal."),
("warnMilliseconds", "Grænsen for langsomt svar skal være et tal (millisekunder)."),
];
private static ServerInput ServerInput(IFormCollection form) => new(
form["name"].ToString(),
@ -135,18 +155,32 @@ public static class ServerSetupEndpoints
return Results.File(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(script)).ToArray(), "text/plain; charset=utf-8", "grant-windows-server.ps1");
}
/// <summary>The check form: every kind's fields are there; only the chosen kind's are kept (<see cref="ServerSetup.Normalize"/>).</summary>
private static CheckInput CheckInput(IFormCollection form)
/// <summary>
/// The check form: every kind's fields are there; only the chosen kind's are kept (<see cref="ServerSetup.Normalize"/>).
/// A number field that is not a number is refused rather than ignored. The interval is in minutes, or in seconds when
/// it was set to a part of a minute through the API (the form then keeps it in seconds).
/// </summary>
private static (CheckInput? Input, string? Error) CheckInput(IFormCollection form)
{
var minutes = Int(form, "intervalMinutes");
return new CheckInput(
foreach (var (field, message) in NumberFields)
{
if (Number(form, field).Bad)
{
return (null, message);
}
}
int? Int(string name) => Number(form, name).Value;
var interval = Int("intervalSeconds")
?? (Int("intervalMinutes") is { } minutes ? Math.Clamp(minutes, 0, (SyntheticCheckLimits.MaxIntervalSeconds / 60) + 1) * 60 : null);
return (new CheckInput(
form["name"].ToString(),
form["kind"].ToString() is { Length: > 0 } kind ? kind : null,
Int(form, "serverId") is > 0 and var server ? server : null,
Int("serverId") is > 0 and var server ? server : null,
new SyntheticCheckSettings
{
Host = form["host"].ToString(),
Port = Int(form, "port"),
Port = Int("port"),
Url = ServerSetup.ParseUrl(form["url"].ToString()),
ExpectedStatus = ServerSetup.ParseStatusCodes(form["expectedStatus"].ToString()),
ExpectedText = form["expectedText"].ToString(),
@ -165,11 +199,11 @@ public static class ServerSetupEndpoints
Configuration = form["configuration"].ToString(),
BusinessConnectorLogon = form["businessConnectorLogon"] == "true",
ServiceName = form["serviceName"].ToString(),
WarnMilliseconds = Int(form, "warnMilliseconds"),
WarnMilliseconds = Int("warnMilliseconds"),
},
form["password"].ToString(),
minutes is { } m ? m * 60 : null,
Int(form, "timeoutSeconds"));
interval,
Int("timeoutSeconds")), null);
}
private static async Task<IResult> AddCheckAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions<WebOptions> options)
@ -180,7 +214,10 @@ public static class ServerSetupEndpoints
}
var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
var outcome = await ServerSetup.AddCheckAsync(repository, CheckInput(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false);
var (input, invalid) = CheckInput(form);
var outcome = input is null
? new ServerSetup.Outcome(Error: invalid)
: await ServerSetup.AddCheckAsync(repository, input, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false);
return outcome.Error is { } error
? Results.Redirect(WithMessage(ServerSetup.ChecksPage, "fejl", error) + "#tilfoej")
: Results.Redirect(ServerSetup.CheckPage(outcome.Id!.Value) + "?tilfoejet=1");
@ -194,7 +231,10 @@ public static class ServerSetupEndpoints
}
var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false);
var outcome = await ServerSetup.UpdateCheckAsync(repository, id, CheckInput(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false);
var (input, invalid) = CheckInput(form);
var outcome = input is null
? new ServerSetup.Outcome(id, Error: invalid)
: await ServerSetup.UpdateCheckAsync(repository, id, input, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false);
return outcome.NotFound ? Results.NotFound()
: Results.Redirect(outcome.Error is { } error ? WithMessage(ServerSetup.CheckPage(id), "fejl", error) + "#ret" : ServerSetup.CheckPage(id) + "?gemt=1");
}
@ -233,6 +273,11 @@ public static class ServerSetupEndpoints
}
var outcome = await ServerSetup.RequestCheckRunAsync(repository, id, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false);
return outcome.NotFound ? Results.NotFound() : Results.Redirect(ServerSetup.CheckPage(id) + "?koersel=" + outcome.RequestId!.Value.ToString(CultureInfo.InvariantCulture));
return outcome switch
{
{ NotFound: true } => Results.NotFound(),
{ Error: { } error } => Results.Redirect(WithMessage(ServerSetup.CheckPage(id), "fejl", error)),
_ => Results.Redirect(ServerSetup.CheckPage(id) + "?koersel=" + outcome.RequestId!.Value.ToString(CultureInfo.InvariantCulture)),
};
}
}

View file

@ -49,6 +49,14 @@ public class SyntheticCheckTests
Assert.NotNull(SyntheticCheckValidation.Validate(" ", SyntheticCheckKind.Tcp, new() { Host = "a", Port = 1 }, 300, 15, false));
}
[Fact]
public void An_http_address_has_a_length_limit()
{
Assert.Contains("2000 tegn", Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep.firma.local/" + new string('a', SyntheticCheckValidation.MaxUrlLength)) }),
StringComparison.Ordinal);
Assert.Null(Validate(SyntheticCheckKind.Http, new() { Url = new Uri("https://ep.firma.local/" + new string('a', 1900)) }));
}
[Fact]
public void Settings_round_trip_as_json_and_bad_json_is_empty_settings()
{

View file

@ -140,6 +140,10 @@ public sealed partial class ServerWebTests(SqlServerFixture sql) : IAsyncLifetim
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]));
// The test asked for when the server was added still waits for the agent: it is reused, not queued again.
await PostAsync(client, $"/servere/{id}", $"/servere/{id}/test", []);
Assert.Equal(1, (await _repository.GetLatestServerRequestAsync(ServerRequestKind.ServerTest, id))!.RequestId);
await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow);
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", []);
@ -220,11 +224,82 @@ public sealed partial class ServerWebTests(SqlServerFixture sql) : IAsyncLifetim
["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);
// "Kør nu" while the first run still waits for the agent shows that run; once taken, a new one is queued.
var firstRun = (await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, axId))!.RequestId;
var again = await PostAsync(client, $"/syntetiske-tjek/{axId}", $"/syntetiske-tjek/{axId}/koer", []);
Assert.EndsWith("koersel=" + firstRun.ToString(System.Globalization.CultureInfo.InvariantCulture), again.Headers.Location!.OriginalString, StringComparison.Ordinal);
while (await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow) is not null)
{
}
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));
}
[Fact]
public async Task Bad_numbers_are_refused_a_paused_check_is_not_run_and_seconds_are_kept()
{
var client = Client();
var port = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej", new() { ["name"] = "Port", ["kind"] = "Tcp", ["host"] = "aos01", ["port"] = "27x12" });
var interval = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej",
new() { ["name"] = "Port", ["kind"] = "Tcp", ["host"] = "aos01", ["port"] = "2712", ["intervalMinutes"] = "fem" });
var huge = await PostAsync(client, "/syntetiske-tjek", "/syntetiske-tjek/tilfoej",
new() { ["name"] = "Port", ["kind"] = "Tcp", ["host"] = "aos01", ["port"] = "2712", ["intervalMinutes"] = "2000000000" });
Assert.Contains("Porten skal være et tal.", Uri.UnescapeDataString(port.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Contains("helt antal minutter", Uri.UnescapeDataString(interval.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Contains("mellem 1 minut og 24 timer", Uri.UnescapeDataString(huge.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Empty(await _repository.GetSyntheticChecksAsync());
// Set to 90 seconds through the API: the form keeps the seconds, the page says so.
var id = await _repository.AddSyntheticCheckAsync(new SyntheticCheckInput("AOS01 port", SyntheticCheckKind.Tcp, null,
new SyntheticCheckSettings { Host = "aos01", Port = 2712 }, null, null, 90, 15), null, DateTimeOffset.UtcNow);
var page = await client.GetStringAsync($"/syntetiske-tjek/{id}");
Assert.Contains("hvert 90. sekund", page, StringComparison.Ordinal);
Assert.Contains("name=\"intervalSeconds\"", page, StringComparison.Ordinal);
await PostAsync(client, $"/syntetiske-tjek/{id}", $"/syntetiske-tjek/{id}/ret",
new() { ["name"] = "AOS01 port", ["kind"] = "Tcp", ["host"] = "aos01", ["port"] = "2712", ["intervalSeconds"] = "90" });
Assert.Equal(90, (await _repository.GetSyntheticCheckAsync(id))!.IntervalSeconds);
await _repository.SetSyntheticCheckPausedAsync(id, true, null, DateTimeOffset.UtcNow);
var paused = await PostAsync(client, $"/syntetiske-tjek/{id}", $"/syntetiske-tjek/{id}/koer", []);
Assert.Contains("pause", Uri.UnescapeDataString(paused.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Null(await _repository.GetLatestServerRequestAsync(ServerRequestKind.SyntheticCheckRun, id));
Assert.DoesNotContain("Kør nu</button>", await client.GetStringAsync($"/syntetiske-tjek/{id}"), StringComparison.Ordinal);
}
[Fact]
public async Task Paused_servers_findings_are_not_on_the_overview_and_a_removed_servers_checks_stay()
{
var client = Client();
var now = DateTimeOffset.UtcNow;
var server = await _repository.AddWindowsServerAsync(new NewWindowsServer("FIL01", "fil01", ServerRoles.File, [], []), null, now);
var check = await _repository.AddSyntheticCheckAsync(new SyntheticCheckInput("FIL01 SMB", SyntheticCheckKind.Tcp, server,
new SyntheticCheckSettings { Host = "fil01", Port = 445, ServiceName = "LanmanServer" }, null, null, 300, 15), null, now);
await _repository.SaveScopedRuleResultsAsync(FindingScope.Server, server,
[
new RuleResult
{
RuleId = "SQLI-WIN-005", RuleTitle = "Disk", Outcome = RuleOutcome.Findings,
Findings = [new Finding { RuleId = "SQLI-WIN-005", Severity = Severity.High, Title = "Disk E: is nearly full", Explanation = "e", Recommendation = "r", Subject = "disk: E:" }],
},
], now);
Assert.Contains("Disk E: is nearly full", await client.GetStringAsync("/servere"), StringComparison.Ordinal);
await PostAsync(client, $"/servere/{server}", $"/servere/{server}/pause", new() { ["paused"] = "true" });
var list = await client.GetStringAsync("/servere");
Assert.DoesNotContain("Disk E: is nearly full", list, StringComparison.Ordinal);
Assert.Contains("Servere og tjek sat på pause er ikke med: FIL01", list, StringComparison.Ordinal);
Assert.Contains("Disk E: is nearly full", await client.GetStringAsync($"/servere/{server}"), StringComparison.Ordinal);
await PostAsync(client, $"/servere/{server}", $"/servere/{server}/fjern", []);
var row = (await _repository.GetSyntheticCheckAsync(check))!;
Assert.Equal((null, null), (row.ServerId, row.Settings.ServiceName));
var saved = await PostAsync(client, $"/syntetiske-tjek/{check}", $"/syntetiske-tjek/{check}/ret",
new() { ["name"] = "FIL01 SMB", ["kind"] = "Tcp", ["host"] = "fil01", ["port"] = "445", ["intervalMinutes"] = "5" });
Assert.Contains("gemt=1", saved.Headers.Location!.OriginalString, StringComparison.Ordinal);
}
}