a8f86b5336
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.
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Wire-level tests: the Python client against a real localhost gRPC server.
|
|
|
|
Every other test in this suite substitutes a fake *stub* object for
|
|
``pb_grpc.MxAccessGatewayStub``, so nothing between the client wrapper and the
|
|
generated stub is exercised: no HTTP/2 framing, no protobuf serialization, no
|
|
call metadata, no gRPC status translation. That leaves a class of contract break
|
|
— a field the gateway populates but the client never decodes, metadata the
|
|
client believes it sends but does not, a status code it maps differently once it
|
|
arrives as a real ``grpc.RpcError`` — invisible to the default suite.
|
|
|
|
These tests close that gap by serving the real ``mxaccess_gateway.v1.MxAccessGateway``
|
|
service from an in-process ``grpc.aio`` server bound to ``127.0.0.1:0`` and
|
|
driving the ordinary public client API against it. The bytes on the wire are the
|
|
real ones; only the gateway's *behavior* is canned. No MXAccess, no worker, no
|
|
network beyond loopback, so this runs everywhere the normal suite runs.
|
|
|
|
See ``docs/GatewayTesting.md`` (Client Wire Tests) for the shared pattern and its
|
|
counterpart in the .NET client.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
|
|
import grpc
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from zb_mom_ww_mxgateway import ClientOptions, GatewayClient
|
|
from zb_mom_ww_mxgateway.errors import MxGatewayAuthorizationError
|
|
from zb_mom_ww_mxgateway.events import ReplayGap
|
|
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
|
|
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2_grpc as pb_grpc
|
|
|
|
API_KEY = "mxgw_wiretest_secret"
|
|
SESSION_ID = "wire-session-1"
|
|
SERVER_HANDLE = 4242
|
|
ITEM_HANDLE = 77
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
def _ok() -> pb.ProtocolStatus:
|
|
return pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK)
|
|
|
|
|
|
class FakeGateway(pb_grpc.MxAccessGatewayServicer):
|
|
"""Canned gateway serving the four session RPCs over a real transport.
|
|
|
|
Replies are shaped like the gateway's own: an OK ``ProtocolStatus``, the
|
|
echoed session id, and the typed payload the client wrapper reads (for
|
|
example ``RegisterReply.server_handle``). Set ``deny`` to make ``Invoke``
|
|
abort with ``PERMISSION_DENIED`` so the client's gRPC-status mapping is
|
|
exercised against a genuine ``grpc.RpcError`` rather than a hand-built one.
|
|
"""
|
|
|
|
def __init__(self, *, deny: bool = False, replay_gap: pb.ReplayGap | None = None) -> None:
|
|
self.deny = deny
|
|
self.replay_gap = replay_gap
|
|
self.endpoint = ""
|
|
self.metadata_by_method: dict[str, str] = {}
|
|
self.open_request: pb.OpenSessionRequest | None = None
|
|
self.invoke_request: pb.MxCommandRequest | None = None
|
|
self.stream_request: pb.StreamEventsRequest | None = None
|
|
self.close_request: pb.CloseSessionRequest | None = None
|
|
|
|
def _record(self, method: str, context: grpc.aio.ServicerContext) -> None:
|
|
for key, value in context.invocation_metadata() or ():
|
|
if key == "authorization":
|
|
self.metadata_by_method[method] = value
|
|
|
|
async def OpenSession( # noqa: N802 - generated gRPC method name
|
|
self, request: pb.OpenSessionRequest, context: grpc.aio.ServicerContext
|
|
) -> pb.OpenSessionReply:
|
|
"""Answer ``OpenSession`` with a fully populated reply."""
|
|
self._record("OpenSession", context)
|
|
self.open_request = request
|
|
return pb.OpenSessionReply(
|
|
session_id=SESSION_ID,
|
|
backend_name="fake-backend",
|
|
worker_process_id=1234,
|
|
worker_protocol_version=1,
|
|
capabilities=["events", "invoke"],
|
|
gateway_protocol_version=3,
|
|
protocol_status=_ok(),
|
|
)
|
|
|
|
async def Invoke( # noqa: N802 - generated gRPC method name
|
|
self, request: pb.MxCommandRequest, context: grpc.aio.ServicerContext
|
|
) -> pb.MxCommandReply:
|
|
"""Answer ``Invoke`` with a Register reply, or deny when configured."""
|
|
self._record("Invoke", context)
|
|
self.invoke_request = request
|
|
if self.deny:
|
|
await context.abort(grpc.StatusCode.PERMISSION_DENIED, "invoke scope required")
|
|
return pb.MxCommandReply(
|
|
session_id=request.session_id,
|
|
correlation_id=request.client_correlation_id,
|
|
kind=request.command.kind,
|
|
protocol_status=_ok(),
|
|
hresult=0,
|
|
register=pb.RegisterReply(server_handle=SERVER_HANDLE),
|
|
)
|
|
|
|
async def StreamEvents( # noqa: N802 - generated gRPC method name
|
|
self, request: pb.StreamEventsRequest, context: grpc.aio.ServicerContext
|
|
) -> AsyncIterator[pb.MxEvent]:
|
|
"""Stream an optional replay-gap sentinel followed by one data change."""
|
|
self._record("StreamEvents", context)
|
|
self.stream_request = request
|
|
if self.replay_gap is not None:
|
|
# The sentinel shape the gateway emits: family unspecified, body
|
|
# unset, only replay_gap populated.
|
|
yield pb.MxEvent(session_id=request.session_id, replay_gap=self.replay_gap)
|
|
yield pb.MxEvent(
|
|
session_id=request.session_id,
|
|
family=pb.MX_EVENT_FAMILY_ON_DATA_CHANGE,
|
|
server_handle=SERVER_HANDLE,
|
|
item_handle=ITEM_HANDLE,
|
|
value=pb.MxValue(int32_value=17),
|
|
quality=192,
|
|
worker_sequence=9,
|
|
on_data_change=pb.OnDataChangeEvent(),
|
|
)
|
|
|
|
async def CloseSession( # noqa: N802 - generated gRPC method name
|
|
self, request: pb.CloseSessionRequest, context: grpc.aio.ServicerContext
|
|
) -> pb.CloseSessionReply:
|
|
"""Answer ``CloseSession`` with a closed final state."""
|
|
self._record("CloseSession", context)
|
|
self.close_request = request
|
|
return pb.CloseSessionReply(
|
|
session_id=request.session_id,
|
|
final_state=pb.SESSION_STATE_CLOSED,
|
|
protocol_status=_ok(),
|
|
)
|
|
|
|
|
|
ServeGateway = Callable[..., Awaitable[FakeGateway]]
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def serve_gateway() -> AsyncIterator[ServeGateway]:
|
|
"""Yield a factory that serves a :class:`FakeGateway` on loopback.
|
|
|
|
Each call starts its own server on a free port and records it for teardown,
|
|
so a test can serve a differently-configured gateway without a fixture per
|
|
variant.
|
|
"""
|
|
servers: list[grpc.aio.Server] = []
|
|
|
|
async def _start(**kwargs: object) -> FakeGateway:
|
|
fake = FakeGateway(**kwargs) # type: ignore[arg-type]
|
|
server = grpc.aio.server()
|
|
pb_grpc.add_MxAccessGatewayServicer_to_server(fake, server)
|
|
port = _free_port()
|
|
server.add_insecure_port(f"127.0.0.1:{port}")
|
|
await server.start()
|
|
servers.append(server)
|
|
fake.endpoint = f"127.0.0.1:{port}"
|
|
return fake
|
|
|
|
try:
|
|
yield _start
|
|
finally:
|
|
for server in servers:
|
|
await server.stop(grace=None)
|
|
|
|
|
|
async def _connect(fake: FakeGateway) -> GatewayClient:
|
|
return await GatewayClient.connect(
|
|
ClientOptions(
|
|
endpoint=fake.endpoint,
|
|
api_key=API_KEY,
|
|
plaintext=True,
|
|
call_timeout=10.0,
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_round_trip_decodes_real_wire_bytes(serve_gateway: ServeGateway) -> None:
|
|
"""Open, invoke, stream, and close against a real server over loopback."""
|
|
wire_gateway = await serve_gateway()
|
|
client = await _connect(wire_gateway)
|
|
try:
|
|
session = await client.open_session(client_session_name="wire-test")
|
|
assert session.session_id == SESSION_ID
|
|
assert session.open_reply.backend_name == "fake-backend"
|
|
assert list(session.open_reply.capabilities) == ["events", "invoke"]
|
|
|
|
server_handle = await session.register("wire-test-client")
|
|
assert server_handle == SERVER_HANDLE
|
|
assert wire_gateway.invoke_request is not None
|
|
assert wire_gateway.invoke_request.command.kind == pb.MX_COMMAND_KIND_REGISTER
|
|
assert wire_gateway.invoke_request.command.register.client_name == "wire-test-client"
|
|
|
|
events = [event async for event in session.stream_events()]
|
|
assert len(events) == 1
|
|
event = events[0]
|
|
assert not isinstance(event, ReplayGap)
|
|
assert event.family == pb.MX_EVENT_FAMILY_ON_DATA_CHANGE
|
|
assert event.server_handle == SERVER_HANDLE
|
|
assert event.item_handle == ITEM_HANDLE
|
|
assert event.value.int32_value == 17
|
|
assert event.quality == 192
|
|
assert event.worker_sequence == 9
|
|
assert event.HasField("on_data_change")
|
|
|
|
close_reply = await session.close()
|
|
assert close_reply.final_state == pb.SESSION_STATE_CLOSED
|
|
assert wire_gateway.close_request is not None
|
|
assert wire_gateway.close_request.session_id == SESSION_ID
|
|
finally:
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_key_reaches_the_server_on_every_rpc(serve_gateway: ServeGateway) -> None:
|
|
"""The bearer header is on the wire for unary and streaming calls alike.
|
|
|
|
Stub-substituting tests can only assert what the client *passes*; this
|
|
asserts what the server *receives*, which is the property that matters.
|
|
"""
|
|
wire_gateway = await serve_gateway()
|
|
client = await _connect(wire_gateway)
|
|
try:
|
|
session = await client.open_session(client_session_name="wire-test")
|
|
await session.register("wire-test-client")
|
|
async for _ in session.stream_events():
|
|
break
|
|
await session.close()
|
|
finally:
|
|
await client.close()
|
|
|
|
expected = f"Bearer {API_KEY}"
|
|
assert wire_gateway.metadata_by_method == {
|
|
"OpenSession": expected,
|
|
"Invoke": expected,
|
|
"StreamEvents": expected,
|
|
"CloseSession": expected,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replay_gap_sentinel_survives_the_wire(serve_gateway: ServeGateway) -> None:
|
|
"""A resumed stream surfaces the gateway's sentinel as a typed ``ReplayGap``."""
|
|
replay_gap_gateway = await serve_gateway(
|
|
replay_gap=pb.ReplayGap(requested_after_sequence=3, oldest_available_sequence=8)
|
|
)
|
|
client = await _connect(replay_gap_gateway)
|
|
try:
|
|
session = await client.open_session(client_session_name="wire-test")
|
|
items = [item async for item in session.stream_events(after_worker_sequence=3)]
|
|
finally:
|
|
await client.close()
|
|
|
|
assert len(items) == 2
|
|
gap = items[0]
|
|
assert isinstance(gap, ReplayGap)
|
|
assert gap.requested_after_sequence == 3
|
|
assert gap.oldest_available_sequence == 8
|
|
assert gap.resume_after_worker_sequence == 7
|
|
assert not isinstance(items[1], ReplayGap)
|
|
assert items[1].family == pb.MX_EVENT_FAMILY_ON_DATA_CHANGE
|
|
|
|
assert replay_gap_gateway.stream_request is not None
|
|
assert replay_gap_gateway.stream_request.after_worker_sequence == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_permission_denied_maps_to_authorization_error(
|
|
serve_gateway: ServeGateway,
|
|
) -> None:
|
|
"""A real ``PERMISSION_DENIED`` status becomes the typed client error."""
|
|
denying_gateway = await serve_gateway(deny=True)
|
|
client = await _connect(denying_gateway)
|
|
try:
|
|
session = await client.open_session(client_session_name="wire-test")
|
|
with pytest.raises(MxGatewayAuthorizationError):
|
|
await session.register("wire-test-client")
|
|
finally:
|
|
await client.close()
|