From 0610b93859d4796ba069e7b894b9b235eb74e35f Mon Sep 17 00:00:00 2001 From: dans Date: Wed, 29 Dec 2021 14:47:04 +1100 Subject: [PATCH 1/9] Add tests for ASGI specification compliance There are a number of tests that currently xfail in this suite. The intention is to fix the issues one by one, or in cases where the issue is a WONTFIX, change the test and/or indicate as such in the xfail reason. There is still a need for a couple more tests in the websocket section, and probably could do with some more type-oriented negative tests (e.g. testing what happens if you send a message with the wrong type in the event values, like with 'status') --- .../tests/asgi_spec/conftest.py | 175 ++++++++ .../tests/asgi_spec/test_application.py | 170 ++++++++ .../tests/asgi_spec/test_http.py | 393 ++++++++++++++++++ .../tests/asgi_spec/test_lifespan.py | 170 ++++++++ .../tests/asgi_spec/test_websocket.py | 306 ++++++++++++++ 5 files changed, 1214 insertions(+) create mode 100644 async_asgi_testclient/tests/asgi_spec/conftest.py create mode 100644 async_asgi_testclient/tests/asgi_spec/test_application.py create mode 100644 async_asgi_testclient/tests/asgi_spec/test_http.py create mode 100644 async_asgi_testclient/tests/asgi_spec/test_lifespan.py create mode 100644 async_asgi_testclient/tests/asgi_spec/test_websocket.py 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..d256551 --- /dev/null +++ b/async_asgi_testclient/tests/asgi_spec/test_application.py @@ -0,0 +1,170 @@ +"""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 + + +@pytest.mark.xfail( + raises=AssertionError, reason="Custom scopes can pollute between calls" +) +@pytest.mark.asyncio +async def test_custom_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): + # lifespan protocol currently ignores the custom scope :-( + if scope["type"] == "lifespan": + return + assert "persisted" not in scope["custom"] + scope["custom"]["persisted"] = "this value should not persist across calls" + + mock_app.handle_all = handle_all + + async with TestClient(mock_app, scope={"custom": {"key": "value"}}) 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..5873130 --- /dev/null +++ b/async_asgi_testclient/tests/asgi_spec/test_http.py @@ -0,0 +1,393 @@ +"""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.xfail( + AssertionError, reason="TestClient does not uppercase scope['method']" +) +@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.xfail(reason="TestClient currently doesn't allow missing headers") +@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.xfail(reason="TestClient currently doesn't allow missing body key") +@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..f6bcfb1 --- /dev/null +++ b/async_asgi_testclient/tests/asgi_spec/test_lifespan.py @@ -0,0 +1,170 @@ +"""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 +""" + +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.xfail( + raises=RuntimeError, reason="TestClient does not support ignoring lifespan messages" +) +@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_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..656019b --- /dev/null +++ b/async_asgi_testclient/tests/asgi_spec/test_websocket.py @@ -0,0 +1,306 @@ +"""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.xfail( + AssertionError, reason="Websocket scope is missing the mandatory asgi key" +) +@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.xfail(KeyError, reason="Websocket scope is missing the mandatory asgi key") +@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") From b5f4d74b652e225963f0a393d116759941e0d687 Mon Sep 17 00:00:00 2001 From: dans Date: Wed, 29 Dec 2021 21:50:29 +1100 Subject: [PATCH 2/9] Allow http.response.body event with no 'body' key Fixes test_http:test_response_body_can_be_missing() --- async_asgi_testclient/testing.py | 2 +- async_asgi_testclient/tests/asgi_spec/test_http.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/async_asgi_testclient/testing.py b/async_asgi_testclient/testing.py index a54ff0f..d355503 100644 --- a/async_asgi_testclient/testing.py +++ b/async_asgi_testclient/testing.py @@ -265,7 +265,7 @@ async def open( # 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/test_http.py b/async_asgi_testclient/tests/asgi_spec/test_http.py index 5873130..0b8f293 100644 --- a/async_asgi_testclient/tests/asgi_spec/test_http.py +++ b/async_asgi_testclient/tests/asgi_spec/test_http.py @@ -328,7 +328,6 @@ async def custom_http_request(scope, receive, send, msg): await client.get("/") -@pytest.mark.xfail(reason="TestClient currently doesn't allow missing body key") @pytest.mark.asyncio async def test_response_body_can_be_missing(mock_app): """ From dbe96c49c6251d516552b8b1cdeafbee92c815a6 Mon Sep 17 00:00:00 2001 From: dans Date: Wed, 29 Dec 2021 21:55:33 +1100 Subject: [PATCH 3/9] Allow http.response.start event with no 'headers' key Fixes test_http:test_response_headers_can_be_missing() --- async_asgi_testclient/testing.py | 2 +- async_asgi_testclient/tests/asgi_spec/test_http.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/async_asgi_testclient/testing.py b/async_asgi_testclient/testing.py index d355503..c89947f 100644 --- a/async_asgi_testclient/testing.py +++ b/async_asgi_testclient/testing.py @@ -260,7 +260,7 @@ 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 diff --git a/async_asgi_testclient/tests/asgi_spec/test_http.py b/async_asgi_testclient/tests/asgi_spec/test_http.py index 0b8f293..4f87c78 100644 --- a/async_asgi_testclient/tests/asgi_spec/test_http.py +++ b/async_asgi_testclient/tests/asgi_spec/test_http.py @@ -262,7 +262,6 @@ async def custom_http_request(scope, receive, send, msg): assert resp.status_code == 200 -@pytest.mark.xfail(reason="TestClient currently doesn't allow missing headers") @pytest.mark.asyncio async def test_response_headers_can_be_missing(mock_app): """ From f655b856d1658b35d4b4642cbd72559adfd9a90d Mon Sep 17 00:00:00 2001 From: dans Date: Wed, 29 Dec 2021 22:01:12 +1100 Subject: [PATCH 4/9] Ensure websocket events include 'asgi' in scope Fixes test_websocket : test_asgi_version_is_present_in_websocket_scope() and test_websocket_spec_version_is_missing_or_correct() --- async_asgi_testclient/tests/asgi_spec/test_websocket.py | 4 ---- async_asgi_testclient/websocket.py | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/async_asgi_testclient/tests/asgi_spec/test_websocket.py b/async_asgi_testclient/tests/asgi_spec/test_websocket.py index 656019b..1d69de7 100644 --- a/async_asgi_testclient/tests/asgi_spec/test_websocket.py +++ b/async_asgi_testclient/tests/asgi_spec/test_websocket.py @@ -15,9 +15,6 @@ import pytest -@pytest.mark.xfail( - AssertionError, reason="Websocket scope is missing the mandatory asgi key" -) @pytest.mark.asyncio async def test_asgi_version_is_present_in_websocket_scope(mock_app): """ @@ -41,7 +38,6 @@ async def handle_all(scope, receive, send): await ws.close(code=1000) -@pytest.mark.xfail(KeyError, reason="Websocket scope is missing the mandatory asgi key") @pytest.mark.asyncio async def test_websocket_spec_version_is_missing_or_correct(mock_app): """ diff --git a/async_asgi_testclient/websocket.py b/async_asgi_testclient/websocket.py index 2788458..c5fae5f 100644 --- a/async_asgi_testclient/websocket.py +++ b/async_asgi_testclient/websocket.py @@ -113,6 +113,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, From 3ff2e15f962ef5357139e6d89c1325897db4a178 Mon Sep 17 00:00:00 2001 From: dans Date: Wed, 29 Dec 2021 22:49:55 +1100 Subject: [PATCH 5/9] Disable lifespan protocol if apps do not support it Fixes test_lifespan:test_lifespan_not_supported_is_allowed() This fix was somewhat challenging to achieve without breaking other things or backward compatibility issues. In the end, I think the most elegant solution is to adopt more Pythonic exceptions in the TestClient, as demonstrated in the commit. However, since this is a less trivial change, I have only used TestClientError in the lifespan related code, leaving all other code using raw Exceptions still. I think TestClientError (or possibly even multiple exception classes for different purposes) should actually be adopted throughout all the code, albeit with the caveat that it might cause some issues if anyone is doing really rigid exception assertions in their test code. For most cases (including my own code that is already using async_asgi_testclient) I believe the average 'with pytest.raises(Exception)' assertions should still work, since TestClientError is a sub-class of Exception. A side-benefit of the TestClientError is it would allow access to the original Message structure, instead of having to decode a string repr of the event message. --- async_asgi_testclient/exceptions.py | 17 +++++++++ async_asgi_testclient/testing.py | 38 ++++++++++++------- .../tests/asgi_spec/test_lifespan.py | 3 -- 3 files changed, 42 insertions(+), 16 deletions(-) create mode 100644 async_asgi_testclient/exceptions.py diff --git a/async_asgi_testclient/exceptions.py b/async_asgi_testclient/exceptions.py new file mode 100644 index 0000000..a02efe9 --- /dev/null +++ b/async_asgi_testclient/exceptions.py @@ -0,0 +1,17 @@ +"""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 typing import Optional + +from async_asgi_testclient.utils import Message + + +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 c89947f..8c75af3 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 @@ -78,33 +79,44 @@ 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. + 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, path, headers=None, cookies=None): return WebSocketSession(self, path, headers, cookies) diff --git a/async_asgi_testclient/tests/asgi_spec/test_lifespan.py b/async_asgi_testclient/tests/asgi_spec/test_lifespan.py index f6bcfb1..64c1e7c 100644 --- a/async_asgi_testclient/tests/asgi_spec/test_lifespan.py +++ b/async_asgi_testclient/tests/asgi_spec/test_lifespan.py @@ -38,9 +38,6 @@ async def handle_all(scope, receive, send): assert resp.status_code == 200 -@pytest.mark.xfail( - raises=RuntimeError, reason="TestClient does not support ignoring lifespan messages" -) @pytest.mark.asyncio async def test_lifespan_not_supported_is_allowed(mock_app): """ From 732b1e61269bbb4dfc5a5a2f0b4cf1953192689d Mon Sep 17 00:00:00 2001 From: dans Date: Wed, 29 Dec 2021 22:59:08 +1100 Subject: [PATCH 6/9] Remove test for custom scope Removed test for contamination between calls with a custom scope. Probably deserves documentation because it could be a gotcha for people, but it doesn't deserve to be in this test suite. --- .../tests/asgi_spec/test_application.py | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/async_asgi_testclient/tests/asgi_spec/test_application.py b/async_asgi_testclient/tests/asgi_spec/test_application.py index d256551..568461b 100644 --- a/async_asgi_testclient/tests/asgi_spec/test_application.py +++ b/async_asgi_testclient/tests/asgi_spec/test_application.py @@ -135,36 +135,3 @@ async def handle_all(scope, receive, send): assert resp.status_code == 200 resp = await client.get("/") assert resp.status_code == 200 - - -@pytest.mark.xfail( - raises=AssertionError, reason="Custom scopes can pollute between calls" -) -@pytest.mark.asyncio -async def test_custom_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): - # lifespan protocol currently ignores the custom scope :-( - if scope["type"] == "lifespan": - return - assert "persisted" not in scope["custom"] - scope["custom"]["persisted"] = "this value should not persist across calls" - - mock_app.handle_all = handle_all - - async with TestClient(mock_app, scope={"custom": {"key": "value"}}) as client: - resp = await client.get("/") - assert resp.status_code == 200 - resp = await client.get("/") - assert resp.status_code == 200 From 2037ec9bd0678a01de14519d283871a0f69fedd2 Mon Sep 17 00:00:00 2001 From: dans Date: Wed, 29 Dec 2021 23:20:28 +1100 Subject: [PATCH 7/9] Fix formatting Had a few issues with formatting and the black version, this commit cleans that up. --- async_asgi_testclient/exceptions.py | 3 +-- async_asgi_testclient/testing.py | 9 +++++++-- async_asgi_testclient/websocket.py | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/async_asgi_testclient/exceptions.py b/async_asgi_testclient/exceptions.py index a02efe9..5e0636c 100644 --- a/async_asgi_testclient/exceptions.py +++ b/async_asgi_testclient/exceptions.py @@ -4,9 +4,8 @@ (and in some cases, possible at all) to handle errors in different ways. """ -from typing import Optional - from async_asgi_testclient.utils import Message +from typing import Optional class TestClientError(Exception): diff --git a/async_asgi_testclient/testing.py b/async_asgi_testclient/testing.py index 8c75af3..bc18d3b 100644 --- a/async_asgi_testclient/testing.py +++ b/async_asgi_testclient/testing.py @@ -111,7 +111,9 @@ async def send_lifespan(self, action): message = await receive(self._lifespan_output_queue, timeout=self.timeout) if isinstance(message, Message): - raise TestClientError(f"{message.event} - {message.reason} - {message.task}", message=message) + raise TestClientError( + f"{message.event} - {message.reason} - {message.task}", message=message + ) if message["type"] == f"lifespan.{action}.complete": pass @@ -272,7 +274,10 @@ 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.get("headers", [])] + [ + (k.decode("utf8"), v.decode("utf8")) + for k, v in message.get("headers", []) + ] ) # Receive initial response body diff --git a/async_asgi_testclient/websocket.py b/async_asgi_testclient/websocket.py index c5fae5f..4b0adf8 100644 --- a/async_asgi_testclient/websocket.py +++ b/async_asgi_testclient/websocket.py @@ -113,7 +113,7 @@ async def connect(self): headers.add("Cookie", cookie_jar.output(header="")) scope = { - "asgi": {"version": "3.0", }, + "asgi": {"version": "3.0"}, "type": "websocket", "headers": flatten_headers(headers), "path": path, From 3f79e78d9c9ba77b9d05e685c5b38be820f5f184 Mon Sep 17 00:00:00 2001 From: Dan Sloan <827555+LucidDan@users.noreply.github.com> Date: Fri, 2 Jun 2023 16:23:58 +0100 Subject: [PATCH 8/9] Notify the user if lifespan protocol raised an exception Re: https://github.com/vinissimus/async-asgi-testclient/pull/50/files#r785889284 The asserts where we access the log message in the test is a bit rough, it could probably be refactored to be a little cleaner. --- async_asgi_testclient/testing.py | 3 +++ .../tests/asgi_spec/test_lifespan.py | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/async_asgi_testclient/testing.py b/async_asgi_testclient/testing.py index b2720cc..ef6622f 100644 --- a/async_asgi_testclient/testing.py +++ b/async_asgi_testclient/testing.py @@ -47,7 +47,9 @@ import asyncio import inspect import requests +import logging +logger = logging.getLogger(__name__) sentinel = object() @@ -97,6 +99,7 @@ async def __aenter__(self): 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 return self diff --git a/async_asgi_testclient/tests/asgi_spec/test_lifespan.py b/async_asgi_testclient/tests/asgi_spec/test_lifespan.py index 64c1e7c..5ec1037 100644 --- a/async_asgi_testclient/tests/asgi_spec/test_lifespan.py +++ b/async_asgi_testclient/tests/asgi_spec/test_lifespan.py @@ -7,6 +7,7 @@ the ASGI specification documented at https://asgi.readthedocs.io/en/latest/specs/main.html """ +import logging from async_asgi_testclient import TestClient @@ -54,6 +55,27 @@ async def test_lifespan_not_supported_is_allowed(mock_app): 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): """ From cbb610009c6460e347694ee93542f432395db36a Mon Sep 17 00:00:00 2001 From: Dan Sloan <827555+LucidDan@users.noreply.github.com> Date: Fri, 2 Jun 2023 16:35:29 +0100 Subject: [PATCH 9/9] Fix method is uppercased in HTTP --- async_asgi_testclient/testing.py | 2 +- async_asgi_testclient/tests/asgi_spec/test_http.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/async_asgi_testclient/testing.py b/async_asgi_testclient/testing.py index ef6622f..85f9d04 100644 --- a/async_asgi_testclient/testing.py +++ b/async_asgi_testclient/testing.py @@ -248,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, diff --git a/async_asgi_testclient/tests/asgi_spec/test_http.py b/async_asgi_testclient/tests/asgi_spec/test_http.py index 4f87c78..633340c 100644 --- a/async_asgi_testclient/tests/asgi_spec/test_http.py +++ b/async_asgi_testclient/tests/asgi_spec/test_http.py @@ -80,9 +80,6 @@ async def handle_all(scope, receive, send): assert resp.status_code == 200 -@pytest.mark.xfail( - AssertionError, reason="TestClient does not uppercase scope['method']" -) @pytest.mark.asyncio async def test_http_method_is_uppercased(mock_app): """