test(tst-24): drive the .NET and Python clients against real in-process gRPC servers
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:
@@ -12,6 +12,7 @@ from zb_mom_ww_mxgateway import client as client_module
|
||||
from zb_mom_ww_mxgateway import galaxy as galaxy_module
|
||||
from zb_mom_ww_mxgateway.galaxy import GalaxyRepositoryClient
|
||||
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
||||
from zb_mom_ww_mxgateway.options import ChannelSecurity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -21,11 +22,12 @@ async def test_gateway_connect_forwards_require_certificate_validation(
|
||||
"""The connect convenience kwarg must reach ClientOptions (Client.Python-027)."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
def fake_resolve(options: ClientOptions) -> ChannelSecurity:
|
||||
captured["options"] = options
|
||||
return object()
|
||||
return ChannelSecurity()
|
||||
|
||||
monkeypatch.setattr(client_module, "create_channel", fake_create_channel)
|
||||
monkeypatch.setattr(client_module, "resolve_channel_security", fake_resolve)
|
||||
monkeypatch.setattr(client_module, "create_channel", _stub_create_channel)
|
||||
monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object())
|
||||
|
||||
await GatewayClient.connect(
|
||||
@@ -43,11 +45,12 @@ async def test_galaxy_connect_forwards_require_certificate_validation(
|
||||
"""GalaxyRepositoryClient.connect must thread the flag too (Client.Python-027)."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
def fake_resolve(options: ClientOptions) -> ChannelSecurity:
|
||||
captured["options"] = options
|
||||
return object()
|
||||
return ChannelSecurity()
|
||||
|
||||
monkeypatch.setattr(galaxy_module, "create_channel", fake_create_channel)
|
||||
monkeypatch.setattr(galaxy_module, "resolve_channel_security", fake_resolve)
|
||||
monkeypatch.setattr(galaxy_module, "create_channel", _stub_create_channel)
|
||||
monkeypatch.setattr(
|
||||
galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object()
|
||||
)
|
||||
@@ -61,52 +64,67 @@ async def test_galaxy_connect_forwards_require_certificate_validation(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_connect_runs_create_channel_off_the_event_loop(
|
||||
async def test_gateway_connect_splits_probe_off_loop_and_channel_on_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""connect must run the blocking channel factory off the loop (Client.Python-028)."""
|
||||
ran_in_thread: dict[str, bool] = {}
|
||||
"""The blocking probe runs off the loop; the channel is built on it.
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
# If this runs on the event loop thread, get_running_loop() succeeds.
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
ran_in_thread["off_loop"] = False
|
||||
except RuntimeError:
|
||||
ran_in_thread["off_loop"] = True
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(client_module, "create_channel", fake_create_channel)
|
||||
Client.Python-028 required the blocking TOFU probe off the event loop. The
|
||||
channel itself must nonetheless be constructed *on* the loop thread: a
|
||||
``grpc.aio`` channel binds to the loop current on the constructing thread,
|
||||
and a ``to_thread`` worker has none, so building it off-loop raises
|
||||
``RuntimeError: There is no current event loop``. Assert both halves.
|
||||
"""
|
||||
where = _record_connect_threads(monkeypatch, client_module)
|
||||
monkeypatch.setattr(client_module.pb_grpc, "MxAccessGatewayStub", lambda channel: object())
|
||||
|
||||
await GatewayClient.connect(endpoint="gateway.example:5001")
|
||||
|
||||
assert ran_in_thread["off_loop"] is True
|
||||
assert where == {"resolve_off_loop": True, "create_on_loop": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_galaxy_connect_runs_create_channel_off_the_event_loop(
|
||||
async def test_galaxy_connect_splits_probe_off_loop_and_channel_on_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""GalaxyRepositoryClient.connect must also run the probe off the loop (Client.Python-028)."""
|
||||
ran_in_thread: dict[str, bool] = {}
|
||||
|
||||
def fake_create_channel(options: ClientOptions) -> object:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
ran_in_thread["off_loop"] = False
|
||||
except RuntimeError:
|
||||
ran_in_thread["off_loop"] = True
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(galaxy_module, "create_channel", fake_create_channel)
|
||||
"""GalaxyRepositoryClient.connect splits the probe and the channel the same way."""
|
||||
where = _record_connect_threads(monkeypatch, galaxy_module)
|
||||
monkeypatch.setattr(
|
||||
galaxy_module.galaxy_pb_grpc, "GalaxyRepositoryStub", lambda channel: object()
|
||||
)
|
||||
|
||||
await GalaxyRepositoryClient.connect(endpoint="gateway.example:5001")
|
||||
|
||||
assert ran_in_thread["off_loop"] is True
|
||||
assert where == {"resolve_off_loop": True, "create_on_loop": True}
|
||||
|
||||
|
||||
def _stub_create_channel(options: ClientOptions, *, security: ChannelSecurity) -> object:
|
||||
return object()
|
||||
|
||||
|
||||
def _on_event_loop_thread() -> bool:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _record_connect_threads(monkeypatch: pytest.MonkeyPatch, module: Any) -> dict[str, bool]:
|
||||
"""Patch *module*'s channel helpers to record which thread each ran on."""
|
||||
where: dict[str, bool] = {}
|
||||
|
||||
def fake_resolve(options: ClientOptions) -> ChannelSecurity:
|
||||
where["resolve_off_loop"] = not _on_event_loop_thread()
|
||||
return ChannelSecurity()
|
||||
|
||||
def fake_create_channel(options: ClientOptions, *, security: ChannelSecurity) -> object:
|
||||
where["create_on_loop"] = _on_event_loop_thread()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(module, "resolve_channel_security", fake_resolve)
|
||||
monkeypatch.setattr(module, "create_channel", fake_create_channel)
|
||||
return where
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user