# -*- coding: utf-8 - # # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. """Tests for HTTP/2 stream state management.""" import pytest from gunicorn.http2.stream import HTTP2Stream, StreamState from gunicorn.http2.errors import HTTP2StreamError class MockConnection: """Mock HTTP/2 connection for testing streams.""" def __init__(self, initial_window_size=65535): self.initial_window_size = initial_window_size class TestStreamState: """Test StreamState enum values.""" def test_state_values_exist(self): assert StreamState.IDLE is not None assert StreamState.RESERVED_LOCAL is not None assert StreamState.RESERVED_REMOTE is not None assert StreamState.OPEN is not None assert StreamState.HALF_CLOSED_LOCAL is not None assert StreamState.HALF_CLOSED_REMOTE is not None assert StreamState.CLOSED is not None def test_states_are_unique(self): states = [ StreamState.IDLE, StreamState.RESERVED_LOCAL, StreamState.RESERVED_REMOTE, StreamState.OPEN, StreamState.HALF_CLOSED_LOCAL, StreamState.HALF_CLOSED_REMOTE, StreamState.CLOSED, ] assert len(states) == len(set(states)) class TestHTTP2StreamInitialization: """Test stream initialization.""" def test_basic_init(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) assert stream.stream_id == 1 assert stream.connection is conn assert stream.state == StreamState.IDLE assert stream.request_headers == [] assert stream.request_complete is False assert stream.response_started is False assert stream.response_headers_sent is False assert stream.response_complete is False assert stream.window_size == 65535 assert stream.trailers is None def test_custom_window_size(self): conn = MockConnection(initial_window_size=32768) stream = HTTP2Stream(stream_id=3, connection=conn) assert stream.window_size == 32768 class TestStreamIdProperties: """Test stream ID classification properties.""" def test_is_client_stream_odd_ids(self): conn = MockConnection() for stream_id in [1, 3, 5, 7, 99, 101]: stream = HTTP2Stream(stream_id=stream_id, connection=conn) assert stream.is_client_stream is True assert stream.is_server_stream is False def test_is_server_stream_even_ids(self): conn = MockConnection() for stream_id in [2, 4, 6, 8, 100, 102]: stream = HTTP2Stream(stream_id=stream_id, connection=conn) assert stream.is_client_stream is False assert stream.is_server_stream is True def test_stream_id_zero(self): conn = MockConnection() stream = HTTP2Stream(stream_id=0, connection=conn) assert stream.is_client_stream is False assert stream.is_server_stream is True class TestCanReceiveProperty: """Test can_receive property.""" def test_can_receive_in_open_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN assert stream.can_receive is True def test_can_receive_in_half_closed_local(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_LOCAL assert stream.can_receive is True def test_cannot_receive_in_idle(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) assert stream.state == StreamState.IDLE assert stream.can_receive is False def test_cannot_receive_in_half_closed_remote(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_REMOTE assert stream.can_receive is False def test_cannot_receive_in_closed(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.CLOSED assert stream.can_receive is False class TestCanSendProperty: """Test can_send property.""" def test_can_send_in_open_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN assert stream.can_send is True def test_can_send_in_half_closed_remote(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_REMOTE assert stream.can_send is True def test_cannot_send_in_idle(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) assert stream.state == StreamState.IDLE assert stream.can_send is False def test_cannot_send_in_half_closed_local(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_LOCAL assert stream.can_send is False def test_cannot_send_in_closed(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.CLOSED assert stream.can_send is False class TestReceiveHeaders: """Test receive_headers method.""" def test_receive_headers_from_idle(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) headers = [(':method', 'GET'), (':path', '/')] stream.receive_headers(headers, end_stream=False) assert stream.state == StreamState.OPEN assert stream.request_headers == headers assert stream.request_complete is False def test_receive_headers_with_end_stream(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) headers = [(':method', 'GET'), (':path', '/')] stream.receive_headers(headers, end_stream=True) assert stream.state == StreamState.HALF_CLOSED_REMOTE assert stream.request_complete is True def test_receive_headers_in_open_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN headers = [('content-type', 'text/plain')] stream.receive_headers(headers, end_stream=False) assert stream.state == StreamState.OPEN assert stream.request_headers == headers def test_receive_headers_extends_existing(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.receive_headers([(':method', 'POST')], end_stream=False) stream.receive_headers([('content-type', 'text/plain')], end_stream=False) assert len(stream.request_headers) == 2 def test_receive_headers_in_invalid_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.CLOSED with pytest.raises(HTTP2StreamError) as exc_info: stream.receive_headers([], end_stream=False) assert exc_info.value.stream_id == 1 class TestReceiveData: """Test receive_data method.""" def test_receive_data_in_open_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.receive_data(b"Hello, World!", end_stream=False) assert stream.request_body.getvalue() == b"Hello, World!" assert stream.request_complete is False def test_receive_data_with_end_stream(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.receive_data(b"Final data", end_stream=True) assert stream.state == StreamState.HALF_CLOSED_REMOTE assert stream.request_complete is True def test_receive_data_accumulates(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.receive_data(b"Part1") stream.receive_data(b"Part2") stream.receive_data(b"Part3", end_stream=True) assert stream.request_body.getvalue() == b"Part1Part2Part3" def test_receive_data_in_half_closed_local(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_LOCAL stream.receive_data(b"data", end_stream=False) assert stream.request_body.getvalue() == b"data" def test_receive_data_in_invalid_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_REMOTE with pytest.raises(HTTP2StreamError) as exc_info: stream.receive_data(b"data", end_stream=False) assert exc_info.value.stream_id == 1 class TestReceiveTrailers: """Test receive_trailers method.""" def test_receive_trailers_in_open_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN trailers = [('grpc-status', '0')] stream.receive_trailers(trailers) assert stream.trailers == trailers assert stream.state == StreamState.HALF_CLOSED_REMOTE assert stream.request_complete is True def test_receive_trailers_in_invalid_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.CLOSED with pytest.raises(HTTP2StreamError): stream.receive_trailers([]) class TestSendHeaders: """Test send_headers method.""" def test_send_headers_in_open_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN headers = [(':status', '200')] stream.send_headers(headers, end_stream=False) assert stream.response_started is True assert stream.response_headers_sent is True assert stream.response_complete is False assert stream.state == StreamState.OPEN def test_send_headers_with_end_stream(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.send_headers([(':status', '204')], end_stream=True) assert stream.state == StreamState.HALF_CLOSED_LOCAL assert stream.response_complete is True def test_send_headers_in_half_closed_remote(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_REMOTE stream.send_headers([(':status', '200')], end_stream=False) assert stream.response_headers_sent is True def test_send_headers_in_invalid_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_LOCAL with pytest.raises(HTTP2StreamError): stream.send_headers([], end_stream=False) class TestSendData: """Test send_data method.""" def test_send_data_in_open_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.send_data(b"Response body", end_stream=False) assert stream.response_complete is False def test_send_data_with_end_stream(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.send_data(b"Final", end_stream=True) assert stream.state == StreamState.HALF_CLOSED_LOCAL assert stream.response_complete is True def test_send_data_in_half_closed_remote(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_REMOTE stream.send_data(b"data", end_stream=True) assert stream.state == StreamState.CLOSED def test_send_data_in_invalid_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.CLOSED with pytest.raises(HTTP2StreamError): stream.send_data(b"data", end_stream=False) class TestStreamReset: """Test stream reset method.""" def test_reset_default_error_code(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.reset() assert stream.state == StreamState.CLOSED assert stream.response_complete is True assert stream.request_complete is True def test_reset_custom_error_code(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.reset(error_code=0x1) # PROTOCOL_ERROR assert stream.state == StreamState.CLOSED class TestStreamClose: """Test stream close method.""" def test_close_stream(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.close() assert stream.state == StreamState.CLOSED assert stream.response_complete is True assert stream.request_complete is True class TestHalfCloseTransitions: """Test half-close state transitions.""" def test_half_close_local_from_open(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream._half_close_local() assert stream.state == StreamState.HALF_CLOSED_LOCAL def test_half_close_local_from_half_closed_remote(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_REMOTE stream._half_close_local() assert stream.state == StreamState.CLOSED def test_half_close_local_invalid_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.IDLE with pytest.raises(HTTP2StreamError): stream._half_close_local() def test_half_close_remote_from_open(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream._half_close_remote() assert stream.state == StreamState.HALF_CLOSED_REMOTE def test_half_close_remote_from_half_closed_local(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.HALF_CLOSED_LOCAL stream._half_close_remote() assert stream.state == StreamState.CLOSED def test_half_close_remote_invalid_state(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.IDLE with pytest.raises(HTTP2StreamError): stream._half_close_remote() class TestGetRequestBody: """Test get_request_body method.""" def test_get_empty_body(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) assert stream.get_request_body() == b"" def test_get_body_after_data(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.receive_data(b"Test body content") assert stream.get_request_body() == b"Test body content" class TestReadBodyChunk: """Test read_body_chunk method.""" @pytest.mark.asyncio async def test_read_body_chunk_returns_data(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.receive_data(b"chunk1", end_stream=True) chunk = await stream.read_body_chunk() assert chunk == b"chunk1" @pytest.mark.asyncio async def test_read_body_chunk_multi_frame(self): """Multiple DATA frames should each be returned as separate chunks.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.state = StreamState.OPEN stream.receive_data(b"part1") stream.receive_data(b"part2") stream.receive_data(b"part3", end_stream=True) chunks = [] for _ in range(3): chunk = await stream.read_body_chunk() if chunk is None: break chunks.append(chunk) assert b"".join(chunks) == b"part1part2part3" class TestGetPseudoHeaders: """Test get_pseudo_headers method.""" def test_extract_pseudo_headers(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.request_headers = [ (':method', 'POST'), (':path', '/api/test'), (':scheme', 'https'), (':authority', 'example.com'), ('content-type', 'application/json'), ('accept', '*/*'), ] pseudo = stream.get_pseudo_headers() assert pseudo == { ':method': 'POST', ':path': '/api/test', ':scheme': 'https', ':authority': 'example.com', } def test_empty_pseudo_headers(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.request_headers = [ ('content-type', 'text/plain'), ] pseudo = stream.get_pseudo_headers() assert pseudo == {} class TestGetRegularHeaders: """Test get_regular_headers method.""" def test_extract_regular_headers(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.request_headers = [ (':method', 'GET'), (':path', '/'), ('content-type', 'text/html'), ('accept-language', 'en-US'), ] regular = stream.get_regular_headers() assert regular == [ ('content-type', 'text/html'), ('accept-language', 'en-US'), ] def test_no_regular_headers(self): conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.request_headers = [ (':method', 'GET'), (':path', '/'), ] regular = stream.get_regular_headers() assert regular == [] class TestStreamRepr: """Test stream string representation.""" def test_repr_format(self): conn = MockConnection() stream = HTTP2Stream(stream_id=5, connection=conn) repr_str = repr(stream) assert "HTTP2Stream" in repr_str assert "id=5" in repr_str assert "state=IDLE" in repr_str assert "req_complete=False" in repr_str assert "resp_complete=False" in repr_str class TestFullStreamLifecycle: """Test complete stream lifecycles.""" def test_simple_get_request(self): """Test a simple GET request lifecycle.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) # Receive request headers (GET with end_stream) stream.receive_headers([ (':method', 'GET'), (':path', '/'), (':scheme', 'https'), (':authority', 'example.com'), ], end_stream=True) assert stream.state == StreamState.HALF_CLOSED_REMOTE assert stream.request_complete is True # Send response headers with body stream.send_headers([(':status', '200')], end_stream=False) assert stream.state == StreamState.HALF_CLOSED_REMOTE # Send response body stream.send_data(b"Hello!", end_stream=True) assert stream.state == StreamState.CLOSED assert stream.response_complete is True def test_post_request_with_body(self): """Test a POST request with body.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) # Receive request headers stream.receive_headers([ (':method', 'POST'), (':path', '/submit'), ('content-type', 'application/json'), ], end_stream=False) assert stream.state == StreamState.OPEN # Receive body data stream.receive_data(b'{"key": "value"}', end_stream=True) assert stream.state == StreamState.HALF_CLOSED_REMOTE assert stream.get_request_body() == b'{"key": "value"}' # Send response stream.send_headers([(':status', '201')], end_stream=False) stream.send_data(b'Created', end_stream=True) assert stream.state == StreamState.CLOSED def test_stream_reset_lifecycle(self): """Test a stream that gets reset.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.receive_headers([(':method', 'GET'), (':path', '/')], end_stream=False) assert stream.state == StreamState.OPEN # Reset the stream stream.reset(error_code=0x8) # CANCEL assert stream.state == StreamState.CLOSED assert stream.request_complete is True assert stream.response_complete is True class TestStreamPriority: """Test stream priority support (RFC 7540 Section 5.3).""" def test_default_priority_values(self): """Test default priority values.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) assert stream.priority_weight == 16 assert stream.priority_depends_on == 0 assert stream.priority_exclusive is False def test_update_priority_weight(self): """Test updating priority weight.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.update_priority(weight=256) assert stream.priority_weight == 256 stream.update_priority(weight=1) assert stream.priority_weight == 1 def test_update_priority_depends_on(self): """Test updating priority dependency.""" conn = MockConnection() stream = HTTP2Stream(stream_id=3, connection=conn) stream.update_priority(depends_on=1) assert stream.priority_depends_on == 1 def test_update_priority_exclusive(self): """Test updating exclusive flag.""" conn = MockConnection() stream = HTTP2Stream(stream_id=3, connection=conn) stream.update_priority(exclusive=True) assert stream.priority_exclusive is True stream.update_priority(exclusive=False) assert stream.priority_exclusive is False def test_update_priority_all_fields(self): """Test updating all priority fields at once.""" conn = MockConnection() stream = HTTP2Stream(stream_id=5, connection=conn) stream.update_priority(weight=128, depends_on=1, exclusive=True) assert stream.priority_weight == 128 assert stream.priority_depends_on == 1 assert stream.priority_exclusive is True def test_update_priority_partial(self): """Test that partial updates don't affect other fields.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) # Set initial values stream.update_priority(weight=200, depends_on=3, exclusive=True) # Update only weight stream.update_priority(weight=100) assert stream.priority_weight == 100 assert stream.priority_depends_on == 3 # unchanged assert stream.priority_exclusive is True # unchanged def test_weight_clamped_to_min(self): """Test that weight is clamped to minimum of 1.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.update_priority(weight=0) assert stream.priority_weight == 1 stream.update_priority(weight=-10) assert stream.priority_weight == 1 def test_weight_clamped_to_max(self): """Test that weight is clamped to maximum of 256.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.update_priority(weight=300) assert stream.priority_weight == 256 stream.update_priority(weight=1000) assert stream.priority_weight == 256 class TestStreamResponseTrailers: """Test response trailer support.""" def test_response_trailers_default_none(self): """Test that response_trailers defaults to None.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) assert stream.response_trailers is None def test_send_trailers_in_open_state(self): """Test sending trailers in OPEN state.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) # Open the stream stream.receive_headers([(':method', 'GET'), (':path', '/')], end_stream=True) assert stream.state == StreamState.HALF_CLOSED_REMOTE # Send response headers stream.send_headers([(':status', '200')], end_stream=False) # Send trailers trailers = [('grpc-status', '0'), ('grpc-message', 'OK')] stream.send_trailers(trailers) assert stream.response_trailers == trailers assert stream.state == StreamState.CLOSED assert stream.response_complete is True def test_send_trailers_after_body(self): """Test sending trailers after response body.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) # Open the stream stream.receive_headers([(':method', 'POST'), (':path', '/api')], end_stream=False) stream.receive_data(b'request body', end_stream=True) # Send response stream.send_headers([(':status', '200')], end_stream=False) stream.send_data(b'response body', end_stream=False) # Send trailers trailers = [('content-md5', 'abc123')] stream.send_trailers(trailers) assert stream.response_trailers == trailers assert stream.state == StreamState.CLOSED def test_send_trailers_closes_stream(self): """Test that trailers close the stream.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) stream.receive_headers([(':method', 'GET'), (':path', '/')], end_stream=True) stream.send_headers([(':status', '200')], end_stream=False) assert stream.can_send is True stream.send_trailers([('trailer', 'value')]) assert stream.can_send is False assert stream.response_complete is True def test_send_trailers_invalid_state_raises(self): """Test that sending trailers in invalid state raises error.""" conn = MockConnection() stream = HTTP2Stream(stream_id=1, connection=conn) # Stream is IDLE, cannot send trailers with pytest.raises(HTTP2StreamError): stream.send_trailers([('trailer', 'value')])