CultLib/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs
2026-07-20 03:43:05 +01:00

177 lines
7.8 KiB
C#

using System.Net;
using System.Net.Quic;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using FluentAssertions;
using GameCult.Mesh.Quic;
using GameCult.Mesh.Quic.Native;
using NUnit.Framework;
namespace GameCult.Mesh.Quic.Native.Tests;
[TestFixture]
public sealed class CultMeshNativeQuicRealtimeTransportTests
{
[Test]
public async Task NativeConnectorReceivesFromExternalManagedProvider()
{
var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT");
if (string.IsNullOrWhiteSpace(endpoint))
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise cross-process native/managed parity.");
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint!),
CultMeshEndpointId.Parse("aetheria.local"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var frame = await client.ReceiveAsync(timeout.Token);
client.TransportId.Should().Be("msquic-native-realtime");
frame.SchemaId.Should().NotBeNullOrWhiteSpace();
frame.BodyId.Should().NotBeNullOrWhiteSpace();
frame.Payload.Should().NotBeEmpty();
}
[Test]
public async Task NativeConnectorAuthenticatesAndReceivesLatestOnlyFrame()
{
if (!OperatingSystem.IsWindows() || !QuicListener.IsSupported)
Assert.Ignore("Native MsQuic integration requires Windows QUIC support.");
using var certificate = CreateCertificate();
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var pin = Convert.ToHexString(SHA256.HashData(certificate.RawData));
var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={pin}";
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
CultMeshEndpointId.Parse("aetheria.local"));
var expected = new CultMeshRealtimeFrame
{
ChannelId = "aetheria.entities",
SchemaId = "gamecult.aetheria.entity_soa.v1",
BodyId = "aetheria.entities.current-zone",
ProducerEpoch = 17,
Sequence = 23,
Delivery = CultMeshRealtimeDelivery.LatestOnly,
Payload = new byte[] { 1, 3, 3, 7 }
};
await WaitUntilAsync(() => server.ConnectionCount == 1);
await server.BroadcastAsync(expected);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var actual = await client.ReceiveAsync(timeout.Token);
client.TransportId.Should().Be("msquic-native-realtime");
actual.ChannelId.Should().Be(expected.ChannelId);
actual.SchemaId.Should().Be(expected.SchemaId);
actual.BodyId.Should().Be(expected.BodyId);
actual.ProducerEpoch.Should().Be(expected.ProducerEpoch);
actual.Sequence.Should().Be(expected.Sequence);
actual.Delivery.Should().Be(CultMeshRealtimeDelivery.LatestOnly);
actual.Payload.ToArray().Should().Equal(expected.Payload.ToArray());
}
[Test]
public async Task NativeConnectorRejectsWrongAdvertisedPin()
{
if (!OperatingSystem.IsWindows() || !QuicListener.IsSupported)
Assert.Ignore("Native MsQuic integration requires Windows QUIC support.");
using var certificate = CreateCertificate();
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={new string('0', 64)}";
var connect = () => connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
CultMeshEndpointId.Parse("aetheria.local"));
await connect.Should().ThrowAsync<IOException>()
.WithMessage("*certificate*pin*");
}
[Test]
public async Task NativeClientDepartureCannotBreakProviderBroadcast()
{
if (!OperatingSystem.IsWindows() || !QuicListener.IsSupported)
Assert.Ignore("Native MsQuic integration requires Windows QUIC support.");
using var certificate = CreateCertificate();
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var pin = Convert.ToHexString(SHA256.HashData(certificate.RawData));
var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={pin}";
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
var departedClient = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
CultMeshEndpointId.Parse("aetheria.local"));
using var healthyClient = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
CultMeshEndpointId.Parse("aetheria.local"));
await WaitUntilAsync(() => server.ConnectionCount == 2);
await server.BroadcastAsync(Frame(sequence: 1));
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await departedClient.ReceiveAsync(receiveTimeout.Token);
await healthyClient.ReceiveAsync(receiveTimeout.Token);
departedClient.Dispose();
for (var sequence = 2; sequence <= 100; sequence++)
await server.BroadcastAsync(Frame(sequence)).WaitAsync(TimeSpan.FromSeconds(1));
CultMeshRealtimeFrame received;
do
{
received = await healthyClient.ReceiveAsync(receiveTimeout.Token);
}
while (received.Sequence < 100);
received.Sequence.Should().Be(100,
"a departed observer cannot stall provider publication or starve healthy peers");
}
private static CultMeshRealtimeFrame Frame(long sequence) => new()
{
ChannelId = "aetheria.entities",
SchemaId = "gamecult.aetheria.entity_soa.v1",
BodyId = "aetheria.entities.current-zone",
ProducerEpoch = 17,
Sequence = sequence,
Delivery = CultMeshRealtimeDelivery.LatestOnly,
Payload = new byte[] { 1, 3, 3, 7 }
};
private static async Task WaitUntilAsync(Func<bool> predicate)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!predicate())
await Task.Delay(10, timeout.Token);
}
private static X509Certificate2 CreateCertificate()
{
using var key = RSA.Create(2048);
var request = new CertificateRequest(
"CN=localhost",
key,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
using var generated = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-1),
DateTimeOffset.UtcNow.AddMinutes(10));
return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null);
}
}