CultLib/tests/GameCult.Mesh.Quic.Tests/CultMeshQuicRealtimeTransportTests.cs

269 lines
12 KiB
C#

using System.Net;
using System.Net.Quic;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using FluentAssertions;
using NUnit.Framework;
namespace GameCult.Mesh.Quic.Tests;
[TestFixture]
public sealed class CultMeshQuicRealtimeTransportTests
{
[Test]
public async Task ConnectorNegotiatesAgainstAPlainMsQuicListener()
{
if (!QuicListener.IsSupported) Assert.Ignore("MsQuic is unavailable on this test host.");
var protocol = new System.Net.Security.SslApplicationProtocol("cultmesh-state-v1");
using var certificate = CreateCertificate();
await using var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ApplicationProtocols = new List<System.Net.Security.SslApplicationProtocol> { protocol },
ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(new QuicServerConnectionOptions
{
DefaultCloseErrorCode = 0,
DefaultStreamErrorCode = 0,
ServerAuthenticationOptions = new System.Net.Security.SslServerAuthenticationOptions
{
ApplicationProtocols = new List<System.Net.Security.SslApplicationProtocol> { protocol },
EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls13,
ServerCertificate = certificate
}
})
});
var accept = listener.AcceptConnectionAsync().AsTask();
var connector = new CultMeshQuicRealtimeTransportConnector(new CultMeshQuicRealtimeConnectorOptions
{
ApplicationProtocol = protocol,
ValidateProviderCertificate = (_, _, _, _) => true
});
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate($"cultmesh-state+quic://127.0.0.1:{listener.LocalEndPoint.Port}"),
new CultMeshSessionTarget("aetheria", "service:aetheria.daemon"));
await using var serverConnection = await accept;
client.TransportId.Should().Be("msquic-realtime");
}
[Test]
public async Task AdvertisedCertificatePinAuthenticatesASelfSignedProvider()
{
if (!QuicListener.IsSupported) Assert.Ignore("MsQuic is unavailable on this test host.");
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 = $"{CultMeshQuicRealtimeTransportConnector.Scheme}://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={pin}";
var connector = new CultMeshQuicRealtimeTransportConnector();
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
new CultMeshSessionTarget("aetheria", "service:aetheria.daemon"));
client.TransportId.Should().Be("msquic-realtime");
await WaitUntilAsync(() => server.ConnectionCount == 1);
}
[Test]
public async Task ReliableOrderedFramesShareAnOrderedStreamInBothDirections()
{
if (!QuicListener.IsSupported) Assert.Ignore("MsQuic is unavailable on this test host.");
using var certificate = CreateCertificate();
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon");
var target = new CultMeshSessionTarget("aetheria", runtimeId.Value);
using var sessions = CreateSessions(server, out var validatedIdentity);
CultMeshRealtimeSession session;
try
{
session = await sessions.ConnectRealtimeAsync(target);
}
catch when (server.BackgroundFailure.IsCompleted)
{
throw new InvalidOperationException("QUIC server accept loop failed.", await server.BackgroundFailure);
}
await WaitUntilAsync(() => server.ConnectionCount == 1);
await session.SendAsync(Frame(1, CultMeshRealtimeDelivery.ReliableOrdered));
await session.SendAsync(Frame(2, CultMeshRealtimeDelivery.ReliableOrdered));
var firstAtServer = await server.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5));
var secondAtServer = await server.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5));
await server.BroadcastAsync(Frame(3, CultMeshRealtimeDelivery.ReliableOrdered));
await server.BroadcastAsync(Frame(4, CultMeshRealtimeDelivery.ReliableOrdered));
var firstAtClient = await session.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5));
var secondAtClient = await session.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5));
new[] { firstAtServer.Sequence, secondAtServer.Sequence }.Should().Equal(1, 2);
new[] { firstAtClient.Sequence, secondAtClient.Sequence }.Should().Equal(3, 4);
firstAtClient.Payload.ToArray().Should().Equal(3, 4, 5);
validatedIdentity.Value.Should().Be(runtimeId.Value);
}
[Test]
public async Task LatestOnlyCoalescesPendingStateAndDropsOlderGenerations()
{
if (!QuicListener.IsSupported) Assert.Ignore("MsQuic is unavailable on this test host.");
using var certificate = CreateCertificate();
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon");
var target = new CultMeshSessionTarget("aetheria", runtimeId.Value);
using var sessions = CreateSessions(server, out _);
var session = await sessions.ConnectRealtimeAsync(target);
await WaitUntilAsync(() => server.ConnectionCount == 1);
await server.BroadcastAsync(Frame(100, CultMeshRealtimeDelivery.LatestOnly, producerEpoch: 7));
await server.BroadcastAsync(Frame(0, CultMeshRealtimeDelivery.LatestOnly, producerEpoch: 8));
await Task.Delay(100);
var latest = await session.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5));
await server.BroadcastAsync(Frame(999, CultMeshRealtimeDelivery.LatestOnly, producerEpoch: 7));
using var staleDeadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
Func<Task> receiveStale = () => session.ReceiveAsync(staleDeadline.Token);
latest.ProducerEpoch.Should().Be(8);
latest.Sequence.Should().Be(0);
await receiveStale.Should().ThrowAsync<OperationCanceledException>(
"a late older generation must not twitch the consumer backwards");
}
[Test]
public async Task UnreliableDeliveryFailsClosedUntilANativeDatagramConnectorExists()
{
if (!QuicListener.IsSupported) Assert.Ignore("MsQuic is unavailable on this test host.");
using var certificate = CreateCertificate();
await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions
{
ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
ServerCertificate = certificate
});
var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon");
var target = new CultMeshSessionTarget("aetheria", runtimeId.Value);
using var sessions = CreateSessions(server, out _);
var session = await sessions.ConnectRealtimeAsync(target);
Func<Task> send = () => session.SendAsync(Frame(1, CultMeshRealtimeDelivery.Unreliable));
await send.Should().ThrowAsync<NotSupportedException>().WithMessage("*datagrams*");
session.State.Status.Should().Be(CultMeshSessionStatus.Offline);
}
private static CultMeshSessionManager CreateSessions(
CultMeshQuicRealtimeServer server,
out IdentityObservation validatedIdentity)
{
validatedIdentity = new IdentityObservation();
var observation = validatedIdentity;
var endpoint = $"{CultMeshQuicRealtimeTransportConnector.Scheme}://127.0.0.1:{server.LocalEndPoint.Port}";
var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) });
var connector = new CultMeshQuicRealtimeTransportConnector(new CultMeshQuicRealtimeConnectorOptions
{
ValidateProviderCertificate = (identity, _, _, _) =>
{
observation.Value = identity.Value;
return true;
}
});
return new CultMeshSessionManager(
discovery,
Array.Empty<ICultMeshTransportConnector>(),
Array.Empty<ICultMeshContentTransportConnector>(),
new ICultMeshRealtimeTransportConnector[] { connector },
new CultMeshSessionManagerOptions
{
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
});
}
private static CultMeshRealtimeFrame Frame(
long sequence,
CultMeshRealtimeDelivery delivery,
long producerEpoch = 7) => new()
{
ChannelId = "aetheria.entities",
SchemaId = "eve.entity_soa.v1",
BodyId = "body:aetheria:entities",
ProducerEpoch = producerEpoch,
Sequence = sequence,
Delivery = delivery,
Payload = new byte[] { 3, 4, 5 }
};
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 X509EnhancedKeyUsageExtension(
new OidCollection { new("1.3.6.1.5.5.7.3.1") },
false));
var names = new SubjectAlternativeNameBuilder();
names.AddDnsName("localhost");
names.AddIpAddress(IPAddress.Loopback);
request.CertificateExtensions.Add(names.Build());
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-1),
DateTimeOffset.UtcNow.AddHours(1));
return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null);
}
private static async Task WaitUntilAsync(Func<bool> predicate)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!predicate())
{
if (DateTime.UtcNow >= deadline) throw new TimeoutException("Timed out waiting for QUIC connection state.");
await Task.Delay(10);
}
}
private sealed class RouteSource(string endpoint) : ICultMeshLookupSource
{
public string SourceId => "quic-test";
public Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(
CultMeshDiscoveryQuery query,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
IReadOnlyList<CultMeshDiscoveryObservation> result = new[]
{
new CultMeshDiscoveryObservation(
new CultMeshVerseDescriptor(
"aetheria",
"Aetheria",
CultMeshVerseAuthorityModel.OperatorCluster,
new CultMeshVerseCompatibility("cultmesh.v1", "test"),
new[] { endpoint },
new[] { "service:aetheria.daemon" }),
SourceId,
now,
now.AddMinutes(1),
CultMeshDiscoveryTrust.Signed)
};
return Task.FromResult(result);
}
}
private sealed class IdentityObservation
{
public string? Value { get; set; }
}
}