- An HTTP answer that breaks off while it is read (HttpIOException/IOException) is a
failed check ("Svaret blev afbrudt, mens det blev læst: …"), not an exception that
made the check rerun every tick. Other unforeseen network errors (IOException,
SocketException, HttpRequestException, AuthenticationException) become
"Forbindelsen fejlede: …"; one that hits the check's deadline is "Intet svar inden
for N sekunder". DNS: an invalid name (ArgumentException) is a failed lookup.
- The HTTP "Adresse" detail (a URL may carry query secrets) and the AX "Object server"
detail are Sensitive, so the API redacts them.
- SyntheticCheckRun's compiler-generated ToString no longer prints the SQL password.
- ProcessRunner (AX Business Connector logon): the PowerShell process tree is killed in
a finally whenever it has not exited, also when the agent stops (before: only on the
check's timeout, leaving an orphaned process and AX session). Output and errors are
capped at 64 K characters each; the rest is read and dropped so the process never
blocks on a full pipe.
- Tests: broken body, password redaction, bounded output, killed on agent stop.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
265 lines
12 KiB
C#
265 lines
12 KiB
C#
using System.Net;
|
|
using System.Net.Security;
|
|
using System.Net.Sockets;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Text;
|
|
using SQLInsight.Connectivity;
|
|
using SQLInsight.Core.SyntheticChecks;
|
|
using SQLInsight.Servers.Synthetic;
|
|
|
|
namespace SQLInsight.Servers.Tests;
|
|
|
|
/// <summary>The checks against small local servers: a TCP listener, a raw HTTP(S) server with a self-signed certificate.</summary>
|
|
public sealed class SyntheticCheckRunnerTests : IDisposable
|
|
{
|
|
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
|
private readonly CancellationTokenSource _stop = new();
|
|
|
|
public SyntheticCheckRunnerTests() => _listener.Start();
|
|
|
|
private int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
|
|
|
public void Dispose()
|
|
{
|
|
_stop.Cancel();
|
|
_listener.Stop();
|
|
_listener.Dispose();
|
|
_stop.Dispose();
|
|
}
|
|
|
|
private sealed class NoSql : ISqlConnectionFactory
|
|
{
|
|
public Task<OpenedConnection> OpenAsync(ConnectionTarget target, CancellationToken cancellationToken = default) => throw new InvalidOperationException("ingen SQL her");
|
|
}
|
|
|
|
private sealed class FakeLocator(BusinessConnectorInstallation? installation) : IBusinessConnectorLocator
|
|
{
|
|
public BusinessConnectorInstallation? Find() => installation;
|
|
}
|
|
|
|
private sealed class FakeProcesses(ProcessResult result) : IProcessRunner
|
|
{
|
|
public IReadOnlyDictionary<string, string>? Environment { get; private set; }
|
|
|
|
public Task<ProcessResult> RunAsync(string fileName, IReadOnlyList<string> arguments, IReadOnlyDictionary<string, string> environment, TimeSpan timeout,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Environment = environment;
|
|
return Task.FromResult(result);
|
|
}
|
|
}
|
|
|
|
private static SyntheticCheckRunner Runner(BusinessConnectorInstallation? bc = null, IProcessRunner? processes = null) =>
|
|
new(new NoSql(), new FakeLocator(bc), processes ?? new FakeProcesses(new ProcessResult(0, string.Empty, string.Empty, false)), TimeProvider.System);
|
|
|
|
private static int ClosedPort()
|
|
{
|
|
using var probe = new TcpListener(IPAddress.Loopback, 0);
|
|
probe.Start();
|
|
return ((IPEndPoint)probe.LocalEndpoint).Port;
|
|
}
|
|
|
|
/// <summary>Answers every connection with one HTTP response, over TLS when a certificate is given.</summary>
|
|
private void Serve(string status, string body, X509Certificate2? certificate = null) => _ = Task.Run(async () =>
|
|
{
|
|
while (!_stop.IsCancellationRequested)
|
|
{
|
|
TcpClient client;
|
|
try
|
|
{
|
|
client = await _listener.AcceptTcpClientAsync(_stop.Token);
|
|
}
|
|
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException)
|
|
{
|
|
return;
|
|
}
|
|
|
|
using (client)
|
|
{
|
|
Stream stream = client.GetStream();
|
|
try
|
|
{
|
|
if (certificate is not null)
|
|
{
|
|
var ssl = new SslStream(stream);
|
|
await ssl.AuthenticateAsServerAsync(certificate);
|
|
stream = ssl;
|
|
}
|
|
|
|
var buffer = new byte[8192];
|
|
_ = await stream.ReadAsync(buffer);
|
|
var bytes = Encoding.UTF8.GetBytes(body);
|
|
await stream.WriteAsync(Encoding.ASCII.GetBytes($"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n"));
|
|
await stream.WriteAsync(bytes);
|
|
await stream.FlushAsync();
|
|
}
|
|
catch (Exception ex) when (ex is IOException or System.Security.Authentication.AuthenticationException)
|
|
{
|
|
// The client refused the certificate.
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
private static X509Certificate2 SelfSigned(DateTimeOffset notAfter)
|
|
{
|
|
using var key = RSA.Create(2048);
|
|
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
var san = new SubjectAlternativeNameBuilder();
|
|
san.AddDnsName("localhost");
|
|
san.AddIpAddress(IPAddress.Loopback);
|
|
request.CertificateExtensions.Add(san.Build());
|
|
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), notAfter);
|
|
return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null);
|
|
}
|
|
|
|
private static SyntheticCheckRun Run(string kind, SyntheticCheckSettings settings, int timeoutSeconds = 10) => new(kind, settings, TimeSpan.FromSeconds(timeoutSeconds));
|
|
|
|
[Fact]
|
|
public async Task Tcp_open_and_refused()
|
|
{
|
|
var open = await Runner().RunAsync(Run(SyntheticCheckKind.Tcp, new() { Host = "127.0.0.1", Port = Port }), CancellationToken.None);
|
|
var closed = await Runner().RunAsync(Run(SyntheticCheckKind.Tcp, new() { Host = "127.0.0.1", Port = ClosedPort() }), CancellationToken.None);
|
|
|
|
Assert.True(open.Success, open.Message);
|
|
Assert.Contains("svarede på", open.Message, StringComparison.Ordinal);
|
|
Assert.False(closed.Success);
|
|
Assert.Contains("afvist", closed.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Http_status_and_text()
|
|
{
|
|
Serve("200 OK", "<html>Microsoft Dynamics AX Enterprise Portal</html>");
|
|
var url = new Uri($"http://127.0.0.1:{Port}/sites/DynamicsAx");
|
|
|
|
var ok = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url, ExpectedText = "enterprise portal" }), CancellationToken.None);
|
|
var missing = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url, ExpectedText = "Logget ind" }), CancellationToken.None);
|
|
var wrongStatus = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url, ExpectedStatus = [401] }), CancellationToken.None);
|
|
|
|
Assert.True(ok.Success, ok.Message);
|
|
Assert.Contains(ok.Details, d => d.Name == "HTTP-status" && d.Value.StartsWith("200", StringComparison.Ordinal));
|
|
Assert.False(missing.Success);
|
|
Assert.Contains("indeholdt ikke", missing.Message, StringComparison.Ordinal);
|
|
Assert.False(wrongStatus.Success);
|
|
Assert.Contains("forventede 401", wrongStatus.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Http_server_errors_fail()
|
|
{
|
|
Serve("503 Service Unavailable", "nede");
|
|
|
|
var result = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = new Uri($"http://127.0.0.1:{Port}/") }), CancellationToken.None);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("503", result.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Https_with_a_self_signed_certificate_fails_unless_allowed_and_the_expiry_is_seen_either_way()
|
|
{
|
|
var expires = DateTimeOffset.UtcNow.AddDays(10);
|
|
using var certificate = SelfSigned(expires);
|
|
Serve("200 OK", "ok", certificate);
|
|
var url = new Uri($"https://localhost:{Port}/");
|
|
|
|
var refused = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url }), CancellationToken.None);
|
|
var allowed = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = url, AllowInvalidCertificate = true }), CancellationToken.None);
|
|
|
|
Assert.False(refused.Success);
|
|
Assert.Contains("certifikatet er ikke gyldigt", refused.Message, StringComparison.Ordinal);
|
|
Assert.True(allowed.Success, allowed.Message);
|
|
Assert.Equal(expires.UtcDateTime, allowed.CertificateExpiresAt!.Value.UtcDateTime, TimeSpan.FromSeconds(2));
|
|
Assert.Equal(expires.UtcDateTime, refused.CertificateExpiresAt!.Value.UtcDateTime, TimeSpan.FromSeconds(2));
|
|
Assert.Equal("CN=localhost", allowed.CertificateSubject);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dns_resolves_and_compares_addresses()
|
|
{
|
|
var ok = await Runner().RunAsync(Run(SyntheticCheckKind.Dns, new() { Host = "localhost" }), CancellationToken.None);
|
|
var wrong = await Runner().RunAsync(Run(SyntheticCheckKind.Dns, new() { Host = "localhost", ExpectedAddresses = ["10.1.2.3"] }), CancellationToken.None);
|
|
var unknown = await Runner().RunAsync(Run(SyntheticCheckKind.Dns, new() { Host = "findes-ikke.invalid" }), CancellationToken.None);
|
|
|
|
Assert.True(ok.Success, ok.Message);
|
|
Assert.False(wrong.Success);
|
|
Assert.Contains("forventede 10.1.2.3", wrong.Message, StringComparison.Ordinal);
|
|
Assert.False(unknown.Success);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ax_without_business_connector_only_tests_the_port_and_says_so()
|
|
{
|
|
var result = await Runner().RunAsync(Run(SyntheticCheckKind.AxLogon, new() { Host = "127.0.0.1", Port = Port, Company = "DAT" }), CancellationToken.None);
|
|
var down = await Runner().RunAsync(Run(SyntheticCheckKind.AxLogon, new() { Host = "127.0.0.1", Port = ClosedPort() }), CancellationToken.None);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.True(result.Partial);
|
|
Assert.Contains("Kun porten er testet", result.Message, StringComparison.Ordinal);
|
|
Assert.False(down.Success);
|
|
Assert.Contains("AOS-porten svarer ikke", down.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ax_with_business_connector_logs_on_in_a_separate_process()
|
|
{
|
|
var installation = BusinessConnectorLocator.Describe(@"C:\Program Files\Microsoft Dynamics AX\60\BusinessConnector\Bin\Microsoft.Dynamics.BusinessConnectorNet.dll");
|
|
var processes = new FakeProcesses(new ProcessResult(0, "SQLINSIGHT-AX-OK 640", string.Empty, false));
|
|
var failing = new FakeProcesses(new ProcessResult(1, "SQLINSIGHT-AX-ERROR Object Server 01: The database reported an error.", string.Empty, false));
|
|
|
|
var ok = await Runner(installation, processes).RunAsync(Run(SyntheticCheckKind.AxLogon, new() { Host = "127.0.0.1", Port = Port, Company = "DAT" }), CancellationToken.None);
|
|
var error = await Runner(installation, failing).RunAsync(Run(SyntheticCheckKind.AxLogon, new() { Host = "127.0.0.1", Port = Port }), CancellationToken.None);
|
|
|
|
Assert.True(ok.Success, ok.Message);
|
|
Assert.False(ok.Partial);
|
|
Assert.Contains("640 ms", ok.Message, StringComparison.Ordinal);
|
|
Assert.Equal($"127.0.0.1:{Port}", processes.Environment!["SQLINSIGHT_AX_SERVER"]);
|
|
Assert.Equal("DAT", processes.Environment["SQLINSIGHT_AX_COMPANY"]);
|
|
Assert.False(error.Success);
|
|
Assert.Contains("The database reported an error", error.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>Promises a long page, sends a little of it and hangs up.</summary>
|
|
private void ServeBrokenBody() => _ = Task.Run(async () =>
|
|
{
|
|
using var client = await _listener.AcceptTcpClientAsync(_stop.Token);
|
|
var stream = client.GetStream();
|
|
var buffer = new byte[8192];
|
|
_ = await stream.ReadAsync(buffer);
|
|
await stream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 100000\r\n\r\n<html>begynd"));
|
|
await stream.FlushAsync();
|
|
client.Client.Shutdown(SocketShutdown.Both);
|
|
});
|
|
|
|
[Fact]
|
|
public async Task A_page_that_breaks_off_while_it_is_read_is_a_failed_check_not_an_error()
|
|
{
|
|
ServeBrokenBody();
|
|
|
|
var result = await Runner().RunAsync(Run(SyntheticCheckKind.Http, new() { Url = new Uri($"http://127.0.0.1:{Port}/"), ExpectedText = "slut" }), CancellationToken.None);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("afbrudt", result.Message, StringComparison.Ordinal);
|
|
Assert.True(Assert.Single(result.Details, d => d.Name == "Adresse").Sensitive); // a URL may carry a secret in its query
|
|
}
|
|
|
|
[Fact]
|
|
public void A_run_never_shows_its_password()
|
|
{
|
|
var run = new SyntheticCheckRun(SyntheticCheckKind.Sql, new() { Server = "sql01" }, TimeSpan.FromSeconds(5), "Hemmelig-123");
|
|
|
|
Assert.DoesNotContain("Hemmelig-123", run.ToString(), StringComparison.Ordinal);
|
|
Assert.Contains("Password = ***", run.ToString(), StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_query_that_writes_is_never_run_even_if_it_was_stored()
|
|
{
|
|
var result = await Runner().RunAsync(Run(SyntheticCheckKind.Sql, new() { Server = "sql01", Query = "DELETE FROM dbo.CustTable" }), CancellationToken.None);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("køres ikke", result.Message, StringComparison.Ordinal);
|
|
}
|
|
}
|