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;
/// The checks against small local servers: a TCP listener, a raw HTTP(S) server with a self-signed certificate.
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 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? Environment { get; private set; }
public Task RunAsync(string fileName, IReadOnlyList arguments, IReadOnlyDictionary 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;
}
/// Answers every connection with one HTTP response, over TLS when a certificate is given.
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", "Microsoft Dynamics AX Enterprise Portal");
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);
}
/// Promises a long page, sends a little of it and hangs up.
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\nbegynd"));
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);
}
}