diff --git a/async_asgi_testclient/exceptions.py b/async_asgi_testclient/exceptions.py new file mode 100644 index 0000000..5e0636c --- /dev/null +++ b/async_asgi_testclient/exceptions.py @@ -0,0 +1,16 @@ +"""Exceptions for TestClient + +Base Exception class and sub-classed exceptions to make it easy +(and in some cases, possible at all) to handle errors in different +ways. +""" +from async_asgi_testclient.utils import Message +from typing import Optional + + +class TestClientError(Exception): + """An error in async_asgi_testclient""" + + def __init__(self, *args, message: Optional[Message] = None): + super().__init__(*args) + self.message = message diff --git a/async_asgi_testclient/testing.py b/async_asgi_testclient/testing.py index c8333ea..85f9d04 100644 --- a/async_asgi_testclient/testing.py +++ b/async_asgi_testclient/testing.py @@ -23,6 +23,7 @@ OTHER DEALINGS IN THE SOFTWARE. """ from async_asgi_testclient.compatibility import guarantee_single_callable +from async_asgi_testclient.exceptions import TestClientError from async_asgi_testclient.multipart import encode_multipart_formdata from async_asgi_testclient.response import BytesRW from async_asgi_testclient.response import Response @@ -46,7 +47,9 @@ import asyncio import inspect import requests +import logging +logger = logging.getLogger(__name__) sentinel = object() @@ -79,33 +82,47 @@ def __init__( self._lifespan_task = None # Must keep hard reference to prevent gc async def __aenter__(self): - self._lifespan_task = create_monitored_task( - self.application( - {"type": "lifespan", "asgi": {"version": "3.0"}}, - self._lifespan_input_queue.get, - self._lifespan_output_queue.put, - ), - self._lifespan_output_queue.put_nowait, - ) + try: + self._lifespan_task = create_monitored_task( + self.application( + {"type": "lifespan", "asgi": {"version": "3.0"}}, + self._lifespan_input_queue.get, + self._lifespan_output_queue.put, + ), + self._lifespan_output_queue.put_nowait, + ) + # Make sure there is time for the output queue to be processed + await self.send_lifespan("startup") + except TestClientError: + # Pass these through directly, so that test clients can assert on them + raise + except: # noqa + # Any other exception is (almost) definitely passed through from the app under test + # So it means the lifespan protocol is not supported. + logger.exception("Lifespan protocol raised an exception") + self._lifespan_task = None - await self.send_lifespan("startup") return self async def __aexit__(self, exc_type, exc, tb): - await self.send_lifespan("shutdown") - self._lifespan_task = None + # If task is None, lifespan protocol is disabled (not supported by app) + if self._lifespan_task is not None: + await self.send_lifespan("shutdown") + self._lifespan_task = None async def send_lifespan(self, action): await self._lifespan_input_queue.put({"type": f"lifespan.{action}"}) message = await receive(self._lifespan_output_queue, timeout=self.timeout) if isinstance(message, Message): - raise Exception(f"{message.event} - {message.reason} - {message.task}") + raise TestClientError( + f"{message.event} - {message.reason} - {message.task}", message=message + ) if message["type"] == f"lifespan.{action}.complete": pass elif message["type"] == f"lifespan.{action}.failed": - raise Exception(message) + raise TestClientError(message, message=message) def websocket_connect(self, *args: Any, **kwargs: Any) -> WebSocketSession: return WebSocketSession(self, *args, **kwargs) @@ -231,7 +248,7 @@ async def open( "type": "http", "http_version": "1.1", "asgi": {"version": "3.0"}, - "method": method, + "method": method.upper(), "scheme": scheme, "path": path, "query_string": query_string_bytes, @@ -261,12 +278,15 @@ async def open( message = await self.wait_response(receive_or_fail, "http.response.start") response.status_code = message["status"] response.headers = CIMultiDict( - [(k.decode("utf8"), v.decode("utf8")) for k, v in message["headers"]] + [ + (k.decode("utf8"), v.decode("utf8")) + for k, v in message.get("headers", []) + ] ) # Receive initial response body message = await self.wait_response(receive_or_fail, "http.response.body") - response.raw.write(message["body"]) + response.raw.write(message.get("body", b"")) response._more_body = message.get("more_body", False) # Consume the remaining response if not in stream diff --git a/async_asgi_testclient/tests/asgi_spec/conftest.py b/async_asgi_testclient/tests/asgi_spec/conftest.py new file mode 100644 index 0000000..03789f9 --- /dev/null +++ b/async_asgi_testclient/tests/asgi_spec/conftest.py @@ -0,0 +1,175 @@ +"""Test setup for ASGI spec tests + +Mock application used for testing ASGI standard compliance. +""" +from enum import Enum +from functools import partial +from sys import version_info as PY_VER # noqa + +import pytest + + +class AppState(Enum): + PREINIT = 0 + INIT = 1 + READY = 2 + SHUTDOWN = 3 + + +class BaseMockApp(object): + """A mock application object passed to TestClient for the tests""" + + # Make it easy to override these for lifespan related test scenarios + lifespan_startup_message = {"type": "lifespan.startup.complete", "message": "OK"} + lifespan_shutdown_message = {"type": "lifespan.shutdown.complete", "message": "OK"} + use_lifespan = True + + def __init__(self, **kwargs): + for k, v in kwargs: + setattr(self, k, v) + self.state = AppState.PREINIT + + async def lifespan_startup(self, scope, receive, send, msg): + if self.state == AppState.READY: + # Technically, this isn't explicitly forbidden in the spec. + # But I think it should not happen. + raise RuntimeError("Received more than one lifespan.startup") + self.state = AppState.READY + return await send(self.lifespan_startup_message) + + async def lifespan_shutdown(self, scope, receive, send, msg): + if self.state == AppState.SHUTDOWN: + # Technically, this isn't explicitly forbidden in the spec. + # But I think it should not happen. + raise RuntimeError("Received more than one lifespan.shutdown") + self.state = AppState.SHUTDOWN + return await send(self.lifespan_shutdown_message) + + async def lifespan(self, scope, receive, send): + if not self.use_lifespan: + raise RuntimeError(f"Type '{scope['type']}' is not supported.") + while True: + try: + msg = await receive() + except RuntimeError as e: + if e.args == ("Event loop is closed",): + return + else: + raise + + if msg["type"] == "lifespan.startup": + await self.lifespan_startup(scope, receive, send, msg) + elif msg["type"] == "lifespan.shutdown": + await self.lifespan_shutdown(scope, receive, send, msg) + else: + raise RuntimeError(f"Received unknown message type '{msg['type']}") + if self.state == AppState.SHUTDOWN: + return + + async def http_request(self, scope, receive, send, msg): + # Default behaviour, just send a minimal response with OK to any request + await send({"type": "http.response.start", "headers": [], "status": 200}) + await send({"type": "http.response.body", "body": b"OK"}) + + async def http_disconnect(self, scope, receive, send, msg): + raise RuntimeError(f"Received http.disconnect message {msg}") + + async def http(self, scope, receive, send): + msg = [] + # Receive http.requests until http.disconnect or more_body = False + while True: + msg.append(await receive()) + if msg[-1]["type"] == "http.disconnect" or not msg[-1].get( + "more_body", False + ): + break + if msg[0]["type"] == "http.disconnect": + # Honestly this shouldn't really happen, but it's allowed in spec, so check. + return await self.http_disconnect(scope, receive, send, msg) + else: + return await self.http_request(scope, receive, send, msg) + + async def websocket_connect(self, scope, receive, send, msg, msg_history): + await send({"type": "websocket.accept"}) + return True + + async def websocket_receive(self, scope, receive, send, msg, msg_history): + return True + + async def websocket_disconnect(self, scope, receive, send, msg, msg_history): + return False + + async def websocket(self, scope, receive, send): + msg_history = [] + while True: + msg = await receive() + + # Send websocket events to a handler + func = getattr( + self, msg["type"].replace(".", "_").replace("-", "__"), "handle_unknown" + ) + res = await func(scope, receive, send, msg, msg_history) + msg_history.append(msg) + + # If the event handler returns false, assume we closed the socket. + if msg["type"] == "websocket.disconnect" or not res: + return + + async def handle_unknown(self, scope, receive, send): + if self.state != AppState.READY: + raise RuntimeError( + "Received another request before lifespan.startup.complete sent" + ) + raise RuntimeError(f"Type '{scope['type']}' is not supported.") + + async def handle_all(self, scope, receive, send): + # Do nothing unless something monkeypatches us + pass + + async def asgi_call(self, scope, receive, send): + # Initial catch-all, for testing things like scope type itself + await self.handle_all(scope, receive, send) + + if self.state == AppState.PREINIT: + if self.use_lifespan: + self.state = AppState.INIT + else: + self.state = AppState.READY + if self.state == AppState.SHUTDOWN: + raise RuntimeError(f"Got message after shutting down: {scope}") + + # call hooks based on scope type, so we can monkeypatch them in tests + # the lifespan, http, and websocket protocol types all have simple methods already + # implemented. + func = getattr( + self, scope["type"].replace(".", "_").replace("-", "__"), "handle_unknown" + ) + return await func(scope, receive, send) + + +class MockApp(BaseMockApp): + """Modern ASGI single-callable app""" + + async def __call__(self, scope, receive, send): + return await super().asgi_call(scope, receive, send) + + +class LegacyMockApp(BaseMockApp): + """Legacy ASGI 'two-callable' app""" + + def __call__(self, scope): + return partial(super().asgi_call, scope) + + +@pytest.fixture(scope="function") +def mock_app(): + """Create a mock ASGI App to test the TestClient against""" + + return MockApp() + + +@pytest.fixture(scope="function") +def legacy_mock_app(): + """Create a mock legacy ASGI App to test the TestClient against""" + + return LegacyMockApp() diff --git a/async_asgi_testclient/tests/asgi_spec/test_application.py b/async_asgi_testclient/tests/asgi_spec/test_application.py new file mode 100644 index 0000000..568461b --- /dev/null +++ b/async_asgi_testclient/tests/asgi_spec/test_application.py @@ -0,0 +1,137 @@ +"""ASGI spec application tests + +Tests to verify conformance with the ASGI specification. This module tests +application-level tests. + +These tests attempt to make sure that TestClient conforms to +the ASGI specification documented at +https://asgi.readthedocs.io/en/latest/specs/main.html +""" + +from async_asgi_testclient import TestClient + +import pytest + + +@pytest.mark.asyncio +async def test_legacy_asgi_application(legacy_mock_app): + """ + Legacy (v2.0) ASGI applications are defined as a callable [...] which returns + another, awaitable callable [...] + + https://asgi.readthedocs.io/en/latest/specs/main.html#legacy-applications + + We expect a legacy app to be handled correctly and be callable as usual + """ + + async with TestClient(legacy_mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_asgi_version_is_present_in_scope(mock_app): + """ + The key scope["asgi"] will also be present as a dictionary containing a scope["asgi"]["version"] key + that corresponds to the ASGI version the server implements. + https://asgi.readthedocs.io/en/latest/specs/main.html#applications + + We expect this to be version 3.0, as that is the current spec version. + """ + + async def handle_all(scope, receive, send): + assert "asgi" in scope + assert scope["asgi"]["version"] == "3.0" + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + await client.get("/") + + +@pytest.mark.asyncio +async def test_sending_invalid_event_raises_exception(mock_app): + """ + If a server receives an invalid event dictionary - for example, having an unknown + type, missing keys an event type should have, or with wrong Python types for objects + (e.g. Unicode strings for HTTP headers) - it should raise an exception out of the + send awaitable back to the application. + https://asgi.readthedocs.io/en/latest/specs/main.html#error-handling + """ + + async def http_request(scope, receive, send, msg): + # Send an invalid response - expect an exception to be raised + await send( + {"type": "http.invalid.response.start", "headers": [], "status": 200} + ) + await send({"type": "http.response.body", "body": b"OK"}) + + mock_app.http_request = http_request + + async with TestClient(mock_app) as client: + with pytest.raises( + Exception, match=r"^Excpected message type 'http.response.start'. .*$" + ): + await client.get("/") + + +@pytest.mark.asyncio +async def test_sending_extra_keys_does_not_raise_error(mock_app): + """ + In both cases [of send and receive events], the presence of additional keys in the + event dictionary should not raise an exception. This allows non-breaking upgrades to + protocol specifications over time. + https://asgi.readthedocs.io/en/latest/specs/main.html#error-handling + """ + + async def http_request(scope, receive, send, msg): + # Send an invalid response - expect an exception to be raised + await send( + { + "type": "http.response.start", + "headers": [], + "status": 200, + "extra_unknown_key": "unnecessary data", + } + ) + await send( + { + "type": "http.response.body", + "body": b"OK", + "more_body": False, + "extra_unknown_data": "also unnecessary", + } + ) + + mock_app.http_request = http_request + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_scope_is_isolated_between_calls(mock_app): + """ + When middleware is modifying the scope, it should make a copy of the scope object + before mutating it and passing it to the inner application, as changes may leak + upstream otherwise. + https://asgi.readthedocs.io/en/latest/specs/main.html#middleware + + The spec doesn't explicitly state this in general, but it is implied in the + middleware section (and by common sense) that the scope dictionary should be + isolated within a call. So if we launch two http requests, one should not pollute + the other's scope dict. + """ + + async def handle_all(scope, receive, send): + assert "persisted" not in scope["asgi"] + scope["asgi"]["persisted"] = "this value should not persist across calls" + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + resp = await client.get("/") + assert resp.status_code == 200 diff --git a/async_asgi_testclient/tests/asgi_spec/test_http.py b/async_asgi_testclient/tests/asgi_spec/test_http.py new file mode 100644 index 0000000..633340c --- /dev/null +++ b/async_asgi_testclient/tests/asgi_spec/test_http.py @@ -0,0 +1,388 @@ +"""ASGI spec http tests + +Tests to verify conformance with the ASGI specification. This module tests +the http protocol. + +These tests attempt to make sure that TestClient conforms to +the ASGI specification documented at +https://asgi.readthedocs.io/en/latest/specs/main.html +""" +from async_asgi_testclient import TestClient +from multidict import CIMultiDict +from urllib.parse import quote + +import pytest + + +@pytest.mark.asyncio +async def test_asgi_version_is_present_in_http_scope(mock_app): + """ + The key scope["asgi"] will also be present as a dictionary containing a scope["asgi"]["version"] key + that corresponds to the ASGI version the server implements. + https://asgi.readthedocs.io/en/latest/specs/main.html#applications + + We expect this to be version 3.0, as that is the current spec version. + """ + + async def handle_all(scope, receive, send): + if scope["type"] == "http": + assert "asgi" in scope + assert scope["asgi"]["version"] == "3.0" + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + await client.get("/") + + +@pytest.mark.asyncio +async def test_http_spec_version_is_missing_or_correct(mock_app): + """ + asgi["spec_version"] (Unicode string) – Version of the ASGI HTTP spec this server + understands; one of "2.0" or "2.1". Optional; if missing assume 2.0 + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + + TestClient doesn't specify spec_version at the moment, which is also okay. + Note that if newer features are added (specifically allowing None in 'server' scope value) + then the spec_version needs to be passed correctly. + """ + + async def handle_all(scope, receive, send): + if scope["type"] == "http": + assert ( + "spec_version" not in scope["asgi"] + or scope["asgi"]["spec_version"] == "2.0" + ) + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_http_version_is_1_1(mock_app): + """ + http_version (Unicode string) – One of "1.0", "1.1" or "2". + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + TestClient currently only supports http/1.1, so test that it is set correctly. + """ + + async def handle_all(scope, receive, send): + if scope["type"] == "http": + assert scope["http_version"] == "1.1" + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_http_method_is_uppercased(mock_app): + """ + method (Unicode string) – The HTTP method name, uppercased. + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + Method is uppercased. + """ + + async def handle_all(scope, receive, send): + if scope["type"] == "http": + assert scope["method"] == "GET" + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + resp = await client.open("/", method="get") + assert resp.status_code == 200 + + +# Ok, this one I'm not sure about... +# The grey area here is TestClient doesn't currently decode an encoded URL; but should it? +# ASGI servers do, but then they're receiving the encoded URL from the web browser, and it is +# encoded because the HTTP protocol requires it...on the other hand, the query string is +# encoded from raw, and passed in encoded form... +# For now, mark this as xfail, but possibly we rewrite the test to say current behaviour is correct. TBD. +@pytest.mark.xfail( + AssertionError, + reason="TBD - this might be the correct behaviour, and the test is wrong", +) +@pytest.mark.asyncio +async def test_http_path_is_not_escaped(mock_app): + """ + path (Unicode string) – HTTP request target excluding any query string, with percent-encoded + sequences and UTF-8 byte sequences decoded into characters. + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + Path is decoded in the scope, with UTF-8 byte sequences properly decoded as well. + """ + + crazy_path = "/crazy.request with spaces and,!%/\xef/" + encoded_path = quote(crazy_path, encoding="utf-8") + + async def handle_all(scope, receive, send): + if scope["type"] == "http": + assert scope["path"] == crazy_path + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + resp = await client.get(encoded_path) + assert resp.status_code == 200 + + +@pytest.mark.xfail(KeyError, reason="The raw_path is not supported by TestClient") +@pytest.mark.asyncio +async def test_http_raw_path_is_escaped(mock_app): + """ + raw_path (byte string) – The original HTTP path component unmodified from the bytes + that were received by the web server. Some web server implementations may be unable + to provide this. Optional; if missing defaults to None. + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + """ + + crazy_path = "/crazy.request with spaces and,!%/\xef/" + encoded_path = quote(crazy_path, encoding="utf-8").encode("utf-8") + + async def handle_all(scope, receive, send): + if scope["type"] == "http": + assert scope["raw_path"] == encoded_path + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + resp = await client.get(crazy_path) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_http_querystring_is_escaped(mock_app): + """ + query_string (byte string) – URL portion after the ?, percent-encoded. + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + Query string is percent-encoded in the scope. + """ + + crazy_querystring = "q=crazy.request with spaces and?,!%&p=foobar" + encoded_querystring = quote(crazy_querystring, safe="&=", encoding="utf-8").encode( + "utf-8" + ) + + async def handle_all(scope, receive, send): + if scope["type"] == "http": + assert scope["query_string"] == encoded_querystring + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + resp = await client.get("/?" + crazy_querystring) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_testclient_preserves_headers_order_of_values(mock_app): + """ + headers (Iterable[[byte string, byte string]]) – An iterable of [name, value] + two-item iterables, where name is the header name, and value is the header value. + Order of header values must be preserved from the original HTTP request; order of header names is not important. + Duplicates are possible and must be preserved in the message as received. + Header names should be lowercased, but it is not required; servers should preserve header case on a best-effort + basis. Pseudo headers (present in HTTP/2 and HTTP/3) must be removed; if :authority is present its value must be + added to the start of the iterable with host as the header name or replace any existing host header already present. + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + """ + + # The spec isn't very clearly written on this; I *think* what it is saying, is if a header has multiple values with + # the same header name, the order of those values must be preserved...but that the overall order of headers with + # different names can change. This would match with implementations in eg Daphne. + + headers = [ + (b"x-test-1", b"2"), + (b"x-test-1", b"1"), + (b"x-test-9", b"4"), + (b"x-test-3", b"3"), + (b"x-test-1", b"3"), + ] + headers_dict = CIMultiDict( + [(k.decode("utf-8"), v.decode("utf-8")) for k, v in headers] + ) + + original_http_request = mock_app.http_request + + async def custom_http_request(scope, receive, send, msg): + # Check that the headers with the same name are still in the same order + request_headers = [(k, v) for k, v in scope["headers"] if k == b"x-test-1"] + matches_headers = [(k, v) for k, v in headers if k == b"x-test-1"] + assert request_headers == matches_headers + await original_http_request(scope, receive, send, msg) + + mock_app.http_request = custom_http_request + + async with TestClient(mock_app, headers=headers_dict) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_testclient_lowercases_header_names(mock_app): + """ + headers (Iterable[[byte string, byte string]]) – An iterable of [name, value] + Header names should be lowercased, but it is not required; servers should preserve header case on a best-effort + basis. + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + """ + + # The spec isn't very clearly written on this; I *think* what it is saying, is if a header has multiple values with + # the same header name, the order of those values must be preserved...but that the overall order of headers with + # different names can change. This would match with implementations in eg Daphne. + + headers = [ + (b"X-TEST-1", b"2"), + ] + headers_dict = CIMultiDict( + [(k.decode("utf-8"), v.decode("utf-8")) for k, v in headers] + ) + + original_http_request = mock_app.http_request + + async def custom_http_request(scope, receive, send, msg): + # Check that the headers with the same name are still in the same order + request_header_keys = [k for k, v in scope["headers"]] + assert b"x-test-1" in request_header_keys + await original_http_request(scope, receive, send, msg) + + mock_app.http_request = custom_http_request + + async with TestClient(mock_app, headers=headers_dict) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_response_headers_can_be_missing(mock_app): + """ + headers (Iterable[[byte string, byte string]]) – An iterable of [name, value] + two-item iterables, where name is the header name, and value is the header value. + Order must be preserved in the HTTP response. Header names must be lowercased. + Optional; if missing defaults to an empty list. + Pseudo headers (present in HTTP/2 and HTTP/3) must not be present. + https://asgi.readthedocs.io/en/latest/specs/www.html#response-start-send-event + """ + + async def custom_http_request(scope, receive, send, msg): + # A http.response.start is NOT required to have a headers key; if missing, defaults to []. + await send({"type": "http.response.start", "status": 200}) + await send({"type": "http.response.body", "body": b"OK", "more_body": False}) + + mock_app.http_request = custom_http_request + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_response_status_is_required(mock_app): + """ + status (int) – HTTP status code. + https://asgi.readthedocs.io/en/latest/specs/www.html#response-start-send-event + """ + + async def custom_http_request(scope, receive, send, msg): + # A http.response.start is NOT required to have a headers key; if missing, defaults to []. + await send({"type": "http.response.start", "headers": []}) + await send({"type": "http.response.body", "body": b"OK", "more_body": False}) + + mock_app.http_request = custom_http_request + + with pytest.raises(KeyError): + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.xfail( + AssertionError, reason="TestClient currently accepts incorrectly typed status code" +) +@pytest.mark.asyncio +async def test_response_status_must_be_int(mock_app): + """ + status (int) – HTTP status code. + https://asgi.readthedocs.io/en/latest/specs/www.html#response-start-send-event + """ + + async def custom_http_request(scope, receive, send, msg): + # A http.response.start is NOT required to have a headers key; if missing, defaults to []. + await send({"type": "http.response.start", "status": "200", "headers": []}) + await send({"type": "http.response.body", "body": b"OK", "more_body": False}) + + mock_app.http_request = custom_http_request + + with pytest.raises(TypeError): + async with TestClient(mock_app) as client: + await client.get("/") + + +@pytest.mark.asyncio +async def test_response_body_can_be_missing(mock_app): + """ + The TestClient should allow for a response with no headers in the message + https://asgi.readthedocs.io/en/latest/specs/www.html#response-body-send-event + """ + + async def custom_http_request(scope, receive, send, msg): + await send({"type": "http.response.start", "headers": [], "status": 200}) + # A http.response.body is NOT required to have a body key; if missing, defaults to b"". + await send({"type": "http.response.body", "more_body": False}) + + mock_app.http_request = custom_http_request + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_response_more_body_can_be_missing(mock_app): + """ + The TestClient should allow for a response with no headers in the message + https://asgi.readthedocs.io/en/latest/specs/www.html#response-body-send-event + """ + + async def custom_http_request(scope, receive, send, msg): + await send({"type": "http.response.start", "headers": [], "status": 200}) + # A http.response.body is NOT required to have a more_body key; if missing, defaults to False. + await send({"type": "http.response.body", "body": b"OK"}) + + mock_app.http_request = custom_http_request + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.xfail( + raises=AttributeError, + reason="TestClient is not currently compliant with this part of the spec", +) +@pytest.mark.asyncio +async def test_sending_event_after_disconnect_is_ignored(mock_app): + """ + Note that messages received by a server after the connection has been closed are not + considered errors. In this case the send awaitable callable should act as a no-op. + https://asgi.readthedocs.io/en/latest/specs/main.html#error-handling + """ + + async def http_request(scope, receive, send, msg): + # Send an invalid response - expect an exception to be raised + await send({"type": "http.response.start", "headers": [], "status": 200}) + await send({"type": "http.response.body", "body": b"OK", "more_body": False}) + # This should be ignored: + await send({"type": "http.response.start", "headers": [], "status": 404}) + + mock_app.http_request = http_request + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 diff --git a/async_asgi_testclient/tests/asgi_spec/test_lifespan.py b/async_asgi_testclient/tests/asgi_spec/test_lifespan.py new file mode 100644 index 0000000..5ec1037 --- /dev/null +++ b/async_asgi_testclient/tests/asgi_spec/test_lifespan.py @@ -0,0 +1,189 @@ +"""ASGI spec lifespan tests + +Tests to verify conformance with the ASGI specification. This module tests +the lifespan protocol. + +These tests attempt to make sure that TestClient conforms to +the ASGI specification documented at +https://asgi.readthedocs.io/en/latest/specs/main.html +""" +import logging + +from async_asgi_testclient import TestClient + +import pytest + + +@pytest.mark.asyncio +async def test_lifespan_spec_version_is_missing_or_correct(mock_app): + """ + asgi["spec_version"] (Unicode string) – The version of this spec being used. + Optional; if missing defaults to "1.0". + https://asgi.readthedocs.io/en/latest/specs/lifespan.html#scope + + TestClient doesn't specify spec_version at the moment, which is also okay. + """ + + async def handle_all(scope, receive, send): + if scope["type"] == "lifespan": + assert ( + "spec_version" not in scope["asgi"] + or scope["asgi"]["spec_version"] == "1.0" + ) + + mock_app.use_lifespan = True + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_lifespan_not_supported_is_allowed(mock_app): + """ + If an exception is raised when calling the application callable with a lifespan.startup + message or a scope with type lifespan, the server must continue but not send any lifespan + events. + https://asgi.readthedocs.io/en/latest/specs/lifespan.html#scope + """ + + mock_app.use_lifespan = False + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_lifespan_not_supported_logs_the_exception(mock_app, caplog): + """ + If an exception is raised when calling the application callable with a lifespan.startup + message or a scope with type lifespan, the server must continue but not send any lifespan + events. + https://asgi.readthedocs.io/en/latest/specs/lifespan.html#scope + + Test that the exception raised is logged by the TestClient. + """ + + mock_app.use_lifespan = False + + with caplog.at_level(level=logging.DEBUG, logger="async_asgi_testclient.testing"): + async with TestClient(mock_app) as client: + resp = await client.get("/") + records = [record for record in caplog.records if record.message == "Lifespan protocol raised an exception"] + assert len(records) == 1 + assert str(records[0].exc_info[1]) == "Type 'lifespan' is not supported." + + +@pytest.mark.asyncio +async def test_lifespan_startup_failed(mock_app): + """ + If the application returns lifespan.startup.failed, the client should not attempt to continue, and should raise + an exception that can be caught and asserted when using the TestClient. + https://asgi.readthedocs.io/en/latest/specs/lifespan.html#startup-complete-send-event + """ + + mock_app.use_lifespan = True + mock_app.lifespan_startup_message = { + "type": "lifespan.startup.failed", + } + + with pytest.raises(Exception, match=r"^{'type': 'lifespan.startup.failed'}"): + async with TestClient(mock_app) as client: + await client.get("/") + + +@pytest.mark.asyncio +async def test_lifespan_startup_failed_with_message(mock_app): + """ + If the application returns lifespan.startup.failed, the client should not attempt to continue, and should raise + an exception that can be caught and asserted when using the TestClient. + https://asgi.readthedocs.io/en/latest/specs/lifespan.html#startup-complete-send-event + """ + + mock_app.use_lifespan = True + mock_app.lifespan_startup_message = { + "type": "lifespan.startup.failed", + "message": "Cowardly failing", + } + + with pytest.raises( + Exception, + match=r"{'type': 'lifespan.startup.failed', 'message': 'Cowardly failing'}", + ): + async with TestClient(mock_app) as client: + await client.get("/") + + +@pytest.mark.asyncio +async def test_lifespan_startup_completed(mock_app): + """ + If the application returns lifespan.startup.complete, the client should continue with its request + https://asgi.readthedocs.io/en/latest/specs/lifespan.html#startup-complete-send-event + """ + + mock_app.use_lifespan = True + mock_app.lifespan_startup_message = {"type": "lifespan.startup.complete"} + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_lifespan_startup_completed_with_message(mock_app): + """ + If the application returns lifespan.startup.complete, the client should continue with its request + https://asgi.readthedocs.io/en/latest/specs/lifespan.html#startup-complete-send-event + """ + + mock_app.use_lifespan = True + mock_app.lifespan_startup_message = { + "type": "lifespan.startup.complete", + "message": "OK", + } + + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_lifespan_shutdown_failed_raises_error(mock_app): + """ + If the application returns lifespan.shutdown.failed, the server should raise an error + https://asgi.readthedocs.io/en/latest/specs/lifespan.html#shutdown-failed-send-event + """ + + mock_app.lifespan_shutdown_message = { + "type": "lifespan.shutdown.failed", + } + mock_app.use_lifespan = True + + with pytest.raises(Exception, match=r"{'type': 'lifespan.shutdown.failed'}"): + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_lifespan_shutdown_failed_with_message_raises_error(mock_app): + """ + If the application returns lifespan.shutdown.failed, the server should raise an error + https://asgi.readthedocs.io/en/latest/specs/lifespan.html#shutdown-failed-send-event + """ + + mock_app.lifespan_shutdown_message = { + "type": "lifespan.shutdown.failed", + "message": "We failed to shut down", + } + mock_app.use_lifespan = True + + with pytest.raises( + Exception, + match=r"{'type': 'lifespan.shutdown.failed', 'message': 'We failed to shut down'}", + ): + async with TestClient(mock_app) as client: + resp = await client.get("/") + assert resp.status_code == 200 diff --git a/async_asgi_testclient/tests/asgi_spec/test_websocket.py b/async_asgi_testclient/tests/asgi_spec/test_websocket.py new file mode 100644 index 0000000..1d69de7 --- /dev/null +++ b/async_asgi_testclient/tests/asgi_spec/test_websocket.py @@ -0,0 +1,302 @@ +"""ASGI spec websocket tests + +Tests to verify conformance with the ASGI specification. This module tests +the websocket protocol. + +These tests attempt to make sure that TestClient conforms to +the ASGI specification documented at +https://asgi.readthedocs.io/en/latest/specs/main.html +""" + +from async_asgi_testclient import TestClient +from multidict import CIMultiDict +from urllib.parse import quote + +import pytest + + +@pytest.mark.asyncio +async def test_asgi_version_is_present_in_websocket_scope(mock_app): + """ + The key scope["asgi"] will also be present as a dictionary containing a scope["asgi"]["version"] key + that corresponds to the ASGI version the server implements. + https://asgi.readthedocs.io/en/latest/specs/main.html#applications + + We expect this to be version 3.0, as that is the current spec version. + """ + + async def handle_all(scope, receive, send): + if scope["type"] == "websocket": + assert "asgi" in scope + assert scope["asgi"]["version"] == "3.0" + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + async with client.websocket_connect("/ws") as ws: + await ws.send_text("hello there") + await ws.close(code=1000) + + +@pytest.mark.asyncio +async def test_websocket_spec_version_is_missing_or_correct(mock_app): + """ + asgi["spec_version"] (Unicode string) – Version of the ASGI HTTP spec this server + understands; one of "2.0" or "2.1". Optional; if missing assume 2.0 + https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope + + TestClient doesn't specify spec_version at the moment, which is also okay. + Note that if newer features are added (eg websocket headers in Accept, reason + parameter to websocket.close) then the spec_version needs to be passed correctly. + """ + + async def handle_all(scope, receive, send): + if scope["type"] == "websocket": + assert ( + "spec_version" not in scope["asgi"] + or scope["asgi"]["spec_version"] == "2.0" + ) + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + async with client.websocket_connect("/ws") as ws: + await ws.send_text("hello there") + await ws.close(code=1000) + + +@pytest.mark.asyncio +async def test_http_version_is_1_1_or_missing(mock_app): + """ + http_version (Unicode string) – One of "1.1" or "2". + Optional; if missing default is "1.1". + https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope + Interestingly, the ASGI spec does not require http_version for websocket + (but does require it for http) + """ + + async def handle_all(scope, receive, send): + if scope["type"] == "websocket": + assert "http_version" not in scope or scope["http_version"] == "1.1" + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + async with client.websocket_connect("/ws") as ws: + await ws.send_text("hello there") + await ws.close(code=1000) + + +# Ok, this one I'm not sure about... +# The grey area here is TestClient doesn't currently decode an encoded URL; but should it? +# ASGI servers do, but then they're receiving the encoded URL from the web browser, and it is +# encoded because the HTTP protocol requires it...on the other hand, the query string is +# encoded from raw, and passed in encoded form... +# For now, mark this as xfail, but possibly we rewrite the test to say current behaviour is correct. TBD. +@pytest.mark.xfail( + AssertionError, + reason="TBD - this might be the correct behaviour, and the test is wrong", +) +@pytest.mark.asyncio +async def test_http_path_is_not_escaped(mock_app): + """ + path (Unicode string) – HTTP request target excluding any query string, with percent-encoded + sequences and UTF-8 byte sequences decoded into characters. + https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope + Path is decoded in the scope, with UTF-8 byte sequences properly decoded as well. + """ + + crazy_path = "/crazy.request with spaces and,!%/\xef/" + encoded_path = quote(crazy_path, encoding="utf-8") + + async def handle_all(scope, receive, send): + if scope["type"] == "websocket": + assert scope["path"] == crazy_path + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + async with client.websocket_connect(encoded_path) as ws: + await ws.send_text("hello there") + await ws.close(code=1000) + + +@pytest.mark.xfail(KeyError, reason="The raw_path is not supported by TestClient") +@pytest.mark.asyncio +async def test_http_raw_path_is_escaped(mock_app): + """ + raw_path (byte string) – The original HTTP path component unmodified from the bytes + that were received by the web server. Some web server implementations may be unable + to provide this. Optional; if missing defaults to None. + https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope + """ + + crazy_path = "/crazy.request with spaces and,!%/\xef/" + encoded_path = quote(crazy_path, encoding="utf-8").encode("utf-8") + + async def handle_all(scope, receive, send): + if scope["type"] == "websocket": + assert scope["raw_path"] == encoded_path + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + async with client.websocket_connect(crazy_path) as ws: + await ws.send_text("hello there") + await ws.close(code=1000) + + +@pytest.mark.asyncio +async def test_http_querystring_is_escaped(mock_app): + """ + query_string (byte string) – URL portion after the ?, percent-encoded. + https://asgi.readthedocs.io/en/latest/specs/www.html#websocket-connection-scope + Query string is percent-encoded in the scope. + """ + + crazy_querystring = "q=crazy.request with spaces and?,!%&p=foobar" + encoded_querystring = quote(crazy_querystring, safe="&=", encoding="utf-8").encode( + "utf-8" + ) + + async def handle_all(scope, receive, send): + if scope["type"] == "websocket": + assert scope["query_string"] == encoded_querystring + + mock_app.handle_all = handle_all + + async with TestClient(mock_app) as client: + async with client.websocket_connect("/ws?" + crazy_querystring) as ws: + await ws.send_text("hello there") + await ws.close(code=1000) + + +@pytest.mark.asyncio +async def test_testclient_preserves_headers_order_of_values(mock_app): + """ + headers (Iterable[[byte string, byte string]]) – An iterable of [name, value] + two-item iterables, where name is the header name, and value is the header value. + Order of header values must be preserved from the original HTTP request; order of header names is not important. + Duplicates are possible and must be preserved in the message as received. + Header names should be lowercased, but it is not required; servers should preserve header case on a best-effort + basis. Pseudo headers (present in HTTP/2 and HTTP/3) must be removed; if :authority is present its value must be + added to the start of the iterable with host as the header name or replace any existing host header already present. + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + """ + + # The spec isn't very clearly written on this; I *think* what it is saying, is if a header has multiple values with + # the same header name, the order of those values must be preserved...but that the overall order of headers with + # different names can change. This would match with implementations in eg Daphne. + + headers = [ + (b"x-test-1", b"2"), + (b"x-test-1", b"1"), + (b"x-test-9", b"4"), + (b"x-test-3", b"3"), + (b"x-test-1", b"3"), + ] + headers_dict = CIMultiDict( + [(k.decode("utf-8"), v.decode("utf-8")) for k, v in headers] + ) + + original_ws_request = mock_app.websocket_connect + + async def custom_ws_request(scope, receive, send, msg, msg_history): + # Check that the headers with the same name are still in the same order + request_headers = [(k, v) for k, v in scope["headers"] if k == b"x-test-1"] + matches_headers = [(k, v) for k, v in headers if k == b"x-test-1"] + assert request_headers == matches_headers + await original_ws_request(scope, receive, send, msg, msg_history) + + mock_app.websocket_connect = custom_ws_request + + async with TestClient(mock_app) as client: + async with client.websocket_connect("/ws", headers=headers_dict) as ws: + await ws.send_text("hello there") + await ws.close(code=1000) + + +@pytest.mark.asyncio +async def test_testclient_lowercases_header_names(mock_app): + """ + headers (Iterable[[byte string, byte string]]) – An iterable of [name, value] + Header names should be lowercased, but it is not required; servers should preserve header case on a best-effort + basis. + https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope + """ + + # The spec isn't very clearly written on this; I *think* what it is saying, is if a header has multiple values with + # the same header name, the order of those values must be preserved...but that the overall order of headers with + # different names can change. This would match with implementations in eg Daphne. + + headers = [ + (b"X-TEST-1", b"2"), + ] + headers_dict = CIMultiDict( + [(k.decode("utf-8"), v.decode("utf-8")) for k, v in headers] + ) + + original_ws_request = mock_app.websocket_connect + + async def custom_ws_request(scope, receive, send, msg, msg_history): + # Check that the headers with the same name are still in the same order + request_header_keys = [k for k, v in scope["headers"]] + assert b"x-test-1" in request_header_keys + await original_ws_request(scope, receive, send, msg, msg_history) + + mock_app.websocket_connect = custom_ws_request + + async with TestClient(mock_app) as client: + async with client.websocket_connect("/ws", headers=headers_dict) as ws: + await ws.send_text("hello there") + await ws.close(code=1000) + + +@pytest.mark.xfail( + AssertionError, + reason="websocket session does not support receiving a close instead of accept", +) +@pytest.mark.asyncio +async def test_close_on_connect(mock_app): + """ + This message must be responded to with either an Accept message or a Close message + before the socket will pass websocket.receive messages. The protocol server must send + this message during the handshake phase of the WebSocket and not complete the handshake + until it gets a reply, returning HTTP status code 403 if the connection is denied. + https://asgi.readthedocs.io/en/latest/specs/www.html#connect-receive-event + """ + + async def custom_ws_connect(scope, receive, send, msg, msg_history): + await send({"type": "websocket.close"}) + return False + + mock_app.websocket_connect = custom_ws_connect + + async with TestClient(mock_app) as client: + async with client.websocket_connect("/ws") as ws: + # There should be a way to assert that the websocket session was closed + # In fact, what will actually happen is an AssertionError in websocket.py:132 + # because it asserts that the message type will always be websocket.accept, + # so it doesn't support receiving a websocket.close + await ws.send_text("we never reach this") + + +# - Subprotocols - proper support for subprotocols doesn't exist so we can test for it yet. +# (e.g. websocket_connect() might specify a list of allowed subprotocols, or a callback to check...) + +# - Test sending and receiving text/binary data in websockets + + +@pytest.mark.asyncio +async def test_sending_event_after_disconnect_is_ignored(mock_app): + """ + Note that messages received by a server after the connection has been closed are not + considered errors. In this case the send awaitable callable should act as a no-op. + https://asgi.readthedocs.io/en/latest/specs/main.html#error-handling + """ + + async with TestClient(mock_app) as client: + async with client.websocket_connect("/ws") as ws: + await ws.send_text("hello there") + await ws.close(code=1000) + await ws.send_text("this should be ignored") diff --git a/async_asgi_testclient/websocket.py b/async_asgi_testclient/websocket.py index 4f75024..3198383 100644 --- a/async_asgi_testclient/websocket.py +++ b/async_asgi_testclient/websocket.py @@ -115,6 +115,7 @@ async def connect(self): headers.add("Cookie", cookie_jar.output(header="")) scope = { + "asgi": {"version": "3.0"}, "type": "websocket", "headers": flatten_headers(headers), "path": path,