mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
New test files covering areas identified as gaps compared to Daphne and Uvicorn test coverage: - test_asgi_header_security.py: Header validation, normalization, injection prevention - test_asgi_error_handling.py: Application errors, body receiver errors, graceful shutdown - test_asgi_protocol_http.py: HTTP connection management, chunked encoding, methods, scope building - test_asgi_websocket_enhanced.py: WebSocket message limits, connection rejection, subprotocols - test_asgi_lifespan.py: Lifespan message formats and behavior - test_asgi_forwarded_headers.py: X-Forwarded-* and proxy header handling
512 lines
14 KiB
Python
512 lines
14 KiB
Python
#
|
|
# This file is part of gunicorn released under the MIT license.
|
|
# See the NOTICE for more information.
|
|
|
|
"""
|
|
ASGI HTTP protocol tests.
|
|
|
|
Tests for HTTP connection management, Expect: 100-continue,
|
|
body size handling, and chunked encoding per ASGI 3.0 and HTTP/1.1 specs.
|
|
"""
|
|
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from gunicorn.config import Config
|
|
from gunicorn.asgi.parser import (
|
|
PythonProtocol,
|
|
InvalidHeader,
|
|
ParseError,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# HTTP Connection Management Tests
|
|
# ============================================================================
|
|
|
|
class TestHTTPConnectionManagement:
|
|
"""Test HTTP connection keep-alive and close handling."""
|
|
|
|
def test_http11_keepalive_default(self):
|
|
"""HTTP/1.1 should use keep-alive by default."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"GET /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
# HTTP/1.1 defaults to keep-alive
|
|
# http_version is a tuple (major, minor)
|
|
assert parser.http_version == (1, 1)
|
|
|
|
def test_http10_version(self):
|
|
"""HTTP/1.0 should be parsed correctly."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"GET /test HTTP/1.0\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert parser.http_version == (1, 0)
|
|
|
|
def test_connection_close_header(self):
|
|
"""Connection: close header should be recognized."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"GET /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Connection: close\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
|
|
def test_connection_keepalive_header_http10(self):
|
|
"""Connection: keep-alive in HTTP/1.0 should be recognized."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"GET /test HTTP/1.0\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Connection: keep-alive\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
|
|
def test_connection_header_case_insensitive(self):
|
|
"""Connection header value should be case-insensitive."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"GET /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Connection: CLOSE\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
|
|
|
|
# ============================================================================
|
|
# Expect: 100-continue Tests
|
|
# ============================================================================
|
|
|
|
class TestExpectContinue:
|
|
"""Test Expect: 100-continue handling."""
|
|
|
|
def test_expect_continue_header_accepted(self):
|
|
"""Expect: 100-continue header should be accepted."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"POST /upload HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Content-Length: 1000\r\n"
|
|
b"Expect: 100-continue\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
# Parser should be waiting for body (not complete yet)
|
|
assert not parser.is_complete
|
|
|
|
def test_expect_header_case_insensitive(self):
|
|
"""Expect header value should be case-insensitive."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"POST /upload HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Content-Length: 100\r\n"
|
|
b"Expect: 100-Continue\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
# Parser should be waiting for body
|
|
assert not parser.is_complete
|
|
|
|
|
|
# ============================================================================
|
|
# Request Body Size Tests
|
|
# ============================================================================
|
|
|
|
class TestRequestBodySize:
|
|
"""Test request body size validation."""
|
|
|
|
def test_exact_content_length_body(self):
|
|
"""Body matching Content-Length should be accepted."""
|
|
body_chunks = []
|
|
parser = PythonProtocol(
|
|
on_body=lambda chunk: body_chunks.append(chunk),
|
|
)
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Content-Length: 5\r\n"
|
|
b"\r\n"
|
|
b"hello"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert b"".join(body_chunks) == b"hello"
|
|
|
|
def test_zero_content_length(self):
|
|
"""Zero Content-Length should have no body."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Content-Length: 0\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
|
|
def test_body_in_chunks(self):
|
|
"""Body can arrive in multiple chunks."""
|
|
body_chunks = []
|
|
parser = PythonProtocol(
|
|
on_body=lambda chunk: body_chunks.append(chunk),
|
|
)
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Content-Length: 10\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
# Feed body in chunks
|
|
parser.feed(b"12345")
|
|
parser.feed(b"67890")
|
|
|
|
assert parser.is_complete
|
|
assert b"".join(body_chunks) == b"1234567890"
|
|
|
|
|
|
# ============================================================================
|
|
# Chunked Encoding Tests
|
|
# ============================================================================
|
|
|
|
class TestChunkedEncoding:
|
|
"""Test chunked Transfer-Encoding handling."""
|
|
|
|
def test_chunked_encoding_single_chunk(self):
|
|
"""Single chunk with terminator should work."""
|
|
body_chunks = []
|
|
parser = PythonProtocol(
|
|
on_body=lambda chunk: body_chunks.append(chunk),
|
|
)
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Transfer-Encoding: chunked\r\n"
|
|
b"\r\n"
|
|
b"5\r\n"
|
|
b"hello\r\n"
|
|
b"0\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert parser.is_chunked
|
|
assert b"".join(body_chunks) == b"hello"
|
|
|
|
def test_chunked_encoding_multiple_chunks(self):
|
|
"""Multiple chunks should be concatenated."""
|
|
body_chunks = []
|
|
parser = PythonProtocol(
|
|
on_body=lambda chunk: body_chunks.append(chunk),
|
|
)
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Transfer-Encoding: chunked\r\n"
|
|
b"\r\n"
|
|
b"5\r\n"
|
|
b"hello\r\n"
|
|
b"6\r\n"
|
|
b" world\r\n"
|
|
b"0\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert b"".join(body_chunks) == b"hello world"
|
|
|
|
def test_chunked_encoding_empty_body(self):
|
|
"""Empty chunked body (just terminator) should work."""
|
|
body_chunks = []
|
|
parser = PythonProtocol(
|
|
on_body=lambda chunk: body_chunks.append(chunk),
|
|
)
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Transfer-Encoding: chunked\r\n"
|
|
b"\r\n"
|
|
b"0\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
# No body chunks or empty
|
|
assert b"".join(body_chunks) == b""
|
|
|
|
def test_chunked_encoding_with_trailer(self):
|
|
"""Chunked encoding with trailer headers."""
|
|
body_chunks = []
|
|
parser = PythonProtocol(
|
|
on_body=lambda chunk: body_chunks.append(chunk),
|
|
)
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Transfer-Encoding: chunked\r\n"
|
|
b"Trailer: X-Checksum\r\n"
|
|
b"\r\n"
|
|
b"5\r\n"
|
|
b"hello\r\n"
|
|
b"0\r\n"
|
|
b"X-Checksum: abc123\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert b"".join(body_chunks) == b"hello"
|
|
|
|
def test_chunked_hex_sizes(self):
|
|
"""Chunk sizes should be parsed as hex."""
|
|
body_chunks = []
|
|
parser = PythonProtocol(
|
|
on_body=lambda chunk: body_chunks.append(chunk),
|
|
)
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Transfer-Encoding: chunked\r\n"
|
|
b"\r\n"
|
|
b"a\r\n" # 10 in hex
|
|
b"0123456789\r\n"
|
|
b"0\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert b"".join(body_chunks) == b"0123456789"
|
|
|
|
def test_chunked_uppercase_hex(self):
|
|
"""Uppercase hex chunk sizes should work."""
|
|
body_chunks = []
|
|
parser = PythonProtocol(
|
|
on_body=lambda chunk: body_chunks.append(chunk),
|
|
)
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Transfer-Encoding: chunked\r\n"
|
|
b"\r\n"
|
|
b"A\r\n" # 10 in uppercase hex
|
|
b"0123456789\r\n"
|
|
b"0\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert b"".join(body_chunks) == b"0123456789"
|
|
|
|
|
|
# ============================================================================
|
|
# HEAD Request Tests
|
|
# ============================================================================
|
|
|
|
class TestHEADRequest:
|
|
"""Test HEAD request handling."""
|
|
|
|
def test_head_request_no_body(self):
|
|
"""HEAD request should have no body."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"HEAD /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
|
|
|
|
# ============================================================================
|
|
# HTTP Method Tests
|
|
# ============================================================================
|
|
|
|
class TestHTTPMethods:
|
|
"""Test HTTP method handling."""
|
|
|
|
def test_get_method(self):
|
|
"""GET method should be parsed."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"GET /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
# method is bytes in the parser
|
|
assert parser.method == b"GET"
|
|
|
|
def test_post_method(self):
|
|
"""POST method should be parsed."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"POST /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Content-Length: 0\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert parser.method == b"POST"
|
|
|
|
def test_put_method(self):
|
|
"""PUT method should be parsed."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"PUT /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"Content-Length: 0\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert parser.method == b"PUT"
|
|
|
|
def test_delete_method(self):
|
|
"""DELETE method should be parsed."""
|
|
parser = PythonProtocol()
|
|
|
|
parser.feed(
|
|
b"DELETE /test HTTP/1.1\r\n"
|
|
b"Host: localhost\r\n"
|
|
b"\r\n"
|
|
)
|
|
|
|
assert parser.is_complete
|
|
assert parser.method == b"DELETE"
|
|
|
|
|
|
# ============================================================================
|
|
# HTTP Scope Building Tests
|
|
# ============================================================================
|
|
|
|
class TestHTTPScopeBuilding:
|
|
"""Test building ASGI HTTP scope."""
|
|
|
|
def _create_protocol(self):
|
|
"""Create an ASGIProtocol instance for testing."""
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
|
|
worker = mock.Mock()
|
|
worker.cfg = Config()
|
|
worker.log = mock.Mock()
|
|
worker.asgi = mock.Mock()
|
|
|
|
return ASGIProtocol(worker)
|
|
|
|
def _create_mock_request(self, **kwargs):
|
|
"""Create a mock HTTP request."""
|
|
request = mock.Mock()
|
|
request.method = kwargs.get("method", "GET")
|
|
path = kwargs.get("path", "/")
|
|
request.path = path
|
|
request.raw_path = kwargs.get("raw_path", path.encode("latin-1"))
|
|
request.query = kwargs.get("query", "")
|
|
request.version = kwargs.get("version", (1, 1))
|
|
request.scheme = kwargs.get("scheme", "http")
|
|
request.headers = kwargs.get("headers", [])
|
|
return request
|
|
|
|
def test_scope_type_is_http(self):
|
|
"""Scope type should be 'http'."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert scope["type"] == "http"
|
|
|
|
def test_scope_method_uppercase(self):
|
|
"""Method in scope should be uppercase."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(method="POST")
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert scope["method"] == "POST"
|
|
|
|
def test_scope_path_percent_encoded(self):
|
|
"""Path with special characters should be handled."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(
|
|
path="/api/users/john%20doe",
|
|
raw_path=b"/api/users/john%20doe",
|
|
)
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert scope["raw_path"] == b"/api/users/john%20doe"
|
|
|
|
def test_scope_query_string_bytes(self):
|
|
"""Query string should be bytes."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request(query="page=1&size=10")
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert scope["query_string"] == b"page=1&size=10"
|
|
assert isinstance(scope["query_string"], bytes)
|
|
|
|
def test_scope_server_info(self):
|
|
"""Server info should be tuple of (host, port)."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(
|
|
request,
|
|
("127.0.0.1", 8000),
|
|
("192.168.1.1", 54321),
|
|
)
|
|
|
|
assert scope["server"] == ("127.0.0.1", 8000)
|
|
assert scope["client"] == ("192.168.1.1", 54321)
|
|
|
|
def test_scope_asgi_version(self):
|
|
"""ASGI version info should be present."""
|
|
protocol = self._create_protocol()
|
|
request = self._create_mock_request()
|
|
|
|
scope = protocol._build_http_scope(request, None, None)
|
|
|
|
assert "asgi" in scope
|
|
assert scope["asgi"]["version"] == "3.0"
|