CultLib/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs
Meta Vens 8cf89a7e56 Extend the Cut 5 interop lane: a late C# native connector gets the TS retained frame
Cut C's headline cross-process proof. The TS `serve` peer already tore its
listener down the instant it finished broadcasting, so nothing could dial it
late; it gains `--linger-ms` to hold the listener open afterward instead. The
interop harness starts that provider, runs a throwaway `dial` peer that
receives every frame and disconnects (the provider requires a first peer
before it broadcasts at all, so this is also what proves broadcasting
finished), then only after that dials with the C# native connector and
asserts the frame it gets is the retained one, by payload, not just
non-empty. New `NativeConnectorReceivesTheRetainedFrameWhenJoiningLate` in
GameCult.Mesh.Quic.Native.Tests carries the assertion; the existing
`NativeConnectorReceivesFromExternalProvider` is untouched. Windows-only,
matching the native connector under test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 18:32:39 +02:00

210 lines
9.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 NativeConnectorReceivesFromExternalProvider()
{
// The provider on the other end is no longer necessarily the C#
// managed one (Cut 5 adds a TypeScript provider on this same lane);
// the env var name stays, since it names the endpoint, not the peer.
var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT");
if (string.IsNullOrWhiteSpace(endpoint))
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise cross-process native/external-provider parity.");
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint!),
new CultMeshSessionTarget("aetheria", "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();
}
/// <summary>
/// Cut C's headline cross-process proof: the harness lets the external
/// TypeScript provider finish broadcasting and disconnect its trigger peer
/// <i>before</i> this test dials, so nothing is retransmitted live. If a
/// frame arrives at all here, it can only be the provider's retained seed;
/// this test also checks the retained frame's own payload, so a
/// regression that seeds a stale generation (not just no seed) is caught
/// too.
/// </summary>
[Test]
public async Task NativeConnectorReceivesTheRetainedFrameWhenJoiningLate()
{
var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT");
if (string.IsNullOrWhiteSpace(endpoint))
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise the late-join retention lane.");
var expectedPayload = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD");
if (string.IsNullOrWhiteSpace(expectedPayload))
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD to the retained frame's expected payload text.");
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
using var client = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint!),
new CultMeshSessionTarget("aetheria", "aetheria.local"));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var frame = await client.ReceiveAsync(timeout.Token);
frame.Delivery.Should().Be(CultMeshRealtimeDelivery.LatestOnly);
System.Text.Encoding.UTF8.GetString(frame.Payload.ToArray()).Should().Be(expectedPayload);
}
[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),
new CultMeshSessionTarget("aetheria", "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),
new CultMeshSessionTarget("aetheria", "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),
new CultMeshSessionTarget("aetheria", "aetheria.local"));
using var healthyClient = await connector.ConnectAsync(
new CultMeshTransportCandidate(endpoint),
new CultMeshSessionTarget("aetheria", "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);
}
}