HTTP/3 Support (#2378)
This commit is contained in:
0
tests/http3/__init__.py
Normal file
0
tests/http3/__init__.py
Normal file
294
tests/http3/test_http_receiver.py
Normal file
294
tests/http3/test_http_receiver.py
Normal file
@@ -0,0 +1,294 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from aioquic.h3.connection import H3Connection
|
||||
from aioquic.h3.events import DataReceived, HeadersReceived
|
||||
from aioquic.quic.configuration import QuicConfiguration
|
||||
from aioquic.quic.connection import QuicConnection
|
||||
from aioquic.quic.events import ProtocolNegotiated
|
||||
|
||||
from sanic import Request, Sanic
|
||||
from sanic.compat import Header
|
||||
from sanic.config import DEFAULT_CONFIG
|
||||
from sanic.exceptions import PayloadTooLarge
|
||||
from sanic.http.constants import Stage
|
||||
from sanic.http.http3 import Http3, HTTPReceiver
|
||||
from sanic.models.server_types import ConnInfo
|
||||
from sanic.response import empty, json
|
||||
from sanic.server.protocols.http_protocol import Http3Protocol
|
||||
|
||||
|
||||
try:
|
||||
from unittest.mock import AsyncMock
|
||||
except ImportError:
|
||||
from tests.asyncmock import AsyncMock # type: ignore
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup(app: Sanic):
|
||||
@app.get("/")
|
||||
async def handler(*_):
|
||||
return empty()
|
||||
|
||||
app.router.finalize()
|
||||
app.signal_router.finalize()
|
||||
app.signal_router.allow_fail_builtin = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_request(app):
|
||||
return Request(b"/", Header({}), "3", "GET", Mock(), app)
|
||||
|
||||
|
||||
def generate_protocol(app):
|
||||
connection = QuicConnection(configuration=QuicConfiguration())
|
||||
connection._ack_delay = 0
|
||||
connection._loss = Mock()
|
||||
connection._loss.spaces = []
|
||||
connection._loss.get_loss_detection_time = lambda: None
|
||||
connection.datagrams_to_send = Mock(return_value=[]) # type: ignore
|
||||
return Http3Protocol(
|
||||
connection,
|
||||
app=app,
|
||||
stream_handler=None,
|
||||
)
|
||||
|
||||
|
||||
def generate_http_receiver(app, http_request) -> HTTPReceiver:
|
||||
protocol = generate_protocol(app)
|
||||
receiver = HTTPReceiver(
|
||||
protocol.transmit,
|
||||
protocol,
|
||||
http_request,
|
||||
)
|
||||
http_request.stream = receiver
|
||||
return receiver
|
||||
|
||||
|
||||
def test_http_receiver_init(app: Sanic, http_request: Request):
|
||||
receiver = generate_http_receiver(app, http_request)
|
||||
assert receiver.request_body is None
|
||||
assert receiver.stage is Stage.IDLE
|
||||
assert receiver.headers_sent is False
|
||||
assert receiver.response is None
|
||||
assert receiver.request_max_size == DEFAULT_CONFIG["REQUEST_MAX_SIZE"]
|
||||
assert receiver.request_bytes == 0
|
||||
|
||||
|
||||
async def test_http_receiver_run_request(app: Sanic, http_request: Request):
|
||||
handler = AsyncMock()
|
||||
|
||||
class mock_handle(Sanic):
|
||||
handle_request = handler
|
||||
|
||||
app.__class__ = mock_handle
|
||||
receiver = generate_http_receiver(app, http_request)
|
||||
receiver.protocol.quic_event_received(
|
||||
ProtocolNegotiated(alpn_protocol="h3")
|
||||
)
|
||||
await receiver.run()
|
||||
handler.assert_awaited_once_with(receiver.request)
|
||||
|
||||
|
||||
async def test_http_receiver_run_exception(app: Sanic, http_request: Request):
|
||||
handler = AsyncMock()
|
||||
|
||||
class mock_handle(Sanic):
|
||||
handle_exception = handler
|
||||
|
||||
app.__class__ = mock_handle
|
||||
receiver = generate_http_receiver(app, http_request)
|
||||
receiver.protocol.quic_event_received(
|
||||
ProtocolNegotiated(alpn_protocol="h3")
|
||||
)
|
||||
exception = Exception("Oof")
|
||||
await receiver.run(exception)
|
||||
handler.assert_awaited_once_with(receiver.request, exception)
|
||||
|
||||
handler.reset_mock()
|
||||
receiver.stage = Stage.REQUEST
|
||||
await receiver.run(exception)
|
||||
handler.assert_awaited_once_with(receiver.request, exception)
|
||||
|
||||
|
||||
def test_http_receiver_respond(app: Sanic, http_request: Request):
|
||||
receiver = generate_http_receiver(app, http_request)
|
||||
response = empty()
|
||||
|
||||
receiver.stage = Stage.RESPONSE
|
||||
with pytest.raises(RuntimeError, match="Response already started"):
|
||||
receiver.respond(response)
|
||||
|
||||
receiver.stage = Stage.HANDLER
|
||||
receiver.response = Mock()
|
||||
resp = receiver.respond(response)
|
||||
|
||||
assert receiver.response is resp
|
||||
assert resp is response
|
||||
assert response.stream is receiver
|
||||
|
||||
|
||||
def test_http_receiver_receive_body(app: Sanic, http_request: Request):
|
||||
receiver = generate_http_receiver(app, http_request)
|
||||
receiver.request_max_size = 4
|
||||
|
||||
receiver.receive_body(b"..")
|
||||
assert receiver.request.body == b".."
|
||||
|
||||
receiver.receive_body(b"..")
|
||||
assert receiver.request.body == b"...."
|
||||
|
||||
with pytest.raises(
|
||||
PayloadTooLarge, match="Request body exceeds the size limit"
|
||||
):
|
||||
receiver.receive_body(b"..")
|
||||
|
||||
|
||||
def test_http3_events(app):
|
||||
protocol = generate_protocol(app)
|
||||
http3 = Http3(protocol, protocol.transmit)
|
||||
http3.http_event_received(
|
||||
HeadersReceived(
|
||||
[
|
||||
(b":method", b"GET"),
|
||||
(b":path", b"/location"),
|
||||
(b":scheme", b"https"),
|
||||
(b":authority", b"localhost:8443"),
|
||||
(b"foo", b"bar"),
|
||||
],
|
||||
1,
|
||||
False,
|
||||
)
|
||||
)
|
||||
http3.http_event_received(DataReceived(b"foobar", 1, False))
|
||||
receiver = http3.receivers[1]
|
||||
|
||||
assert len(http3.receivers) == 1
|
||||
assert receiver.request.stream_id == 1
|
||||
assert receiver.request.path == "/location"
|
||||
assert receiver.request.method == "GET"
|
||||
assert receiver.request.headers["foo"] == "bar"
|
||||
assert receiver.request.body == b"foobar"
|
||||
|
||||
|
||||
async def test_send_headers(app: Sanic, http_request: Request):
|
||||
send_headers_mock = Mock()
|
||||
existing_send_headers = H3Connection.send_headers
|
||||
receiver = generate_http_receiver(app, http_request)
|
||||
receiver.protocol.quic_event_received(
|
||||
ProtocolNegotiated(alpn_protocol="h3")
|
||||
)
|
||||
|
||||
http_request._protocol = receiver.protocol
|
||||
|
||||
def send_headers(*args, **kwargs):
|
||||
send_headers_mock(*args, **kwargs)
|
||||
return existing_send_headers(
|
||||
receiver.protocol.connection, *args, **kwargs
|
||||
)
|
||||
|
||||
receiver.protocol.connection.send_headers = send_headers
|
||||
receiver.head_only = False
|
||||
response = json({}, status=201, headers={"foo": "bar"})
|
||||
|
||||
with pytest.raises(RuntimeError, match="no response"):
|
||||
receiver.send_headers()
|
||||
|
||||
receiver.response = response
|
||||
receiver.send_headers()
|
||||
|
||||
assert receiver.headers_sent
|
||||
assert receiver.stage is Stage.RESPONSE
|
||||
send_headers_mock.assert_called_once_with(
|
||||
stream_id=0,
|
||||
headers=[
|
||||
(b":status", b"201"),
|
||||
(b"foo", b"bar"),
|
||||
(b"content-length", b"2"),
|
||||
(b"content-type", b"application/json"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_multiple_streams(app):
|
||||
protocol = generate_protocol(app)
|
||||
http3 = Http3(protocol, protocol.transmit)
|
||||
http3.http_event_received(
|
||||
HeadersReceived(
|
||||
[
|
||||
(b":method", b"GET"),
|
||||
(b":path", b"/location"),
|
||||
(b":scheme", b"https"),
|
||||
(b":authority", b"localhost:8443"),
|
||||
(b"foo", b"bar"),
|
||||
],
|
||||
1,
|
||||
False,
|
||||
)
|
||||
)
|
||||
http3.http_event_received(
|
||||
HeadersReceived(
|
||||
[
|
||||
(b":method", b"GET"),
|
||||
(b":path", b"/location"),
|
||||
(b":scheme", b"https"),
|
||||
(b":authority", b"localhost:8443"),
|
||||
(b"foo", b"bar"),
|
||||
],
|
||||
2,
|
||||
False,
|
||||
)
|
||||
)
|
||||
|
||||
receiver1 = http3.get_receiver_by_stream_id(1)
|
||||
receiver2 = http3.get_receiver_by_stream_id(2)
|
||||
assert len(http3.receivers) == 2
|
||||
assert isinstance(receiver1, HTTPReceiver)
|
||||
assert isinstance(receiver2, HTTPReceiver)
|
||||
assert receiver1 is not receiver2
|
||||
|
||||
|
||||
def test_request_stream_id(app):
|
||||
protocol = generate_protocol(app)
|
||||
http3 = Http3(protocol, protocol.transmit)
|
||||
http3.http_event_received(
|
||||
HeadersReceived(
|
||||
[
|
||||
(b":method", b"GET"),
|
||||
(b":path", b"/location"),
|
||||
(b":scheme", b"https"),
|
||||
(b":authority", b"localhost:8443"),
|
||||
(b"foo", b"bar"),
|
||||
],
|
||||
1,
|
||||
False,
|
||||
)
|
||||
)
|
||||
receiver = http3.get_receiver_by_stream_id(1)
|
||||
|
||||
assert isinstance(receiver.request, Request)
|
||||
assert receiver.request.stream_id == 1
|
||||
|
||||
|
||||
def test_request_conn_info(app):
|
||||
protocol = generate_protocol(app)
|
||||
http3 = Http3(protocol, protocol.transmit)
|
||||
http3.http_event_received(
|
||||
HeadersReceived(
|
||||
[
|
||||
(b":method", b"GET"),
|
||||
(b":path", b"/location"),
|
||||
(b":scheme", b"https"),
|
||||
(b":authority", b"localhost:8443"),
|
||||
(b"foo", b"bar"),
|
||||
],
|
||||
1,
|
||||
False,
|
||||
)
|
||||
)
|
||||
receiver = http3.get_receiver_by_stream_id(1)
|
||||
|
||||
assert isinstance(receiver.request.conn_info, ConnInfo)
|
||||
114
tests/http3/test_server.py
Normal file
114
tests/http3/test_server.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from asyncio import Event
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.compat import UVLOOP_INSTALLED
|
||||
from sanic.http.constants import HTTP
|
||||
|
||||
|
||||
parent_dir = Path(__file__).parent.parent
|
||||
localhost_dir = parent_dir / "certs/localhost"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", (3, HTTP.VERSION_3))
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 8) and not UVLOOP_INSTALLED,
|
||||
reason="In 3.7 w/o uvloop the port is not always released",
|
||||
)
|
||||
def test_server_starts_http3(app: Sanic, version, caplog):
|
||||
ev = Event()
|
||||
|
||||
@app.after_server_start
|
||||
def shutdown(*_):
|
||||
ev.set()
|
||||
app.stop()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
app.run(
|
||||
version=version,
|
||||
ssl={
|
||||
"cert": localhost_dir / "fullchain.pem",
|
||||
"key": localhost_dir / "privkey.pem",
|
||||
},
|
||||
)
|
||||
|
||||
assert ev.is_set()
|
||||
assert (
|
||||
"sanic.root",
|
||||
logging.INFO,
|
||||
"server: sanic, HTTP/3",
|
||||
) in caplog.record_tuples
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 8) and not UVLOOP_INSTALLED,
|
||||
reason="In 3.7 w/o uvloop the port is not always released",
|
||||
)
|
||||
def test_server_starts_http1_and_http3(app: Sanic, caplog):
|
||||
@app.after_server_start
|
||||
def shutdown(*_):
|
||||
app.stop()
|
||||
|
||||
app.prepare(
|
||||
version=3,
|
||||
ssl={
|
||||
"cert": localhost_dir / "fullchain.pem",
|
||||
"key": localhost_dir / "privkey.pem",
|
||||
},
|
||||
)
|
||||
app.prepare(
|
||||
version=1,
|
||||
ssl={
|
||||
"cert": localhost_dir / "fullchain.pem",
|
||||
"key": localhost_dir / "privkey.pem",
|
||||
},
|
||||
)
|
||||
with caplog.at_level(logging.INFO):
|
||||
Sanic.serve()
|
||||
|
||||
assert (
|
||||
"sanic.root",
|
||||
logging.INFO,
|
||||
"server: sanic, HTTP/1.1",
|
||||
) in caplog.record_tuples
|
||||
assert (
|
||||
"sanic.root",
|
||||
logging.INFO,
|
||||
"server: sanic, HTTP/3",
|
||||
) in caplog.record_tuples
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 8) and not UVLOOP_INSTALLED,
|
||||
reason="In 3.7 w/o uvloop the port is not always released",
|
||||
)
|
||||
def test_server_starts_http1_and_http3_bad_order(app: Sanic, caplog):
|
||||
@app.after_server_start
|
||||
def shutdown(*_):
|
||||
app.stop()
|
||||
|
||||
app.prepare(
|
||||
version=1,
|
||||
ssl={
|
||||
"cert": localhost_dir / "fullchain.pem",
|
||||
"key": localhost_dir / "privkey.pem",
|
||||
},
|
||||
)
|
||||
message = (
|
||||
"Serving HTTP/3 instances as a secondary server is not supported. "
|
||||
"There can only be a single HTTP/3 worker and it must be the first "
|
||||
"instance prepared."
|
||||
)
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
app.prepare(
|
||||
version=3,
|
||||
ssl={
|
||||
"cert": localhost_dir / "fullchain.pem",
|
||||
"key": localhost_dir / "privkey.pem",
|
||||
},
|
||||
)
|
||||
46
tests/http3/test_session_ticket_store.py
Normal file
46
tests/http3/test_session_ticket_store.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aioquic.tls import CipherSuite, SessionTicket
|
||||
|
||||
from sanic.http.http3 import SessionTicketStore
|
||||
|
||||
|
||||
def _generate_ticket(label):
|
||||
return SessionTicket(
|
||||
1,
|
||||
CipherSuite.AES_128_GCM_SHA256,
|
||||
datetime.now(),
|
||||
datetime.now(),
|
||||
label,
|
||||
label.decode(),
|
||||
label,
|
||||
None,
|
||||
[],
|
||||
)
|
||||
|
||||
|
||||
def test_session_ticket_store():
|
||||
store = SessionTicketStore()
|
||||
|
||||
assert len(store.tickets) == 0
|
||||
|
||||
ticket1 = _generate_ticket(b"foo")
|
||||
store.add(ticket1)
|
||||
|
||||
assert len(store.tickets) == 1
|
||||
|
||||
ticket2 = _generate_ticket(b"bar")
|
||||
store.add(ticket2)
|
||||
|
||||
assert len(store.tickets) == 2
|
||||
assert len(store.tickets) == 2
|
||||
|
||||
popped2 = store.pop(ticket2.ticket)
|
||||
|
||||
assert len(store.tickets) == 1
|
||||
assert popped2 is ticket2
|
||||
|
||||
popped1 = store.pop(ticket1.ticket)
|
||||
|
||||
assert len(store.tickets) == 0
|
||||
assert popped1 is ticket1
|
||||
Reference in New Issue
Block a user