"""Step definitions for ASGI protocol behavior scenarios.""" from __future__ import annotations import asyncio import logging from collections import deque from typing import Any from behave import given, then, when from behave.runner import Context from cleveragents.a2a.asgi import app SendMessage = dict[str, Any] @given("the ASGI app module is loaded") def step_asgi_module_loaded(context: Context) -> None: context.asgi_app = app @when('I send an HTTP GET request to "{path}" through the ASGI app') def step_send_http_request(context: Context, path: str) -> None: sent_messages: list[SendMessage] = [] async def receive() -> dict[str, Any]: return {"type": "http.request", "body": b"", "more_body": False} async def send(message: SendMessage) -> None: sent_messages.append(message) scope = {"type": "http", "method": "GET", "path": path} asyncio.run(context.asgi_app(scope, receive, send)) context.asgi_sent_messages = sent_messages @when('I send an HTTP POST request to "{path}" through the ASGI app') def step_send_http_post_request(context: Context, path: str) -> None: sent_messages: list[SendMessage] = [] async def receive() -> dict[str, Any]: return {"type": "http.request", "body": b"", "more_body": False} async def send(message: SendMessage) -> None: sent_messages.append(message) scope = {"type": "http", "method": "POST", "path": path} asyncio.run(context.asgi_app(scope, receive, send)) context.asgi_sent_messages = sent_messages @then("the HTTP response status should be {status:d}") def step_http_status(context: Context, status: int) -> None: start = next( msg for msg in context.asgi_sent_messages if msg.get("type") == "http.response.start" ) assert start.get("status") == status, ( f"Expected HTTP status {status}, got {start.get('status')}" ) @then('the HTTP response body should be "{body}"') def step_http_body(context: Context, body: str) -> None: response_body = next( msg for msg in context.asgi_sent_messages if msg.get("type") == "http.response.body" ) normalized_body = body.replace(r"\"", '"') assert response_body.get("body") == normalized_body.encode("utf-8"), ( f"Expected body {normalized_body!r}, got {response_body.get('body')!r}" ) @when("I run ASGI lifespan startup then shutdown") def step_run_lifespan(context: Context) -> None: sent_messages: list[SendMessage] = [] incoming = deque( [ {"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}, ] ) async def receive() -> dict[str, Any]: if not incoming: raise AssertionError( "ASGI app issued an unexpected extra receive() call; " "all queued lifespan messages have already been consumed" ) return incoming.popleft() async def send(message: SendMessage) -> None: sent_messages.append(message) scope = {"type": "lifespan"} asyncio.run(context.asgi_app(scope, receive, send)) context.asgi_sent_messages = sent_messages @then("the ASGI app should emit lifespan completion messages") def step_assert_lifespan_messages(context: Context) -> None: message_types = [str(msg.get("type")) for msg in context.asgi_sent_messages] assert message_types == [ "lifespan.startup.complete", "lifespan.shutdown.complete", ], f"Unexpected lifespan messages: {message_types}" @then("no HTTP response frames should be emitted") def step_no_http_frames(context: Context) -> None: message_types = [str(msg.get("type")) for msg in context.asgi_sent_messages] assert not any( msg_type.startswith("http.response") for msg_type in message_types ), f"Unexpected HTTP response frames: {message_types}" @when("I invoke the ASGI app with a websocket scope") def step_websocket_scope(context: Context) -> None: sent_messages: list[SendMessage] = [] async def receive() -> dict[str, Any]: return {"type": "websocket.connect"} async def send(message: SendMessage) -> None: sent_messages.append(message) scope = {"type": "websocket", "path": "/ws"} asyncio.run(context.asgi_app(scope, receive, send)) context.asgi_sent_messages = sent_messages @when("I invoke the ASGI app with an unsupported scope type") def step_unsupported_scope(context: Context) -> None: async def receive() -> dict[str, Any]: return {"type": "unsupported.receive"} async def send(message: SendMessage) -> None: del message scope = {"type": "unsupported", "path": "/"} try: asyncio.run(context.asgi_app(scope, receive, send)) except RuntimeError as exc: context.asgi_error = exc return raise AssertionError("Expected RuntimeError for unsupported ASGI scope type") @then("the ASGI app should emit a websocket close frame") def step_websocket_close_frame(context: Context) -> None: assert context.asgi_sent_messages == [{"type": "websocket.close", "code": 1008}], ( f"Unexpected websocket frames: {context.asgi_sent_messages}" ) @then("the ASGI invocation should raise an unsupported scope runtime error") def step_unsupported_scope_error(context: Context) -> None: error = getattr(context, "asgi_error", None) assert isinstance(error, RuntimeError), f"Expected RuntimeError, got {error!r}" assert "Unsupported ASGI scope type" in str(error), ( f"Unexpected runtime error message: {error}" ) # --- Allow header assertion for 405 responses --- @then('the HTTP response should include an Allow header with value "{value}"') def step_http_allow_header(context: Context, value: str) -> None: start = next( msg for msg in context.asgi_sent_messages if msg.get("type") == "http.response.start" ) headers: list[tuple[bytes, bytes]] = start.get("headers", []) allow_values = [v.decode("utf-8") for k, v in headers if k.lower() == b"allow"] assert allow_values, f"Expected Allow header, but none found in: {headers}" assert value in allow_values, ( f"Expected Allow header value {value!r}, got {allow_values!r}" ) # --- Security-hardening header assertions --- @then("the HTTP response should include a content-length header") def step_http_content_length_header(context: Context) -> None: start = next( msg for msg in context.asgi_sent_messages if msg.get("type") == "http.response.start" ) headers: list[tuple[bytes, bytes]] = start.get("headers", []) header_names = [k.lower() for k, _v in headers] assert b"content-length" in header_names, ( f"Expected content-length header, but found: {headers}" ) @then('the HTTP response should include header "{name}" with value "{value}"') def step_http_header_with_value(context: Context, name: str, value: str) -> None: start = next( msg for msg in context.asgi_sent_messages if msg.get("type") == "http.response.start" ) headers: list[tuple[bytes, bytes]] = start.get("headers", []) name_bytes = name.lower().encode("utf-8") matched_values = [v.decode("utf-8") for k, v in headers if k.lower() == name_bytes] assert matched_values, f"Expected {name} header, but none found in: {headers}" assert value in matched_values, ( f"Expected {name} header value {value!r}, got {matched_values!r}" ) # --- Unrecognised lifespan message type --- @when("I run ASGI lifespan with an unrecognised message type") def step_run_lifespan_with_unrecognised_type(context: Context) -> None: sent_messages: list[SendMessage] = [] incoming = deque( [ {"type": "lifespan.startup"}, {"type": "lifespan.bogus"}, {"type": "lifespan.shutdown"}, ] ) async def receive() -> dict[str, Any]: if not incoming: raise AssertionError( "ASGI app issued an unexpected extra receive() call; " "all queued lifespan messages have already been consumed" ) return incoming.popleft() async def send(message: SendMessage) -> None: sent_messages.append(message) scope = {"type": "lifespan"} logger_name = "cleveragents.a2a.asgi" with _capture_log_records(logger_name, logging.WARNING) as records: asyncio.run(context.asgi_app(scope, receive, send)) context.asgi_sent_messages = sent_messages context.asgi_log_records = records @then("a warning should be logged for the unrecognised lifespan message type") def step_assert_lifespan_warning_logged(context: Context) -> None: records: list[logging.LogRecord] = context.asgi_log_records warning_messages = [r.getMessage() for r in records] assert any("lifespan.bogus" in msg for msg in warning_messages), ( f"Expected a warning about 'lifespan.bogus', got: {warning_messages}" ) class _capture_log_records: """Context manager that captures log records for a named logger.""" def __init__(self, logger_name: str, level: int) -> None: self._logger = logging.getLogger(logger_name) self._level = level self._handler: logging.Handler | None = None self.records: list[logging.LogRecord] = [] def __enter__(self) -> list[logging.LogRecord]: handler = logging.Handler() handler.setLevel(self._level) handler.emit = self.records.append # type: ignore[assignment] self._handler = handler self._logger.addHandler(handler) self._logger.setLevel(self._level) return self.records def __exit__(self, *_args: object) -> None: if self._handler is not None: self._logger.removeHandler(self._handler)