mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
RFC 9110 forbids a body for HEAD requests and for 1xx/204/304 status codes. PR #3614 stopped gunicorn from auto-applying chunked encoding in those cases, but if the application explicitly emitted a Content-Length or Transfer-Encoding header (and possibly body bytes), gunicorn still passed them through. Now strip both headers, force plain framing, and discard any body the app emits.
542 lines
18 KiB
Python
542 lines
18 KiB
Python
#
|
|
# This file is part of gunicorn released under the MIT license.
|
|
# See the NOTICE for more information.
|
|
|
|
"""
|
|
ASGI streaming response tests.
|
|
|
|
Tests for chunked transfer encoding, Server-Sent Events (SSE),
|
|
and streaming response handling.
|
|
"""
|
|
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from gunicorn.config import Config
|
|
|
|
|
|
# ============================================================================
|
|
# Chunked Transfer Encoding Tests
|
|
# ============================================================================
|
|
|
|
class TestChunkedTransferEncoding:
|
|
"""Tests for HTTP/1.1 chunked transfer encoding."""
|
|
|
|
def test_chunked_encoding_format(self):
|
|
"""Test chunked encoding format: size in hex + CRLF + data + CRLF."""
|
|
body = b"Hello"
|
|
chunk = f"{len(body):x}\r\n".encode("latin-1") + body + b"\r\n"
|
|
|
|
assert chunk == b"5\r\nHello\r\n"
|
|
|
|
def test_chunked_encoding_large_chunk(self):
|
|
"""Test chunked encoding with larger data."""
|
|
body = b"x" * 1000
|
|
chunk = f"{len(body):x}\r\n".encode("latin-1") + body + b"\r\n"
|
|
|
|
# 1000 in hex is 3e8
|
|
assert chunk.startswith(b"3e8\r\n")
|
|
assert chunk.endswith(b"\r\n")
|
|
|
|
def test_chunked_encoding_terminal_chunk(self):
|
|
"""Test terminal chunk (zero-length)."""
|
|
terminal = b"0\r\n\r\n"
|
|
|
|
# Parse it
|
|
assert terminal == b"0\r\n\r\n"
|
|
|
|
def test_chunked_encoding_empty_chunk(self):
|
|
"""Test encoding empty body chunk."""
|
|
body = b""
|
|
chunk = f"{len(body):x}\r\n".encode("latin-1") + body + b"\r\n"
|
|
|
|
assert chunk == b"0\r\n\r\n"
|
|
|
|
def test_chunked_encoding_multiple_chunks(self):
|
|
"""Test multiple chunks in sequence."""
|
|
chunks = []
|
|
|
|
# First chunk
|
|
body1 = b"Hello, "
|
|
chunks.append(f"{len(body1):x}\r\n".encode() + body1 + b"\r\n")
|
|
|
|
# Second chunk
|
|
body2 = b"World!"
|
|
chunks.append(f"{len(body2):x}\r\n".encode() + body2 + b"\r\n")
|
|
|
|
# Terminal chunk
|
|
chunks.append(b"0\r\n\r\n")
|
|
|
|
full_response = b"".join(chunks)
|
|
|
|
assert b"7\r\nHello, \r\n" in full_response
|
|
assert b"6\r\nWorld!\r\n" in full_response
|
|
assert full_response.endswith(b"0\r\n\r\n")
|
|
|
|
|
|
# ============================================================================
|
|
# ASGI Streaming Response Tests
|
|
# ============================================================================
|
|
|
|
class TestASGIStreamingResponse:
|
|
"""Tests for ASGI streaming response handling."""
|
|
|
|
def test_streaming_response_more_body_true(self):
|
|
"""Test streaming response with more_body=True."""
|
|
messages = [
|
|
{
|
|
"type": "http.response.body",
|
|
"body": b"chunk1",
|
|
"more_body": True,
|
|
},
|
|
{
|
|
"type": "http.response.body",
|
|
"body": b"chunk2",
|
|
"more_body": True,
|
|
},
|
|
{
|
|
"type": "http.response.body",
|
|
"body": b"chunk3",
|
|
"more_body": False,
|
|
},
|
|
]
|
|
|
|
assert messages[0]["more_body"] is True
|
|
assert messages[1]["more_body"] is True
|
|
assert messages[2]["more_body"] is False
|
|
|
|
def test_streaming_response_empty_final_chunk(self):
|
|
"""Test streaming response with empty final chunk."""
|
|
final_message = {
|
|
"type": "http.response.body",
|
|
"body": b"",
|
|
"more_body": False,
|
|
}
|
|
|
|
assert final_message["body"] == b""
|
|
assert final_message["more_body"] is False
|
|
|
|
def test_response_start_without_content_length(self):
|
|
"""Test response start without Content-Length triggers chunked encoding."""
|
|
# When Content-Length is missing, HTTP/1.1 should use chunked encoding
|
|
message = {
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [
|
|
(b"content-type", b"text/plain"),
|
|
# No content-length header
|
|
],
|
|
}
|
|
|
|
# Check no content-length in headers
|
|
header_names = [name.lower() for name, _ in message["headers"]]
|
|
assert b"content-length" not in header_names
|
|
|
|
|
|
# ============================================================================
|
|
# Server-Sent Events (SSE) Format Tests
|
|
# ============================================================================
|
|
|
|
class TestSSEFormat:
|
|
"""Tests for Server-Sent Events format."""
|
|
|
|
def test_sse_data_event(self):
|
|
"""Test SSE data event format."""
|
|
data = "Hello, SSE!"
|
|
event = f"data: {data}\n\n"
|
|
|
|
assert event == "data: Hello, SSE!\n\n"
|
|
|
|
def test_sse_named_event(self):
|
|
"""Test SSE named event format."""
|
|
event_name = "message"
|
|
data = "Hello"
|
|
event = f"event: {event_name}\ndata: {data}\n\n"
|
|
|
|
assert "event: message\n" in event
|
|
assert "data: Hello\n" in event
|
|
assert event.endswith("\n\n")
|
|
|
|
def test_sse_event_with_id(self):
|
|
"""Test SSE event with ID."""
|
|
event_id = "12345"
|
|
data = "Some data"
|
|
event = f"id: {event_id}\ndata: {data}\n\n"
|
|
|
|
assert "id: 12345\n" in event
|
|
|
|
def test_sse_multiline_data(self):
|
|
"""Test SSE multiline data."""
|
|
lines = ["line1", "line2", "line3"]
|
|
data_lines = "\n".join(f"data: {line}" for line in lines)
|
|
event = f"{data_lines}\n\n"
|
|
|
|
assert event == "data: line1\ndata: line2\ndata: line3\n\n"
|
|
|
|
def test_sse_retry_directive(self):
|
|
"""Test SSE retry directive."""
|
|
retry_ms = 3000
|
|
directive = f"retry: {retry_ms}\n\n"
|
|
|
|
assert directive == "retry: 3000\n\n"
|
|
|
|
def test_sse_comment(self):
|
|
"""Test SSE comment (keep-alive)."""
|
|
comment = ": keep-alive\n\n"
|
|
|
|
assert comment.startswith(":")
|
|
|
|
def test_sse_content_type(self):
|
|
"""Test SSE Content-Type header."""
|
|
headers = [
|
|
(b"content-type", b"text/event-stream"),
|
|
(b"cache-control", b"no-cache"),
|
|
(b"connection", b"keep-alive"),
|
|
]
|
|
|
|
content_type = dict(headers).get(b"content-type")
|
|
assert content_type == b"text/event-stream"
|
|
|
|
|
|
# ============================================================================
|
|
# Protocol Send Body Tests
|
|
# ============================================================================
|
|
|
|
class TestProtocolSendBody:
|
|
"""Tests for ASGIProtocol._send_body method."""
|
|
|
|
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()
|
|
|
|
protocol = ASGIProtocol(worker)
|
|
protocol.transport = mock.Mock()
|
|
|
|
return protocol
|
|
|
|
def test_send_body_without_chunking(self):
|
|
"""Test sending body without chunked encoding."""
|
|
protocol = self._create_protocol()
|
|
|
|
protocol._send_body(b"Hello, World!", chunked=False)
|
|
|
|
protocol.transport.write.assert_called_once_with(b"Hello, World!")
|
|
|
|
def test_send_body_with_chunking(self):
|
|
"""Test sending body with chunked encoding."""
|
|
protocol = self._create_protocol()
|
|
|
|
protocol._send_body(b"Hello", chunked=True)
|
|
|
|
# Should write: "5\r\nHello\r\n"
|
|
protocol.transport.write.assert_called_once()
|
|
call_arg = protocol.transport.write.call_args[0][0]
|
|
assert call_arg == b"5\r\nHello\r\n"
|
|
|
|
def test_send_body_empty_without_chunking(self):
|
|
"""Test sending empty body without chunked encoding."""
|
|
protocol = self._create_protocol()
|
|
|
|
protocol._send_body(b"", chunked=False)
|
|
|
|
# Empty body should not write anything
|
|
protocol.transport.write.assert_not_called()
|
|
|
|
def test_send_body_empty_with_chunking(self):
|
|
"""Test sending empty body with chunked encoding."""
|
|
protocol = self._create_protocol()
|
|
|
|
protocol._send_body(b"", chunked=True)
|
|
|
|
# Empty body should not write (terminal chunk handled separately)
|
|
protocol.transport.write.assert_not_called()
|
|
|
|
|
|
# ============================================================================
|
|
# Content-Length Detection Tests
|
|
# ============================================================================
|
|
|
|
class TestContentLengthDetection:
|
|
"""Tests for Content-Length header detection."""
|
|
|
|
def test_has_content_length_bytes(self):
|
|
"""Test detecting Content-Length header (bytes)."""
|
|
headers = [
|
|
(b"content-type", b"text/plain"),
|
|
(b"content-length", b"100"),
|
|
]
|
|
|
|
has_cl = any(
|
|
name.lower() == b"content-length"
|
|
for name, _ in headers
|
|
)
|
|
assert has_cl is True
|
|
|
|
def test_has_content_length_string(self):
|
|
"""Test detecting Content-Length header (string)."""
|
|
headers = [
|
|
("content-type", "text/plain"),
|
|
("content-length", "100"),
|
|
]
|
|
|
|
has_cl = any(
|
|
name.lower() == "content-length"
|
|
for name, _ in headers
|
|
)
|
|
assert has_cl is True
|
|
|
|
def test_no_content_length(self):
|
|
"""Test when Content-Length is missing."""
|
|
headers = [
|
|
(b"content-type", b"text/plain"),
|
|
]
|
|
|
|
has_cl = any(
|
|
name.lower() == b"content-length"
|
|
for name, _ in headers
|
|
)
|
|
assert has_cl is False
|
|
|
|
def test_content_length_case_insensitive(self):
|
|
"""Test Content-Length detection is case-insensitive."""
|
|
headers = [
|
|
(b"Content-Length", b"100"),
|
|
]
|
|
|
|
has_cl = any(
|
|
name.lower() == b"content-length"
|
|
for name, _ in headers
|
|
)
|
|
assert has_cl is True
|
|
|
|
|
|
# ============================================================================
|
|
# HTTP Version Check for Chunked Encoding
|
|
# ============================================================================
|
|
|
|
class TestHTTPVersionForChunked:
|
|
"""Tests for HTTP version requirements for chunked encoding."""
|
|
|
|
def test_http11_supports_chunked(self):
|
|
"""Test HTTP/1.1 supports chunked encoding."""
|
|
version = (1, 1)
|
|
supports_chunked = version >= (1, 1)
|
|
assert supports_chunked is True
|
|
|
|
def test_http10_no_chunked(self):
|
|
"""Test HTTP/1.0 does not support chunked encoding."""
|
|
version = (1, 0)
|
|
supports_chunked = version >= (1, 1)
|
|
assert supports_chunked is False
|
|
|
|
def test_http2_no_chunked(self):
|
|
"""Test HTTP/2 doesn't use chunked encoding (uses framing)."""
|
|
# HTTP/2 has its own framing mechanism
|
|
version = (2, 0)
|
|
# Chunked encoding is not used in HTTP/2
|
|
uses_http1_chunked = version[0] == 1 and version >= (1, 1)
|
|
assert uses_http1_chunked is False
|
|
|
|
|
|
# ============================================================================
|
|
# No-Body Response Tests (RFC 9110)
|
|
# ============================================================================
|
|
|
|
class TestResponseOmitsBody:
|
|
"""Verify HEAD/1xx/204/304 are flagged as bodyless responses."""
|
|
|
|
def _omits(self, method, status):
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
return ASGIProtocol._response_omits_body(method, status)
|
|
|
|
def test_head_omits_body(self):
|
|
assert self._omits("HEAD", 200) is True
|
|
assert self._omits("HEAD", 500) is True
|
|
|
|
def test_204_omits_body(self):
|
|
assert self._omits("GET", 204) is True
|
|
assert self._omits("POST", 204) is True
|
|
|
|
def test_304_omits_body(self):
|
|
assert self._omits("GET", 304) is True
|
|
|
|
def test_informational_omits_body(self):
|
|
assert self._omits("GET", 100) is True
|
|
assert self._omits("GET", 103) is True
|
|
assert self._omits("GET", 199) is True
|
|
|
|
def test_get_200_has_body(self):
|
|
assert self._omits("GET", 200) is False
|
|
|
|
def test_post_200_has_body(self):
|
|
assert self._omits("POST", 200) is False
|
|
|
|
def test_404_has_body(self):
|
|
assert self._omits("GET", 404) is False
|
|
|
|
|
|
class TestStripBodyFramingHeaders:
|
|
"""Verify Content-Length and Transfer-Encoding are stripped for no-body
|
|
responses, regardless of header name casing or bytes/str typing."""
|
|
|
|
def _strip(self, headers):
|
|
from gunicorn.asgi.protocol import ASGIProtocol
|
|
return ASGIProtocol._strip_body_framing_headers(headers)
|
|
|
|
def test_strips_lowercase_bytes(self):
|
|
result = self._strip([
|
|
(b"content-type", b"text/plain"),
|
|
(b"content-length", b"5"),
|
|
(b"transfer-encoding", b"chunked"),
|
|
])
|
|
assert result == [(b"content-type", b"text/plain")]
|
|
|
|
def test_strips_mixed_case_str(self):
|
|
result = self._strip([
|
|
("Content-Type", "text/plain"),
|
|
("Content-Length", "5"),
|
|
("Transfer-Encoding", "chunked"),
|
|
])
|
|
assert result == [("Content-Type", "text/plain")]
|
|
|
|
def test_preserves_unrelated_headers(self):
|
|
headers = [
|
|
(b"x-custom", b"value"),
|
|
(b"server", b"gunicorn"),
|
|
]
|
|
assert self._strip(headers) == headers
|
|
|
|
|
|
# ============================================================================
|
|
# Streaming Response Message Sequence Tests
|
|
# ============================================================================
|
|
|
|
class TestStreamingMessageSequence:
|
|
"""Tests for valid streaming response message sequences."""
|
|
|
|
def test_valid_sequence_single_body(self):
|
|
"""Test valid sequence: start -> body (more_body=False)."""
|
|
messages = [
|
|
{"type": "http.response.start", "status": 200, "headers": []},
|
|
{"type": "http.response.body", "body": b"Hello", "more_body": False},
|
|
]
|
|
|
|
# First message should be start
|
|
assert messages[0]["type"] == "http.response.start"
|
|
# Last body message should have more_body=False
|
|
assert messages[-1]["type"] == "http.response.body"
|
|
assert messages[-1]["more_body"] is False
|
|
|
|
def test_valid_sequence_multiple_bodies(self):
|
|
"""Test valid sequence: start -> body (more=True) -> body (more=False)."""
|
|
messages = [
|
|
{"type": "http.response.start", "status": 200, "headers": []},
|
|
{"type": "http.response.body", "body": b"chunk1", "more_body": True},
|
|
{"type": "http.response.body", "body": b"chunk2", "more_body": True},
|
|
{"type": "http.response.body", "body": b"", "more_body": False},
|
|
]
|
|
|
|
# Verify sequence
|
|
assert messages[0]["type"] == "http.response.start"
|
|
assert all(m["more_body"] for m in messages[1:-1])
|
|
assert messages[-1]["more_body"] is False
|
|
|
|
def test_valid_sequence_with_informational(self):
|
|
"""Test valid sequence with informational response."""
|
|
messages = [
|
|
{
|
|
"type": "http.response.informational",
|
|
"status": 103,
|
|
"headers": [(b"link", b"</style.css>; rel=preload")],
|
|
},
|
|
{"type": "http.response.start", "status": 200, "headers": []},
|
|
{"type": "http.response.body", "body": b"Hello", "more_body": False},
|
|
]
|
|
|
|
# Informational before start is valid
|
|
assert messages[0]["type"] == "http.response.informational"
|
|
assert messages[1]["type"] == "http.response.start"
|
|
|
|
|
|
# ============================================================================
|
|
# Large Response Tests
|
|
# ============================================================================
|
|
|
|
class TestLargeResponses:
|
|
"""Tests for handling large responses."""
|
|
|
|
def test_chunk_size_encoding(self):
|
|
"""Test chunk size encoding for various sizes."""
|
|
test_cases = [
|
|
(1, b"1\r\n"),
|
|
(10, b"a\r\n"),
|
|
(15, b"f\r\n"),
|
|
(16, b"10\r\n"),
|
|
(255, b"ff\r\n"),
|
|
(256, b"100\r\n"),
|
|
(1024, b"400\r\n"),
|
|
(65535, b"ffff\r\n"),
|
|
(1048576, b"100000\r\n"), # 1MB
|
|
]
|
|
|
|
for size, expected in test_cases:
|
|
chunk_header = f"{size:x}\r\n".encode("latin-1")
|
|
assert chunk_header == expected, f"Failed for size {size}"
|
|
|
|
def test_megabyte_chunk(self):
|
|
"""Test encoding 1MB chunk."""
|
|
size = 1024 * 1024 # 1MB
|
|
body = b"x" * size
|
|
|
|
chunk = f"{len(body):x}\r\n".encode("latin-1") + body + b"\r\n"
|
|
|
|
# Verify structure
|
|
assert chunk.startswith(b"100000\r\n") # 1MB in hex
|
|
assert chunk.endswith(b"\r\n")
|
|
# Total size: header (8) + body (1048576) + trailer (2)
|
|
assert len(chunk) == 8 + 1048576 + 2
|
|
|
|
|
|
# ============================================================================
|
|
# Transfer-Encoding Header Tests
|
|
# ============================================================================
|
|
|
|
class TestTransferEncodingHeader:
|
|
"""Tests for Transfer-Encoding header handling."""
|
|
|
|
def test_transfer_encoding_chunked(self):
|
|
"""Test Transfer-Encoding: chunked header."""
|
|
headers = [(b"transfer-encoding", b"chunked")]
|
|
|
|
te_header = dict(headers).get(b"transfer-encoding")
|
|
assert te_header == b"chunked"
|
|
|
|
def test_add_transfer_encoding_to_headers(self):
|
|
"""Test adding Transfer-Encoding header to response."""
|
|
headers = [
|
|
(b"content-type", b"text/plain"),
|
|
]
|
|
|
|
# Add chunked encoding
|
|
headers = list(headers) + [(b"transfer-encoding", b"chunked")]
|
|
|
|
header_names = [name for name, _ in headers]
|
|
assert b"transfer-encoding" in header_names
|
|
|
|
def test_no_content_length_with_transfer_encoding(self):
|
|
"""Test Content-Length should not be present with Transfer-Encoding."""
|
|
# Per HTTP spec, Content-Length must be ignored if Transfer-Encoding present
|
|
headers = [
|
|
(b"content-type", b"text/plain"),
|
|
(b"transfer-encoding", b"chunked"),
|
|
]
|
|
|
|
header_names = [name for name, _ in headers]
|
|
assert b"content-length" not in header_names
|