test(tst-24): drive the .NET and Python clients against real in-process gRPC servers
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m19s
ci / portable (push) Successful in 23m59s
ci / windows-x86 (push) Failing after 58m42s

TST-24 asked for per-client wire tests against a fake gateway. An audit first
corrected the finding's premise: Go, Rust, and Java already had them — bufconn,
a loopback tonic server, and InProcessServerBuilder respectively — each already
asserting the round trip, the server-observed bearer header, and the ReplayGap
sentinel. The two genuine gaps were .NET (every test substituted the transport
interface; the test project had no server package at all) and Python (stub
monkeypatching everywhere but one opt-in TLS test).

Both now serve mxaccess_gateway.v1.MxAccessGateway over a real transport —
Kestrel h2c and grpc.aio, each on an ephemeral loopback port — and drive the
ordinary public client API against it. Only the gateway's behaviour is canned;
the framing, serialization, metadata, and status codes are genuine. Four shapes
each: full round trip with every reply field asserted, the authorization header
as received by the server (including on the streaming RPC), the ReplayGap
sentinel surfaced as the client's typed signal, and a real PERMISSION_DENIED
mapping to the typed authorization error.

The .NET client was only ever compiled in CI, never tested, so the portable job
gains a dotnet test step.

Fixes a bug the new tests caught on their first run: Python's connect() built the
grpc.aio channel inside asyncio.to_thread, and a grpc.aio channel binds to the
event loop current on the constructing thread, so every non-stub connection
raised 'There is no current event loop in thread'. No mock-based test could see
it, and the test guarding the off-loop behaviour patched create_channel and so
asserted the bug. Split resolve_channel_security (blocking TOFU probe, off-loop)
from create_channel (on-loop); the guard tests now assert both halves.
This commit is contained in:
Joseph Doherty
2026-08-10 08:22:25 -04:00
parent d4302c6ac4
commit a8f86b5336
16 changed files with 980 additions and 76 deletions
@@ -0,0 +1,162 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Drives the public client API against <see cref="WireFakeGatewayServer"/> — a real
/// gRPC server on loopback — so the transport, protobuf serialization, call metadata,
/// and gRPC status mapping are all exercised. Every other test in this project
/// substitutes <see cref="FakeGatewayTransport"/> and therefore proves nothing about
/// what actually crosses the wire.
/// </summary>
public sealed class MxGatewayClientWireTests
{
private const string ApiKey = "mxgw_wiretest_secret";
/// <summary>
/// Verifies the full session happy path decodes real wire bytes end to end.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SessionRoundTrip_OverRealTransport_DecodesEveryReplyField()
{
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync();
await using MxGatewayClient client = server.CreateClient(ApiKey);
MxGatewaySession session = await client.OpenSessionAsync(
new OpenSessionRequest { ClientSessionName = "wire-test" });
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.SessionId, session.SessionId);
Assert.Equal("fake-backend", session.OpenSessionReply.BackendName);
Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId);
Assert.Equal(3u, session.OpenSessionReply.GatewayProtocolVersion);
Assert.Equal(["events", "invoke"], session.OpenSessionReply.Capabilities);
int serverHandle = await session.RegisterAsync("wire-test-client");
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.ServerHandle, serverHandle);
MxCommandRequest? invoke = server.Service.InvokeRequest;
Assert.NotNull(invoke);
Assert.Equal(MxCommandKind.Register, invoke.Command.Kind);
Assert.Equal("wire-test-client", invoke.Command.Register.ClientName);
List<MxEvent> events = await CollectAsync(client.StreamEventsAsync(
new StreamEventsRequest { SessionId = session.SessionId }));
MxEvent single = Assert.Single(events);
Assert.Equal(MxEventFamily.OnDataChange, single.Family);
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.ServerHandle, single.ServerHandle);
Assert.Equal(WireFakeGatewayServer.FakeGatewayService.ItemHandle, single.ItemHandle);
Assert.Equal(17, single.Value.Int32Value);
Assert.Equal(192, single.Quality);
Assert.Equal(9ul, single.WorkerSequence);
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, single.BodyCase);
CloseSessionReply closeReply = await session.CloseAsync();
Assert.Equal(SessionState.Closed, closeReply.FinalState);
Assert.Equal(
WireFakeGatewayServer.FakeGatewayService.SessionId,
server.Service.CloseSessionRequest?.SessionId);
}
/// <summary>
/// Verifies the API key reaches the server as a bearer header on unary and
/// streaming calls alike. A transport fake can only assert what the client passes;
/// this asserts what the server receives.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApiKey_ReachesTheServerAsBearerMetadata_OnEveryRpc()
{
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync();
await using MxGatewayClient client = server.CreateClient(ApiKey);
MxGatewaySession session = await client.OpenSessionAsync(
new OpenSessionRequest { ClientSessionName = "wire-test" });
await session.RegisterAsync("wire-test-client");
await CollectAsync(client.StreamEventsAsync(
new StreamEventsRequest { SessionId = session.SessionId }));
await session.CloseAsync();
string expected = $"Bearer {ApiKey}";
Assert.Equal(
new Dictionary<string, string>
{
["OpenSession"] = expected,
["Invoke"] = expected,
["StreamEvents"] = expected,
["CloseSession"] = expected,
},
server.Service.AuthorizationByMethod);
}
/// <summary>
/// Verifies the gateway's replay-gap sentinel survives serialization and is
/// surfaced as a typed, non-terminal stream item.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReplayGapSentinel_SurvivesTheWire_AsTypedStreamItem()
{
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync(service =>
service.ReplayGap = new ReplayGap
{
RequestedAfterSequence = 3,
OldestAvailableSequence = 8,
});
await using MxGatewayClient client = server.CreateClient(ApiKey);
MxGatewaySession session = await client.OpenSessionAsync(
new OpenSessionRequest { ClientSessionName = "wire-test" });
List<MxEventStreamItem> items = [];
IAsyncEnumerable<MxEvent> stream = client.StreamEventsAsync(new StreamEventsRequest
{
SessionId = session.SessionId,
AfterWorkerSequence = 3,
});
await foreach (MxEventStreamItem item in stream.AsStreamItemsAsync())
{
items.Add(item);
}
Assert.Equal(2, items.Count);
Assert.True(items[0].IsReplayGap);
Assert.Equal(3ul, items[0].ReplayGap!.RequestedAfterSequence);
Assert.Equal(8ul, items[0].ReplayGap!.OldestAvailableSequence);
Assert.False(items[1].IsReplayGap);
Assert.Equal(MxEventFamily.OnDataChange, items[1].Event.Family);
Assert.Equal(3ul, server.Service.StreamEventsRequest?.AfterWorkerSequence);
}
/// <summary>
/// Verifies a genuine <c>PERMISSION_DENIED</c> status maps to the typed client
/// exception rather than a bare RpcException.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PermissionDeniedStatus_MapsToAuthorizationException()
{
await using WireFakeGatewayServer server = await WireFakeGatewayServer.StartAsync(
service => service.DenyInvoke = true);
await using MxGatewayClient client = server.CreateClient(ApiKey);
MxGatewaySession session = await client.OpenSessionAsync(
new OpenSessionRequest { ClientSessionName = "wire-test" });
await Assert.ThrowsAsync<MxGatewayAuthorizationException>(
() => session.RegisterAsync("wire-test-client"));
}
private static async Task<List<MxEvent>> CollectAsync(IAsyncEnumerable<MxEvent> stream)
{
List<MxEvent> events = [];
await foreach (MxEvent gatewayEvent in stream)
{
events.Add(gatewayEvent);
}
return events;
}
}