Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="xunit.runner.visualstudio" >
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Transport.Tls\StellaOps.Router.Transport.Tls.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="FluentAssertions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,515 @@
using System.Net;
using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using FluentAssertions;
using StellaOps.Router.Common.Enums;
using StellaOps.Router.Common.Frames;
using StellaOps.Router.Common.Models;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Router.Transport.Tls.Tests;
/// <summary>
/// Transport compliance tests for TLS transport.
/// Tests: roundtrip over TLS, certificate validation, protocol handling.
/// </summary>
public sealed class TlsTransportComplianceTests
{
#region TLS Options Compliance Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TlsOptions_DefaultProtocols_SecureDefaults()
{
// Arrange & Act
var options = new TlsTransportOptions();
// Assert - Should default to TLS 1.2 and 1.3 (no legacy protocols)
options.EnabledProtocols.Should().Be(SslProtocols.Tls12 | SslProtocols.Tls13);
// Should NOT include legacy protocols
#pragma warning disable SYSLIB0039, CS0618 // Intentionally testing obsolete protocols are disabled
options.EnabledProtocols.HasFlag(SslProtocols.Tls).Should().BeFalse();
options.EnabledProtocols.HasFlag(SslProtocols.Tls11).Should().BeFalse();
options.EnabledProtocols.HasFlag(SslProtocols.Ssl2).Should().BeFalse();
options.EnabledProtocols.HasFlag(SslProtocols.Ssl3).Should().BeFalse();
#pragma warning restore SYSLIB0039, CS0618
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TlsOptions_RequireClientCertificate_DefaultFalse()
{
// Arrange & Act
var options = new TlsTransportOptions();
// Assert
options.RequireClientCertificate.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TlsOptions_AllowSelfSigned_DefaultFalse()
{
// Arrange & Act
var options = new TlsTransportOptions();
// Assert
options.AllowSelfSigned.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TlsOptions_CheckCertificateRevocation_DefaultFalse()
{
// Arrange & Act
var options = new TlsTransportOptions();
// Assert
options.CheckCertificateRevocation.Should().BeFalse();
}
#endregion
#region Certificate Loading Compliance Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CertificateLoading_DirectCertificate_Preferred()
{
// Arrange
var cert = CreateTestCertificate("direct-cert");
var options = new TlsTransportOptions
{
ServerCertificate = cert,
ServerCertificatePath = "/should/be/ignored"
};
// Act
var loaded = CertificateLoader.LoadServerCertificate(options);
// Assert - Direct certificate should be used
loaded.Should().BeSameAs(cert);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CertificateLoading_NoCertificate_ThrowsForServer()
{
// Arrange
var options = new TlsTransportOptions();
// Act & Assert
var action = () => CertificateLoader.LoadServerCertificate(options);
action.Should().Throw<InvalidOperationException>();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CertificateLoading_NoCertificate_ReturnsNullForClient()
{
// Arrange
var options = new TlsTransportOptions();
// Act
var result = CertificateLoader.LoadClientCertificate(options);
// Assert
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CertificateLoading_ClientCertificate_LoadsSuccessfully()
{
// Arrange
var cert = CreateTestCertificate("client-cert");
var options = new TlsTransportOptions
{
ClientCertificate = cert
};
// Act
var loaded = CertificateLoader.LoadClientCertificate(options);
// Assert
loaded.Should().BeSameAs(cert);
}
#endregion
#region Protocol Negotiation Tests
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(SslProtocols.Tls12)]
[InlineData(SslProtocols.Tls13)]
[InlineData(SslProtocols.Tls12 | SslProtocols.Tls13)]
public void ProtocolNegotiation_SupportedProtocols_Configurable(SslProtocols protocols)
{
// Arrange & Act
var options = new TlsTransportOptions
{
EnabledProtocols = protocols
};
// Assert
options.EnabledProtocols.Should().Be(protocols);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ProtocolNegotiation_Tls12Only_Configurable()
{
// Arrange & Act
var options = new TlsTransportOptions
{
EnabledProtocols = SslProtocols.Tls12
};
// Assert
options.EnabledProtocols.Should().Be(SslProtocols.Tls12);
options.EnabledProtocols.HasFlag(SslProtocols.Tls13).Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ProtocolNegotiation_Tls13Only_Configurable()
{
// Arrange & Act
var options = new TlsTransportOptions
{
EnabledProtocols = SslProtocols.Tls13
};
// Assert
options.EnabledProtocols.Should().Be(SslProtocols.Tls13);
options.EnabledProtocols.HasFlag(SslProtocols.Tls12).Should().BeFalse();
}
#endregion
#region Frame Roundtrip Tests (via TcpFrameProtocol shared)
// TLS uses the same frame protocol as TCP after the TLS handshake
// These tests verify frames are correctly serialized before TLS encryption
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FrameRoundtrip_RequestFrame_PreTlsEncryption()
{
// Arrange - Test frame serialization (TLS encrypts the result)
using var stream = new MemoryStream();
var request = new RequestFrame
{
RequestId = "tls-req-12345",
CorrelationId = "tls-corr-67890",
Method = "POST",
Path = "/api/secure",
Headers = new Dictionary<string, string>
{
["Content-Type"] = "application/json",
["Authorization"] = "Bearer secure-token"
},
Payload = Encoding.UTF8.GetBytes(@"{""sensitive"":""data""}"),
TimeoutSeconds = 30,
SupportsStreaming = false
};
var frame = FrameConverter.ToFrame(request);
// Act - Simulate frame protocol write (what gets encrypted by TLS)
await TcpFrameProtocolWrapper.WriteFrameAsync(stream, frame, CancellationToken.None);
stream.Position = 0;
var readFrame = await TcpFrameProtocolWrapper.ReadFrameAsync(stream, 16 * 1024 * 1024, CancellationToken.None);
var restored = FrameConverter.ToRequestFrame(readFrame!);
// Assert
restored.Should().NotBeNull();
restored!.RequestId.Should().Be(request.RequestId);
restored.Method.Should().Be(request.Method);
restored.Path.Should().Be(request.Path);
restored.Headers.Should().BeEquivalentTo(request.Headers);
Encoding.UTF8.GetString(restored.Payload.Span).Should().Be(@"{""sensitive"":""data""}");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FrameRoundtrip_BinaryPayload_NotCorrupted()
{
// Arrange - Binary data should survive serialization before TLS encryption
using var stream = new MemoryStream();
var binaryPayload = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray();
var frame = new Frame
{
Type = FrameType.Request,
CorrelationId = "binary-tls",
Payload = binaryPayload
};
// Act
await TcpFrameProtocolWrapper.WriteFrameAsync(stream, frame, CancellationToken.None);
stream.Position = 0;
var readFrame = await TcpFrameProtocolWrapper.ReadFrameAsync(stream, 16 * 1024 * 1024, CancellationToken.None);
// Assert - All bytes preserved
readFrame!.Payload.ToArray().Should().BeEquivalentTo(binaryPayload);
}
#endregion
#region Hostname Verification Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HostnameVerification_ExpectedHostname_Configurable()
{
// Arrange & Act
var options = new TlsTransportOptions
{
ExpectedServerHostname = "api.stellaops.io"
};
// Assert
options.ExpectedServerHostname.Should().Be("api.stellaops.io");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HostnameVerification_NotSet_UsesHost()
{
// Arrange & Act
var options = new TlsTransportOptions
{
Host = "gateway.local",
ExpectedServerHostname = null
};
// Assert
options.ExpectedServerHostname.Should().BeNull();
options.Host.Should().Be("gateway.local");
}
#endregion
#region Certificate Path Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CertificatePath_Server_Configurable()
{
// Arrange & Act
var options = new TlsTransportOptions
{
ServerCertificatePath = "/etc/stellaops/certs/server.pfx",
ServerCertificatePassword = "secure-password"
};
// Assert
options.ServerCertificatePath.Should().Be("/etc/stellaops/certs/server.pfx");
options.ServerCertificatePassword.Should().Be("secure-password");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CertificatePath_Client_Configurable()
{
// Arrange & Act
var options = new TlsTransportOptions
{
ClientCertificatePath = "/etc/stellaops/certs/client.pfx",
ClientCertificatePassword = "client-password"
};
// Assert
options.ClientCertificatePath.Should().Be("/etc/stellaops/certs/client.pfx");
options.ClientCertificatePassword.Should().Be("client-password");
}
#endregion
#region Timeout and Buffer Configuration Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Timeouts_DefaultValues_Reasonable()
{
// Arrange & Act
var options = new TlsTransportOptions();
// Assert - Sensible defaults for secure connections
options.ConnectTimeout.Should().Be(TimeSpan.FromSeconds(10));
options.KeepAliveInterval.Should().Be(TimeSpan.FromSeconds(30));
options.MaxReconnectAttempts.Should().Be(10);
options.MaxReconnectBackoff.Should().Be(TimeSpan.FromMinutes(1));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Buffers_DefaultValues_Reasonable()
{
// Arrange & Act
var options = new TlsTransportOptions();
// Assert
options.ReceiveBufferSize.Should().Be(64 * 1024); // 64KB
options.SendBufferSize.Should().Be(64 * 1024); // 64KB
options.MaxFrameSize.Should().Be(16 * 1024 * 1024); // 16MB
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(8 * 1024)]
[InlineData(64 * 1024)]
[InlineData(256 * 1024)]
public void Buffers_Customizable(int bufferSize)
{
// Arrange & Act
var options = new TlsTransportOptions
{
ReceiveBufferSize = bufferSize,
SendBufferSize = bufferSize
};
// Assert
options.ReceiveBufferSize.Should().Be(bufferSize);
options.SendBufferSize.Should().Be(bufferSize);
}
#endregion
#region mTLS Configuration Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void MutualTls_ClientCertRequired_Configurable()
{
// Arrange & Act
var options = new TlsTransportOptions
{
RequireClientCertificate = true
};
// Assert
options.RequireClientCertificate.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void MutualTls_FullConfiguration_AllOptionsCombine()
{
// Arrange
var serverCert = CreateTestCertificate("server");
var clientCert = CreateTestCertificate("client");
// Act
var options = new TlsTransportOptions
{
ServerCertificate = serverCert,
ClientCertificate = clientCert,
RequireClientCertificate = true,
CheckCertificateRevocation = true,
AllowSelfSigned = false,
EnabledProtocols = SslProtocols.Tls13,
ExpectedServerHostname = "mtls.stellaops.io"
};
// Assert
options.ServerCertificate.Should().BeSameAs(serverCert);
options.ClientCertificate.Should().BeSameAs(clientCert);
options.RequireClientCertificate.Should().BeTrue();
options.CheckCertificateRevocation.Should().BeTrue();
options.AllowSelfSigned.Should().BeFalse();
options.EnabledProtocols.Should().Be(SslProtocols.Tls13);
options.ExpectedServerHostname.Should().Be("mtls.stellaops.io");
}
#endregion
#region Determinism Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Determinism_SameInput_SameOutput()
{
// Arrange - Frame serialization should be deterministic
for (int run = 0; run < 10; run++)
{
using var stream = new MemoryStream();
var request = new RequestFrame
{
RequestId = "tls-deterministic",
Method = "GET",
Path = "/api/secure",
Headers = new Dictionary<string, string> { ["X-Run"] = $"{run}" },
Payload = Encoding.UTF8.GetBytes("test-payload")
};
var frame = FrameConverter.ToFrame(request);
// Act
await TcpFrameProtocolWrapper.WriteFrameAsync(stream, frame, CancellationToken.None);
stream.Position = 0;
var readFrame = await TcpFrameProtocolWrapper.ReadFrameAsync(stream, 16 * 1024 * 1024, CancellationToken.None);
var restored = FrameConverter.ToRequestFrame(readFrame!);
// Assert
restored!.RequestId.Should().Be("tls-deterministic");
restored.Path.Should().Be("/api/secure");
}
}
#endregion
#region Test Helpers
private static X509Certificate2 CreateTestCertificate(string subject)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN={subject}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true));
var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddYears(1));
var pfxBytes = certificate.Export(X509ContentType.Pfx);
return X509CertificateLoader.LoadPkcs12(
pfxBytes,
null,
X509KeyStorageFlags.MachineKeySet);
}
/// <summary>
/// Wrapper to access TCP frame protocol (shared between TCP and TLS after handshake).
/// </summary>
private static class TcpFrameProtocolWrapper
{
public static Task WriteFrameAsync(Stream stream, Frame frame, CancellationToken ct)
{
// TLS transport uses the same frame protocol as TCP
return Router.Transport.Tcp.FrameProtocol.WriteFrameAsync(stream, frame, ct);
}
public static Task<Frame?> ReadFrameAsync(Stream stream, int maxFrameSize, CancellationToken ct)
{
return Router.Transport.Tcp.FrameProtocol.ReadFrameAsync(stream, maxFrameSize, ct);
}
}
#endregion
}

View File

@@ -0,0 +1,824 @@
using System.Net;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Router.Common.Models;
using StellaOps.Router.Transport.Tls;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Router.Transport.Tls.Tests;
#region TlsTransportOptions Tests
public class TlsTransportOptionsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DefaultOptions_HaveCorrectValues()
{
// Act
var options = new TlsTransportOptions();
// Assert
options.Port.Should().Be(5101);
options.ReceiveBufferSize.Should().Be(64 * 1024);
options.SendBufferSize.Should().Be(64 * 1024);
options.KeepAliveInterval.Should().Be(TimeSpan.FromSeconds(30));
options.ConnectTimeout.Should().Be(TimeSpan.FromSeconds(10));
options.MaxReconnectAttempts.Should().Be(10);
options.MaxReconnectBackoff.Should().Be(TimeSpan.FromMinutes(1));
options.MaxFrameSize.Should().Be(16 * 1024 * 1024);
options.RequireClientCertificate.Should().BeFalse();
options.AllowSelfSigned.Should().BeFalse();
options.CheckCertificateRevocation.Should().BeFalse();
options.EnabledProtocols.Should().Be(SslProtocols.Tls12 | SslProtocols.Tls13);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Host_CanBeSet()
{
// Act
var options = new TlsTransportOptions { Host = "tls.gateway.local" };
// Assert
options.Host.Should().Be("tls.gateway.local");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Port_CanBeSet()
{
// Act
var options = new TlsTransportOptions { Port = 443 };
// Assert
options.Port.Should().Be(443);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(true)]
[InlineData(false)]
public void RequireClientCertificate_CanBeSet(bool required)
{
// Act
var options = new TlsTransportOptions { RequireClientCertificate = required };
// Assert
options.RequireClientCertificate.Should().Be(required);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(true)]
[InlineData(false)]
public void AllowSelfSigned_CanBeSet(bool allowed)
{
// Act
var options = new TlsTransportOptions { AllowSelfSigned = allowed };
// Assert
options.AllowSelfSigned.Should().Be(allowed);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(true)]
[InlineData(false)]
public void CheckCertificateRevocation_CanBeSet(bool check)
{
// Act
var options = new TlsTransportOptions { CheckCertificateRevocation = check };
// Assert
options.CheckCertificateRevocation.Should().Be(check);
}
[Trait("Category", TestCategories.Unit)]
[Theory]
[InlineData(SslProtocols.Tls12)]
[InlineData(SslProtocols.Tls13)]
[InlineData(SslProtocols.Tls12 | SslProtocols.Tls13)]
public void EnabledProtocols_CanBeSet(SslProtocols protocols)
{
// Act
var options = new TlsTransportOptions { EnabledProtocols = protocols };
// Assert
options.EnabledProtocols.Should().Be(protocols);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExpectedServerHostname_CanBeSet()
{
// Act
var options = new TlsTransportOptions { ExpectedServerHostname = "expected.host.name" };
// Assert
options.ExpectedServerHostname.Should().Be("expected.host.name");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ServerCertificatePath_CanBeSet()
{
// Act
var options = new TlsTransportOptions { ServerCertificatePath = "/etc/certs/server.pfx" };
// Assert
options.ServerCertificatePath.Should().Be("/etc/certs/server.pfx");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ClientCertificatePath_CanBeSet()
{
// Act
var options = new TlsTransportOptions { ClientCertificatePath = "/etc/certs/client.pfx" };
// Assert
options.ClientCertificatePath.Should().Be("/etc/certs/client.pfx");
}
}
#endregion
#region CertificateLoader Tests
public class CertificateLoaderTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void LoadServerCertificate_WithDirectCertificate_ReturnsCertificate()
{
// Arrange
var cert = CreateSelfSignedCertificate("TestServer");
var options = new TlsTransportOptions
{
ServerCertificate = cert
};
// Act
var loaded = CertificateLoader.LoadServerCertificate(options);
// Assert
loaded.Should().BeSameAs(cert);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void LoadServerCertificate_WithNoCertificate_ThrowsException()
{
// Arrange
var options = new TlsTransportOptions();
// Act & Assert
var action = () => CertificateLoader.LoadServerCertificate(options);
action.Should().Throw<InvalidOperationException>();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void LoadClientCertificate_WithNoCertificate_ReturnsNull()
{
// Arrange
var options = new TlsTransportOptions();
// Act
var result = CertificateLoader.LoadClientCertificate(options);
// Assert
result.Should().BeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void LoadClientCertificate_WithDirectCertificate_ReturnsCertificate()
{
// Arrange
var cert = CreateSelfSignedCertificate("TestClient");
var options = new TlsTransportOptions
{
ClientCertificate = cert
};
// Act
var loaded = CertificateLoader.LoadClientCertificate(options);
// Assert
loaded.Should().BeSameAs(cert);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void LoadServerCertificate_WithInvalidPath_ThrowsException()
{
// Arrange
var options = new TlsTransportOptions
{
ServerCertificatePath = "/nonexistent/path/cert.pfx"
};
// Act & Assert
var action = () => CertificateLoader.LoadServerCertificate(options);
action.Should().Throw<Exception>();
}
private static X509Certificate2 CreateSelfSignedCertificate(string subject)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN={subject}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true));
var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddYears(1));
// Export and re-import to get the private key
var pfxBytes = certificate.Export(X509ContentType.Pfx);
return X509CertificateLoader.LoadPkcs12(
pfxBytes,
null,
X509KeyStorageFlags.MachineKeySet);
}
}
#endregion
#region TlsTransportServer Tests
public class TlsTransportServerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_WithValidCertificate_StartsListening()
{
// Arrange
var cert = CreateSelfSignedCertificate("TestServer");
var options = Options.Create(new TlsTransportOptions
{
Port = 0,
ServerCertificate = cert
});
await using var server = new TlsTransportServer(options, NullLogger<TlsTransportServer>.Instance);
// Act
await server.StartAsync(CancellationToken.None);
// Assert
server.ConnectionCount.Should().Be(0);
await server.StopAsync(CancellationToken.None);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_WithNoCertificate_ThrowsException()
{
// Arrange
var options = Options.Create(new TlsTransportOptions { Port = 0 });
await using var server = new TlsTransportServer(options, NullLogger<TlsTransportServer>.Instance);
// Act & Assert
var action = () => server.StartAsync(CancellationToken.None);
await action.Should().ThrowAsync<InvalidOperationException>();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StopAsync_CanBeCalledWithoutStart()
{
// Arrange
var cert = CreateSelfSignedCertificate("TestServer");
var options = Options.Create(new TlsTransportOptions
{
Port = 0,
ServerCertificate = cert
});
await using var server = new TlsTransportServer(options, NullLogger<TlsTransportServer>.Instance);
// Act
var action = () => server.StopAsync(CancellationToken.None);
// Assert
await action.Should().NotThrowAsync();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ConnectionCount_InitiallyZero()
{
// Arrange
var cert = CreateSelfSignedCertificate("TestServer");
var options = Options.Create(new TlsTransportOptions
{
Port = 0,
ServerCertificate = cert
});
await using var server = new TlsTransportServer(options, NullLogger<TlsTransportServer>.Instance);
// Assert
server.ConnectionCount.Should().Be(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DisposeAsync_CanBeCalledMultipleTimes()
{
// Arrange
var cert = CreateSelfSignedCertificate("TestServer");
var options = Options.Create(new TlsTransportOptions
{
Port = 0,
ServerCertificate = cert
});
var server = new TlsTransportServer(options, NullLogger<TlsTransportServer>.Instance);
// Act
var action = async () =>
{
await server.DisposeAsync();
await server.DisposeAsync();
await server.DisposeAsync();
};
// Assert
await action.Should().NotThrowAsync();
}
private static X509Certificate2 CreateSelfSignedCertificate(string subject)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN={subject}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, critical: true));
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") },
critical: true));
var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddYears(1));
var pfxBytes = certificate.Export(X509ContentType.Pfx);
return X509CertificateLoader.LoadPkcs12(
pfxBytes,
null,
X509KeyStorageFlags.MachineKeySet);
}
}
#endregion
#region TlsTransportClient Tests
public class TlsTransportClientTests
{
private TlsTransportClient CreateClient(TlsTransportOptions? options = null)
{
var opts = options ?? new TlsTransportOptions { Host = "localhost", Port = 5101 };
return new TlsTransportClient(
Options.Create(opts),
NullLogger<TlsTransportClient>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Constructor_InitializesCorrectly()
{
// Act
await using var client = CreateClient();
// Assert - No exception means it initialized correctly
client.Should().NotBeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ConnectAsync_WithoutHost_ThrowsInvalidOperationException()
{
// Arrange
var options = new TlsTransportOptions { Host = null, Port = 5101 };
await using var client = CreateClient(options);
var instance = new InstanceDescriptor
{
ServiceName = "test",
Version = "1.0",
InstanceId = "test-1",
Region = "local"
};
// Act
var action = () => client.ConnectAsync(instance, [], CancellationToken.None);
// Assert
await action.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*Host is not configured*");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ConnectAsync_WithEmptyHost_ThrowsInvalidOperationException()
{
// Arrange
var options = new TlsTransportOptions { Host = "", Port = 5101 };
await using var client = CreateClient(options);
var instance = new InstanceDescriptor
{
ServiceName = "test",
Version = "1.0",
InstanceId = "test-1",
Region = "local"
};
// Act
var action = () => client.ConnectAsync(instance, [], CancellationToken.None);
// Assert
await action.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*Host is not configured*");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DisposeAsync_CanBeCalledMultipleTimes()
{
// Arrange
var client = CreateClient();
// Act
var action = async () =>
{
await client.DisposeAsync();
await client.DisposeAsync();
await client.DisposeAsync();
};
// Assert
await action.Should().NotThrowAsync();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DisconnectAsync_WithoutConnect_DoesNotThrow()
{
// Arrange
await using var client = CreateClient();
// Act
var action = () => client.DisconnectAsync();
// Assert
await action.Should().NotThrowAsync();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CancelAllInflight_WithNoInflight_DoesNotThrow()
{
// Arrange
await using var client = CreateClient();
// Act
var action = () => client.CancelAllInflight("test shutdown");
// Assert
action.Should().NotThrow();
}
}
#endregion
#region CertificateWatcher Tests
public class CertificateWatcherTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_LoadsServerCertificate()
{
// Arrange
var cert = CreateSelfSignedCertificate("TestServer");
var options = new TlsTransportOptions
{
ServerCertificate = cert
};
// Act
using var watcher = new CertificateWatcher(options, NullLogger<CertificateWatcher>.Instance);
// Assert
watcher.ServerCertificate.Should().BeSameAs(cert);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Constructor_LoadsClientCertificate()
{
// Arrange
var cert = CreateSelfSignedCertificate("TestClient");
var options = new TlsTransportOptions
{
ClientCertificate = cert
};
// Act
using var watcher = new CertificateWatcher(options, NullLogger<CertificateWatcher>.Instance);
// Assert
watcher.ClientCertificate.Should().BeSameAs(cert);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Dispose_CanBeCalledMultipleTimes()
{
// Arrange
var options = new TlsTransportOptions();
var watcher = new CertificateWatcher(options, NullLogger<CertificateWatcher>.Instance);
// Act
var action = () =>
{
watcher.Dispose();
watcher.Dispose();
watcher.Dispose();
};
// Assert
action.Should().NotThrow();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void OnServerCertificateReloaded_CanBeSubscribed()
{
// Arrange
var options = new TlsTransportOptions();
using var watcher = new CertificateWatcher(options, NullLogger<CertificateWatcher>.Instance);
var eventRaised = false;
// Act
watcher.OnServerCertificateReloaded += _ => eventRaised = true;
// Assert - no exception during subscription
eventRaised.Should().BeFalse();
watcher.Should().NotBeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void OnClientCertificateReloaded_CanBeSubscribed()
{
// Arrange
var options = new TlsTransportOptions();
using var watcher = new CertificateWatcher(options, NullLogger<CertificateWatcher>.Instance);
var eventRaised = false;
// Act
watcher.OnClientCertificateReloaded += _ => eventRaised = true;
// Assert - no exception during subscription
eventRaised.Should().BeFalse();
watcher.Should().NotBeNull();
}
private static X509Certificate2 CreateSelfSignedCertificate(string subject)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN={subject}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true));
var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddYears(1));
var pfxBytes = certificate.Export(X509ContentType.Pfx);
return X509CertificateLoader.LoadPkcs12(
pfxBytes,
null,
X509KeyStorageFlags.MachineKeySet);
}
}
#endregion
#region TlsIntegration Tests
public class TlsIntegrationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ServerAndClient_CanEstablishConnection()
{
// Arrange - Create self-signed server certificate
var serverCert = CreateSelfSignedServerCertificate("localhost");
var serverOptions = Options.Create(new TlsTransportOptions
{
Port = 0, // Auto-assign
ServerCertificate = serverCert,
RequireClientCertificate = false
});
await using var server = new TlsTransportServer(serverOptions, NullLogger<TlsTransportServer>.Instance);
// Act
await server.StartAsync(CancellationToken.None);
// Assert
server.ConnectionCount.Should().Be(0);
await server.StopAsync(CancellationToken.None);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ServerWithMtls_RequiresClientCertificate()
{
// Arrange
var serverCert = CreateSelfSignedServerCertificate("localhost");
var serverOptions = Options.Create(new TlsTransportOptions
{
Port = 0,
ServerCertificate = serverCert,
RequireClientCertificate = true,
AllowSelfSigned = true
});
await using var server = new TlsTransportServer(serverOptions, NullLogger<TlsTransportServer>.Instance);
// Act
await server.StartAsync(CancellationToken.None);
// Assert
serverOptions.Value.RequireClientCertificate.Should().BeTrue();
await server.StopAsync(CancellationToken.None);
}
private static X509Certificate2 CreateSelfSignedServerCertificate(string hostname)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
$"CN={hostname}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Key usage for server auth
request.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
critical: true));
// Server authentication EKU
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") },
critical: true));
// Subject Alternative Name
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName(hostname);
sanBuilder.AddIpAddress(IPAddress.Loopback);
request.CertificateExtensions.Add(sanBuilder.Build());
var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddYears(1));
var pfxBytes = certificate.Export(X509ContentType.Pfx);
return X509CertificateLoader.LoadPkcs12(
pfxBytes,
null,
X509KeyStorageFlags.MachineKeySet);
}
}
#endregion
#region ServiceCollection Extensions Tests
public class ServiceCollectionExtensionsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddTlsTransportServer_RegistersServices()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddTlsTransportServer(options =>
{
options.Port = 5101;
});
// Assert
var provider = services.BuildServiceProvider();
var server = provider.GetService<TlsTransportServer>();
server.Should().NotBeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddTlsTransportClient_RegistersServices()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddTlsTransportClient(options =>
{
options.Host = "localhost";
options.Port = 5101;
});
// Assert
var provider = services.BuildServiceProvider();
var client = provider.GetService<TlsTransportClient>();
client.Should().NotBeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddTlsTransportServer_WithOptions_ConfiguresOptions()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddTlsTransportServer(options =>
{
options.Port = 9443;
options.RequireClientCertificate = true;
});
// Assert
var provider = services.BuildServiceProvider();
var optionsService = provider.GetService<IOptions<TlsTransportOptions>>();
optionsService.Should().NotBeNull();
optionsService!.Value.Port.Should().Be(9443);
optionsService.Value.RequireClientCertificate.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddTlsTransportClient_WithOptions_ConfiguresOptions()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
// Act
services.AddTlsTransportClient(options =>
{
options.Host = "secure.gateway.local";
options.Port = 8443;
options.AllowSelfSigned = true;
});
// Assert
var provider = services.BuildServiceProvider();
var optionsService = provider.GetService<IOptions<TlsTransportOptions>>();
optionsService.Should().NotBeNull();
optionsService!.Value.Host.Should().Be("secure.gateway.local");
optionsService.Value.Port.Should().Be(8443);
optionsService.Value.AllowSelfSigned.Should().BeTrue();
}
}
#endregion