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); } [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); } }