"""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()