mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-01-22 10:53:03 -08:00
6414a0ba12
* Bump cheroot from 10.0.0 to 10.0.1 Bumps [cheroot](https://github.com/cherrypy/cheroot) from 10.0.0 to 10.0.1. - [Release notes](https://github.com/cherrypy/cheroot/releases) - [Changelog](https://github.com/cherrypy/cheroot/blob/main/CHANGES.rst) - [Commits](https://github.com/cherrypy/cheroot/compare/v10.0.0...v10.0.1) --- updated-dependencies: - dependency-name: cheroot dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> * Update cheroot==10.0.1 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> [skip ci]
1584 lines
52 KiB
Python
1584 lines
52 KiB
Python
"""Tests for TCP connection handling, including proper and timely close."""
|
|
|
|
import errno
|
|
from re import match as _matches_pattern
|
|
import socket
|
|
import sys
|
|
import time
|
|
import logging
|
|
import traceback as traceback_
|
|
from collections import namedtuple
|
|
import http.client
|
|
import urllib.request
|
|
|
|
import pytest
|
|
from jaraco.text import trim, unwrap
|
|
|
|
from cheroot.test import helper, webtest
|
|
from cheroot._compat import IS_CI, IS_MACOS, IS_PYPY, IS_WINDOWS
|
|
import cheroot.server
|
|
|
|
|
|
IS_PY36 = sys.version_info[:2] == (3, 6)
|
|
IS_SLOW_ENV = IS_MACOS or IS_WINDOWS
|
|
|
|
|
|
timeout = 1
|
|
pov = 'pPeErRsSiIsStTeEnNcCeE oOfF vViIsSiIoOnN'
|
|
|
|
|
|
class Controller(helper.Controller):
|
|
"""Controller for serving WSGI apps."""
|
|
|
|
def hello(req, resp):
|
|
"""Render Hello world."""
|
|
return 'Hello, world!'
|
|
|
|
def pov(req, resp):
|
|
"""Render ``pov`` value."""
|
|
return pov
|
|
|
|
def stream(req, resp):
|
|
"""Render streaming response."""
|
|
if 'set_cl' in req.environ['QUERY_STRING']:
|
|
resp.headers['Content-Length'] = str(10)
|
|
|
|
def content():
|
|
for x in range(10):
|
|
yield str(x)
|
|
|
|
return content()
|
|
|
|
def upload(req, resp):
|
|
"""Process file upload and render thank."""
|
|
if not req.environ['REQUEST_METHOD'] == 'POST':
|
|
raise AssertionError(
|
|
"'POST' != request.method %r" %
|
|
req.environ['REQUEST_METHOD'],
|
|
)
|
|
input_contents = req.environ['wsgi.input'].read().decode('utf-8')
|
|
return f"thanks for '{input_contents !s}'"
|
|
|
|
def custom_204(req, resp):
|
|
"""Render response with status 204."""
|
|
resp.status = '204'
|
|
return 'Code = 204'
|
|
|
|
def custom_304(req, resp):
|
|
"""Render response with status 304."""
|
|
resp.status = '304'
|
|
return 'Code = 304'
|
|
|
|
def err_before_read(req, resp):
|
|
"""Render response with status 500."""
|
|
resp.status = '500 Internal Server Error'
|
|
return 'ok'
|
|
|
|
def one_megabyte_of_a(req, resp):
|
|
"""Render 1MB response."""
|
|
return ['a' * 1024] * 1024
|
|
|
|
def wrong_cl_buffered(req, resp):
|
|
"""Render buffered response with invalid length value."""
|
|
resp.headers['Content-Length'] = '5'
|
|
return 'I have too many bytes'
|
|
|
|
def wrong_cl_unbuffered(req, resp):
|
|
"""Render unbuffered response with invalid length value."""
|
|
resp.headers['Content-Length'] = '5'
|
|
return ['I too', ' have too many bytes']
|
|
|
|
def _munge(string):
|
|
"""Encode PATH_INFO correctly depending on Python version.
|
|
|
|
WSGI 1.0 is a mess around unicode. Create endpoints
|
|
that match the PATH_INFO that it produces.
|
|
"""
|
|
return string.encode('utf-8').decode('latin-1')
|
|
|
|
handlers = {
|
|
'/hello': hello,
|
|
'/pov': pov,
|
|
'/page1': pov,
|
|
'/page2': pov,
|
|
'/page3': pov,
|
|
'/stream': stream,
|
|
'/upload': upload,
|
|
'/custom/204': custom_204,
|
|
'/custom/304': custom_304,
|
|
'/err_before_read': err_before_read,
|
|
'/one_megabyte_of_a': one_megabyte_of_a,
|
|
'/wrong_cl_buffered': wrong_cl_buffered,
|
|
'/wrong_cl_unbuffered': wrong_cl_unbuffered,
|
|
}
|
|
|
|
|
|
class ErrorLogMonitor:
|
|
"""Mock class to access the server error_log calls made by the server."""
|
|
|
|
ErrorLogCall = namedtuple('ErrorLogCall', ['msg', 'level', 'traceback'])
|
|
|
|
def __init__(self):
|
|
"""Initialize the server error log monitor/interceptor.
|
|
|
|
If you need to ignore a particular error message use the property
|
|
``ignored_msgs`` by appending to the list the expected error messages.
|
|
"""
|
|
self.calls = []
|
|
# to be used the the teardown validation
|
|
self.ignored_msgs = []
|
|
|
|
def __call__(self, msg='', level=logging.INFO, traceback=False):
|
|
"""Intercept the call to the server error_log method."""
|
|
if traceback:
|
|
tblines = traceback_.format_exc()
|
|
else:
|
|
tblines = ''
|
|
self.calls.append(ErrorLogMonitor.ErrorLogCall(msg, level, tblines))
|
|
|
|
|
|
@pytest.fixture
|
|
def raw_testing_server(wsgi_server_client):
|
|
"""Attach a WSGI app to the given server and preconfigure it."""
|
|
app = Controller()
|
|
|
|
def _timeout(req, resp):
|
|
return str(wsgi_server.timeout)
|
|
app.handlers['/timeout'] = _timeout
|
|
wsgi_server = wsgi_server_client.server_instance
|
|
wsgi_server.wsgi_app = app
|
|
wsgi_server.max_request_body_size = 1001
|
|
wsgi_server.timeout = timeout
|
|
wsgi_server.server_client = wsgi_server_client
|
|
wsgi_server.keep_alive_conn_limit = 2
|
|
|
|
return wsgi_server
|
|
|
|
|
|
@pytest.fixture
|
|
def testing_server(raw_testing_server, monkeypatch):
|
|
"""Modify the "raw" base server to monitor the error_log messages.
|
|
|
|
If you need to ignore a particular error message use the property
|
|
``testing_server.error_log.ignored_msgs`` by appending to the list
|
|
the expected error messages.
|
|
"""
|
|
# patch the error_log calls of the server instance
|
|
monkeypatch.setattr(raw_testing_server, 'error_log', ErrorLogMonitor())
|
|
|
|
yield raw_testing_server
|
|
|
|
# Teardown verification, in case that the server logged an
|
|
# error that wasn't notified to the client or we just made a mistake.
|
|
# pylint: disable=possibly-unused-variable
|
|
for c_msg, c_level, c_traceback in raw_testing_server.error_log.calls:
|
|
if c_level <= logging.WARNING:
|
|
continue
|
|
|
|
assert c_msg in raw_testing_server.error_log.ignored_msgs, (
|
|
'Found error in the error log: '
|
|
"message = '{c_msg}', level = '{c_level}'\n"
|
|
'{c_traceback}'.format(**locals()),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client(testing_server):
|
|
"""Get and return a test client out of the given server."""
|
|
return testing_server.server_client
|
|
|
|
|
|
def header_exists(header_name, headers):
|
|
"""Check that a header is present."""
|
|
return header_name.lower() in (k.lower() for (k, _) in headers)
|
|
|
|
|
|
def header_has_value(header_name, header_value, headers):
|
|
"""Check that a header with a given value is present."""
|
|
return header_name.lower() in (
|
|
k.lower() for (k, v) in headers
|
|
if v == header_value
|
|
)
|
|
|
|
|
|
def test_HTTP11_persistent_connections(test_client):
|
|
"""Test persistent HTTP/1.1 connections."""
|
|
# Initialize a persistent HTTP connection
|
|
http_connection = test_client.get_connection()
|
|
http_connection.auto_open = False
|
|
http_connection.connect()
|
|
|
|
# Make the first request and assert there's no "Connection: close".
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/pov', http_conn=http_connection,
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
assert not header_exists('Connection', actual_headers)
|
|
|
|
# Make another request on the same connection.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/page1', http_conn=http_connection,
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
assert not header_exists('Connection', actual_headers)
|
|
|
|
# Test client-side close.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/page2', http_conn=http_connection,
|
|
headers=[('Connection', 'close')],
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
assert header_has_value('Connection', 'close', actual_headers)
|
|
|
|
# Make another request on the same connection, which should error.
|
|
with pytest.raises(http.client.NotConnected):
|
|
test_client.get('/pov', http_conn=http_connection)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'set_cl',
|
|
(
|
|
False, # Without Content-Length
|
|
True, # With Content-Length
|
|
),
|
|
)
|
|
def test_streaming_11(test_client, set_cl):
|
|
"""Test serving of streaming responses with HTTP/1.1 protocol."""
|
|
# Initialize a persistent HTTP connection
|
|
http_connection = test_client.get_connection()
|
|
http_connection.auto_open = False
|
|
http_connection.connect()
|
|
|
|
# Make the first request and assert there's no "Connection: close".
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/pov', http_conn=http_connection,
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
assert not header_exists('Connection', actual_headers)
|
|
|
|
# Make another, streamed request on the same connection.
|
|
if set_cl:
|
|
# When a Content-Length is provided, the content should stream
|
|
# without closing the connection.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/stream?set_cl=Yes', http_conn=http_connection,
|
|
)
|
|
assert header_exists('Content-Length', actual_headers)
|
|
assert not header_has_value('Connection', 'close', actual_headers)
|
|
assert not header_exists('Transfer-Encoding', actual_headers)
|
|
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == b'0123456789'
|
|
else:
|
|
# When no Content-Length response header is provided,
|
|
# streamed output will either close the connection, or use
|
|
# chunked encoding, to determine transfer-length.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/stream', http_conn=http_connection,
|
|
)
|
|
assert not header_exists('Content-Length', actual_headers)
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == b'0123456789'
|
|
|
|
chunked_response = False
|
|
for k, v in actual_headers:
|
|
if k.lower() == 'transfer-encoding':
|
|
if str(v) == 'chunked':
|
|
chunked_response = True
|
|
|
|
if chunked_response:
|
|
assert not header_has_value('Connection', 'close', actual_headers)
|
|
else:
|
|
assert header_has_value('Connection', 'close', actual_headers)
|
|
|
|
# Make another request on the same connection, which should
|
|
# error.
|
|
with pytest.raises(http.client.NotConnected):
|
|
test_client.get('/pov', http_conn=http_connection)
|
|
|
|
# Try HEAD.
|
|
# See https://www.bitbucket.org/cherrypy/cherrypy/issue/864.
|
|
# TODO: figure out how can this be possible on an closed connection
|
|
# (chunked_response case)
|
|
status_line, actual_headers, actual_resp_body = test_client.head(
|
|
'/stream', http_conn=http_connection,
|
|
)
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == b''
|
|
assert not header_exists('Transfer-Encoding', actual_headers)
|
|
|
|
# Prevent the resource warnings:
|
|
http_connection.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'set_cl',
|
|
(
|
|
False, # Without Content-Length
|
|
True, # With Content-Length
|
|
),
|
|
)
|
|
def test_streaming_10(test_client, set_cl):
|
|
"""Test serving of streaming responses with HTTP/1.0 protocol."""
|
|
original_server_protocol = test_client.server_instance.protocol
|
|
test_client.server_instance.protocol = 'HTTP/1.0'
|
|
|
|
# Initialize a persistent HTTP connection
|
|
http_connection = test_client.get_connection()
|
|
http_connection.auto_open = False
|
|
http_connection.connect()
|
|
|
|
# Make the first request and assert Keep-Alive.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/pov', http_conn=http_connection,
|
|
headers=[('Connection', 'Keep-Alive')],
|
|
protocol='HTTP/1.0',
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
assert header_has_value('Connection', 'Keep-Alive', actual_headers)
|
|
|
|
# Make another, streamed request on the same connection.
|
|
if set_cl:
|
|
# When a Content-Length is provided, the content should
|
|
# stream without closing the connection.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/stream?set_cl=Yes', http_conn=http_connection,
|
|
headers=[('Connection', 'Keep-Alive')],
|
|
protocol='HTTP/1.0',
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == b'0123456789'
|
|
|
|
assert header_exists('Content-Length', actual_headers)
|
|
assert header_has_value('Connection', 'Keep-Alive', actual_headers)
|
|
assert not header_exists('Transfer-Encoding', actual_headers)
|
|
else:
|
|
# When a Content-Length is not provided,
|
|
# the server should close the connection.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/stream', http_conn=http_connection,
|
|
headers=[('Connection', 'Keep-Alive')],
|
|
protocol='HTTP/1.0',
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == b'0123456789'
|
|
|
|
assert not header_exists('Content-Length', actual_headers)
|
|
assert not header_has_value('Connection', 'Keep-Alive', actual_headers)
|
|
assert not header_exists('Transfer-Encoding', actual_headers)
|
|
|
|
# Make another request on the same connection, which should error.
|
|
with pytest.raises(http.client.NotConnected):
|
|
test_client.get(
|
|
'/pov', http_conn=http_connection,
|
|
protocol='HTTP/1.0',
|
|
)
|
|
|
|
test_client.server_instance.protocol = original_server_protocol
|
|
|
|
# Prevent the resource warnings:
|
|
http_connection.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'http_server_protocol',
|
|
(
|
|
'HTTP/1.0',
|
|
pytest.param(
|
|
'HTTP/1.1',
|
|
marks=pytest.mark.xfail(
|
|
IS_PYPY and IS_CI,
|
|
reason='Fails under PyPy in CI for unknown reason',
|
|
strict=False,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
def test_keepalive(test_client, http_server_protocol):
|
|
"""Test Keep-Alive enabled connections."""
|
|
original_server_protocol = test_client.server_instance.protocol
|
|
test_client.server_instance.protocol = http_server_protocol
|
|
|
|
http_client_protocol = 'HTTP/1.0'
|
|
|
|
# Initialize a persistent HTTP connection
|
|
http_connection = test_client.get_connection()
|
|
http_connection.auto_open = False
|
|
http_connection.connect()
|
|
|
|
# Test a normal HTTP/1.0 request.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/page2',
|
|
protocol=http_client_protocol,
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
assert not header_exists('Connection', actual_headers)
|
|
|
|
# Test a keep-alive HTTP/1.0 request.
|
|
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/page3', headers=[('Connection', 'Keep-Alive')],
|
|
http_conn=http_connection, protocol=http_client_protocol,
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
assert header_has_value('Connection', 'Keep-Alive', actual_headers)
|
|
assert header_has_value(
|
|
'Keep-Alive',
|
|
'timeout={test_client.server_instance.timeout}'.format(**locals()),
|
|
actual_headers,
|
|
)
|
|
|
|
# Remove the keep-alive header again.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/page3', http_conn=http_connection,
|
|
protocol=http_client_protocol,
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
assert not header_exists('Connection', actual_headers)
|
|
assert not header_exists('Keep-Alive', actual_headers)
|
|
|
|
test_client.server_instance.protocol = original_server_protocol
|
|
|
|
# Prevent the resource warnings:
|
|
http_connection.close()
|
|
|
|
|
|
def test_keepalive_conn_management(test_client):
|
|
"""Test management of Keep-Alive connections."""
|
|
test_client.server_instance.timeout = 2
|
|
|
|
def connection():
|
|
# Initialize a persistent HTTP connection
|
|
http_connection = test_client.get_connection()
|
|
http_connection.auto_open = False
|
|
http_connection.connect()
|
|
return http_connection
|
|
|
|
def request(conn, keepalive=True):
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/page3', headers=[('Connection', 'Keep-Alive')],
|
|
http_conn=conn, protocol='HTTP/1.0',
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
if keepalive:
|
|
assert header_has_value('Connection', 'Keep-Alive', actual_headers)
|
|
assert header_has_value(
|
|
'Keep-Alive',
|
|
'timeout={test_client.server_instance.timeout}'.
|
|
format(**locals()),
|
|
actual_headers,
|
|
)
|
|
else:
|
|
assert not header_exists('Connection', actual_headers)
|
|
assert not header_exists('Keep-Alive', actual_headers)
|
|
|
|
def check_server_idle_conn_count(count, timeout=1.0):
|
|
deadline = time.time() + timeout
|
|
while True:
|
|
n = test_client.server_instance._connections._num_connections
|
|
if n == count:
|
|
return
|
|
assert time.time() <= deadline, (
|
|
'idle conn count mismatch, wanted {count}, got {n}'.
|
|
format(**locals()),
|
|
)
|
|
|
|
disconnect_errors = (
|
|
http.client.BadStatusLine,
|
|
http.client.CannotSendRequest,
|
|
http.client.NotConnected,
|
|
)
|
|
|
|
# Make a new connection.
|
|
c1 = connection()
|
|
request(c1)
|
|
check_server_idle_conn_count(1)
|
|
|
|
# Make a second one.
|
|
c2 = connection()
|
|
request(c2)
|
|
check_server_idle_conn_count(2)
|
|
|
|
# Reusing the first connection should still work.
|
|
request(c1)
|
|
check_server_idle_conn_count(2)
|
|
|
|
# Creating a new connection should still work, but we should
|
|
# have run out of available connections to keep alive, so the
|
|
# server should tell us to close.
|
|
c3 = connection()
|
|
request(c3, keepalive=False)
|
|
check_server_idle_conn_count(2)
|
|
|
|
# Show that the third connection was closed.
|
|
with pytest.raises(disconnect_errors):
|
|
request(c3)
|
|
check_server_idle_conn_count(2)
|
|
|
|
# Wait for some of our timeout.
|
|
time.sleep(1.2)
|
|
|
|
# Refresh the second connection.
|
|
request(c2)
|
|
check_server_idle_conn_count(2)
|
|
|
|
# Wait for the remainder of our timeout, plus one tick.
|
|
time.sleep(1.2)
|
|
check_server_idle_conn_count(1)
|
|
|
|
# First connection should now be expired.
|
|
with pytest.raises(disconnect_errors):
|
|
request(c1)
|
|
check_server_idle_conn_count(1)
|
|
|
|
# But the second one should still be valid.
|
|
request(c2)
|
|
check_server_idle_conn_count(1)
|
|
|
|
# Restore original timeout.
|
|
test_client.server_instance.timeout = timeout
|
|
|
|
# Prevent the resource warnings:
|
|
c1.close()
|
|
c2.close()
|
|
c3.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('simulated_exception', 'error_number', 'exception_leaks'),
|
|
(
|
|
pytest.param(
|
|
socket.error, errno.ECONNRESET, False,
|
|
id='socket.error(ECONNRESET)',
|
|
),
|
|
pytest.param(
|
|
socket.error, errno.EPIPE, False,
|
|
id='socket.error(EPIPE)',
|
|
),
|
|
pytest.param(
|
|
socket.error, errno.ENOTCONN, False,
|
|
id='simulated socket.error(ENOTCONN)',
|
|
),
|
|
pytest.param(
|
|
None, # <-- don't raise an artificial exception
|
|
errno.ENOTCONN, False,
|
|
id='real socket.error(ENOTCONN)',
|
|
marks=pytest.mark.xfail(
|
|
IS_WINDOWS,
|
|
reason='Now reproducible this way on Windows',
|
|
),
|
|
),
|
|
pytest.param(
|
|
socket.error, errno.ESHUTDOWN, False,
|
|
id='socket.error(ESHUTDOWN)',
|
|
),
|
|
pytest.param(RuntimeError, 666, True, id='RuntimeError(666)'),
|
|
pytest.param(socket.error, -1, True, id='socket.error(-1)'),
|
|
) + (
|
|
pytest.param(
|
|
ConnectionResetError, errno.ECONNRESET, False,
|
|
id='ConnectionResetError(ECONNRESET)',
|
|
),
|
|
pytest.param(
|
|
BrokenPipeError, errno.EPIPE, False,
|
|
id='BrokenPipeError(EPIPE)',
|
|
),
|
|
pytest.param(
|
|
BrokenPipeError, errno.ESHUTDOWN, False,
|
|
id='BrokenPipeError(ESHUTDOWN)',
|
|
),
|
|
),
|
|
)
|
|
def test_broken_connection_during_tcp_fin(
|
|
error_number, exception_leaks,
|
|
mocker, monkeypatch,
|
|
simulated_exception, test_client,
|
|
):
|
|
"""Test there's no traceback on broken connection during close.
|
|
|
|
It artificially causes :py:data:`~errno.ECONNRESET` /
|
|
:py:data:`~errno.EPIPE` / :py:data:`~errno.ESHUTDOWN` /
|
|
:py:data:`~errno.ENOTCONN` as well as unrelated :py:exc:`RuntimeError`
|
|
and :py:exc:`socket.error(-1) <socket.error>` on the server socket when
|
|
:py:meth:`socket.shutdown() <socket.socket.shutdown>` is called. It's
|
|
triggered by closing the client socket before the server had a chance
|
|
to respond.
|
|
|
|
The expectation is that only :py:exc:`RuntimeError` and a
|
|
:py:exc:`socket.error` with an unusual error code would leak.
|
|
|
|
With the :py:data:`None`-parameter, a real non-simulated
|
|
:py:exc:`OSError(107, 'Transport endpoint is not connected')
|
|
<OSError>` happens.
|
|
"""
|
|
exc_instance = (
|
|
None if simulated_exception is None
|
|
else simulated_exception(error_number, 'Simulated socket error')
|
|
)
|
|
old_close_kernel_socket = (
|
|
test_client.server_instance.
|
|
ConnectionClass._close_kernel_socket
|
|
)
|
|
|
|
def _close_kernel_socket(self):
|
|
monkeypatch.setattr( # `socket.shutdown` is read-only otherwise
|
|
self, 'socket',
|
|
mocker.mock_module.Mock(wraps=self.socket),
|
|
)
|
|
if exc_instance is not None:
|
|
monkeypatch.setattr(
|
|
self.socket, 'shutdown',
|
|
mocker.mock_module.Mock(side_effect=exc_instance),
|
|
)
|
|
_close_kernel_socket.fin_spy = mocker.spy(self.socket, 'shutdown')
|
|
|
|
try:
|
|
old_close_kernel_socket(self)
|
|
except simulated_exception:
|
|
_close_kernel_socket.exception_leaked = True
|
|
else:
|
|
_close_kernel_socket.exception_leaked = False
|
|
|
|
monkeypatch.setattr(
|
|
test_client.server_instance.ConnectionClass,
|
|
'_close_kernel_socket',
|
|
_close_kernel_socket,
|
|
)
|
|
|
|
conn = test_client.get_connection()
|
|
conn.auto_open = False
|
|
conn.connect()
|
|
conn.send(b'GET /hello HTTP/1.1')
|
|
conn.send(('Host: %s' % conn.host).encode('ascii'))
|
|
conn.close()
|
|
|
|
# Let the server attempt TCP shutdown:
|
|
for _ in range(10 * (2 if IS_SLOW_ENV else 1)):
|
|
time.sleep(0.1)
|
|
if hasattr(_close_kernel_socket, 'exception_leaked'):
|
|
break
|
|
|
|
if exc_instance is not None: # simulated by us
|
|
assert _close_kernel_socket.fin_spy.spy_exception is exc_instance
|
|
else: # real
|
|
assert isinstance(
|
|
_close_kernel_socket.fin_spy.spy_exception, socket.error,
|
|
)
|
|
assert _close_kernel_socket.fin_spy.spy_exception.errno == error_number
|
|
|
|
assert _close_kernel_socket.exception_leaked is exception_leaks
|
|
|
|
|
|
def test_broken_connection_during_http_communication_fallback( # noqa: WPS118
|
|
monkeypatch,
|
|
test_client,
|
|
testing_server,
|
|
wsgi_server_thread,
|
|
):
|
|
"""Test that unhandled internal error cascades into shutdown."""
|
|
def _raise_connection_reset(*_args, **_kwargs):
|
|
raise ConnectionResetError(666)
|
|
|
|
def _read_request_line(self):
|
|
monkeypatch.setattr(self.conn.rfile, 'close', _raise_connection_reset)
|
|
monkeypatch.setattr(self.conn.wfile, 'write', _raise_connection_reset)
|
|
_raise_connection_reset()
|
|
|
|
monkeypatch.setattr(
|
|
test_client.server_instance.ConnectionClass.RequestHandlerClass,
|
|
'read_request_line',
|
|
_read_request_line,
|
|
)
|
|
|
|
test_client.get_connection().send(b'GET / HTTP/1.1')
|
|
wsgi_server_thread.join() # no extra logs upon server termination
|
|
|
|
actual_log_entries = testing_server.error_log.calls[:]
|
|
testing_server.error_log.calls.clear() # prevent post-test assertions
|
|
|
|
expected_log_entries = (
|
|
(logging.WARNING, r'^socket\.error 666$'),
|
|
(
|
|
logging.INFO,
|
|
'^Got a connection error while handling a connection '
|
|
r'from .*:\d{1,5} \(666\)',
|
|
),
|
|
(
|
|
logging.CRITICAL,
|
|
r'A fatal exception happened\. Setting the server interrupt flag '
|
|
r'to ConnectionResetError\(666,?\) and giving up\.\n\nPlease, '
|
|
'report this on the Cheroot tracker at '
|
|
r'<https://github\.com/cherrypy/cheroot/issues/new/choose>, '
|
|
'providing a full reproducer with as much context and details '
|
|
r'as possible\.$',
|
|
),
|
|
)
|
|
|
|
assert len(actual_log_entries) == len(expected_log_entries)
|
|
|
|
for ( # noqa: WPS352
|
|
(expected_log_level, expected_msg_regex),
|
|
(actual_msg, actual_log_level, _tb),
|
|
) in zip(expected_log_entries, actual_log_entries):
|
|
assert expected_log_level == actual_log_level
|
|
assert _matches_pattern(expected_msg_regex, actual_msg) is not None, (
|
|
f'{actual_msg !r} does not match {expected_msg_regex !r}'
|
|
)
|
|
|
|
|
|
def test_kb_int_from_http_handler(
|
|
test_client,
|
|
testing_server,
|
|
wsgi_server_thread,
|
|
):
|
|
"""Test that a keyboard interrupt from HTTP handler causes shutdown."""
|
|
def _trigger_kb_intr(_req, _resp):
|
|
raise KeyboardInterrupt('simulated test handler keyboard interrupt')
|
|
testing_server.wsgi_app.handlers['/kb_intr'] = _trigger_kb_intr
|
|
|
|
http_conn = test_client.get_connection()
|
|
http_conn.putrequest('GET', '/kb_intr', skip_host=True)
|
|
http_conn.putheader('Host', http_conn.host)
|
|
http_conn.endheaders()
|
|
wsgi_server_thread.join() # no extra logs upon server termination
|
|
|
|
actual_log_entries = testing_server.error_log.calls[:]
|
|
testing_server.error_log.calls.clear() # prevent post-test assertions
|
|
|
|
expected_log_entries = (
|
|
(
|
|
logging.DEBUG,
|
|
'^Got a server shutdown request while handling a connection '
|
|
r'from .*:\d{1,5} \(simulated test handler keyboard interrupt\)$',
|
|
),
|
|
(
|
|
logging.DEBUG,
|
|
'^Setting the server interrupt flag to KeyboardInterrupt'
|
|
r"\('simulated test handler keyboard interrupt',?\)$",
|
|
),
|
|
(
|
|
logging.INFO,
|
|
'^Keyboard Interrupt: shutting down$',
|
|
),
|
|
)
|
|
|
|
assert len(actual_log_entries) == len(expected_log_entries)
|
|
|
|
for ( # noqa: WPS352
|
|
(expected_log_level, expected_msg_regex),
|
|
(actual_msg, actual_log_level, _tb),
|
|
) in zip(expected_log_entries, actual_log_entries):
|
|
assert expected_log_level == actual_log_level
|
|
assert _matches_pattern(expected_msg_regex, actual_msg) is not None, (
|
|
f'{actual_msg !r} does not match {expected_msg_regex !r}'
|
|
)
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
IS_CI and IS_PYPY and IS_PY36 and not IS_SLOW_ENV,
|
|
reason='Fails under PyPy 3.6 under Ubuntu 20.04 in CI for unknown reason',
|
|
# NOTE: Actually covers any Linux
|
|
strict=False,
|
|
)
|
|
def test_unhandled_exception_in_request_handler(
|
|
mocker,
|
|
monkeypatch,
|
|
test_client,
|
|
testing_server,
|
|
wsgi_server_thread,
|
|
):
|
|
"""Ensure worker threads are resilient to in-handler exceptions."""
|
|
|
|
class SillyMistake(BaseException): # noqa: WPS418, WPS431
|
|
"""A simulated crash within an HTTP handler."""
|
|
|
|
def _trigger_scary_exc(_req, _resp):
|
|
raise SillyMistake('simulated unhandled exception 💣 in test handler')
|
|
|
|
testing_server.wsgi_app.handlers['/scary_exc'] = _trigger_scary_exc
|
|
|
|
server_connection_close_spy = mocker.spy(
|
|
test_client.server_instance.ConnectionClass,
|
|
'close',
|
|
)
|
|
|
|
http_conn = test_client.get_connection()
|
|
http_conn.putrequest('GET', '/scary_exc', skip_host=True)
|
|
http_conn.putheader('Host', http_conn.host)
|
|
http_conn.endheaders()
|
|
|
|
# NOTE: This spy ensure the log entry gets recorded before we're testing
|
|
# NOTE: them and before server shutdown, preserving their order and making
|
|
# NOTE: the log entry presence non-flaky.
|
|
while not server_connection_close_spy.called: # noqa: WPS328
|
|
pass
|
|
|
|
assert len(testing_server.requests._threads) == 10
|
|
while testing_server.requests.idle < 10: # noqa: WPS328
|
|
pass
|
|
assert len(testing_server.requests._threads) == 10
|
|
testing_server.interrupt = SystemExit('test requesting shutdown')
|
|
assert not testing_server.requests._threads
|
|
wsgi_server_thread.join() # no extra logs upon server termination
|
|
|
|
actual_log_entries = testing_server.error_log.calls[:]
|
|
testing_server.error_log.calls.clear() # prevent post-test assertions
|
|
|
|
expected_log_entries = (
|
|
(
|
|
logging.ERROR,
|
|
'^Unhandled error while processing an incoming connection '
|
|
'SillyMistake'
|
|
r"\('simulated unhandled exception 💣 in test handler',?\)$",
|
|
),
|
|
(
|
|
logging.INFO,
|
|
'^SystemExit raised: shutting down$',
|
|
),
|
|
)
|
|
|
|
assert len(actual_log_entries) == len(expected_log_entries)
|
|
|
|
for ( # noqa: WPS352
|
|
(expected_log_level, expected_msg_regex),
|
|
(actual_msg, actual_log_level, _tb),
|
|
) in zip(expected_log_entries, actual_log_entries):
|
|
assert expected_log_level == actual_log_level
|
|
assert _matches_pattern(expected_msg_regex, actual_msg) is not None, (
|
|
f'{actual_msg !r} does not match {expected_msg_regex !r}'
|
|
)
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
IS_CI and IS_PYPY and IS_PY36 and not IS_SLOW_ENV,
|
|
reason='Fails under PyPy 3.6 under Ubuntu 20.04 in CI for unknown reason',
|
|
# NOTE: Actually covers any Linux
|
|
strict=False,
|
|
)
|
|
def test_remains_alive_post_unhandled_exception(
|
|
mocker,
|
|
monkeypatch,
|
|
test_client,
|
|
testing_server,
|
|
wsgi_server_thread,
|
|
):
|
|
"""Ensure worker threads are resilient to unhandled exceptions."""
|
|
|
|
class ScaryCrash(BaseException): # noqa: WPS418, WPS431
|
|
"""A simulated crash during HTTP parsing."""
|
|
|
|
_orig_read_request_line = (
|
|
test_client.server_instance.
|
|
ConnectionClass.RequestHandlerClass.
|
|
read_request_line
|
|
)
|
|
|
|
def _read_request_line(self):
|
|
_orig_read_request_line(self)
|
|
raise ScaryCrash(666)
|
|
|
|
monkeypatch.setattr(
|
|
test_client.server_instance.ConnectionClass.RequestHandlerClass,
|
|
'read_request_line',
|
|
_read_request_line,
|
|
)
|
|
|
|
server_connection_close_spy = mocker.spy(
|
|
test_client.server_instance.ConnectionClass,
|
|
'close',
|
|
)
|
|
|
|
# NOTE: The initial worker thread count is 10.
|
|
assert len(testing_server.requests._threads) == 10
|
|
|
|
test_client.get_connection().send(b'GET / HTTP/1.1')
|
|
|
|
# NOTE: This spy ensure the log entry gets recorded before we're testing
|
|
# NOTE: them and before server shutdown, preserving their order and making
|
|
# NOTE: the log entry presence non-flaky.
|
|
while not server_connection_close_spy.called: # noqa: WPS328
|
|
pass
|
|
|
|
# NOTE: This checks for whether there's any crashed threads
|
|
while testing_server.requests.idle < 10: # noqa: WPS328
|
|
pass
|
|
assert len(testing_server.requests._threads) == 10
|
|
assert all(
|
|
worker_thread.is_alive()
|
|
for worker_thread in testing_server.requests._threads
|
|
)
|
|
testing_server.interrupt = SystemExit('test requesting shutdown')
|
|
assert not testing_server.requests._threads
|
|
wsgi_server_thread.join() # no extra logs upon server termination
|
|
|
|
actual_log_entries = testing_server.error_log.calls[:]
|
|
testing_server.error_log.calls.clear() # prevent post-test assertions
|
|
|
|
expected_log_entries = (
|
|
(
|
|
logging.ERROR,
|
|
'^Unhandled error while processing an incoming connection '
|
|
r'ScaryCrash\(666,?\)$',
|
|
),
|
|
(
|
|
logging.INFO,
|
|
'^SystemExit raised: shutting down$',
|
|
),
|
|
)
|
|
|
|
assert len(actual_log_entries) == len(expected_log_entries)
|
|
|
|
for ( # noqa: WPS352
|
|
(expected_log_level, expected_msg_regex),
|
|
(actual_msg, actual_log_level, _tb),
|
|
) in zip(expected_log_entries, actual_log_entries):
|
|
assert expected_log_level == actual_log_level
|
|
assert _matches_pattern(expected_msg_regex, actual_msg) is not None, (
|
|
f'{actual_msg !r} does not match {expected_msg_regex !r}'
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'timeout_before_headers',
|
|
(
|
|
True,
|
|
False,
|
|
),
|
|
)
|
|
def test_HTTP11_Timeout(test_client, timeout_before_headers):
|
|
"""Check timeout without sending any data.
|
|
|
|
The server will close the connection with a 408.
|
|
"""
|
|
conn = test_client.get_connection()
|
|
conn.auto_open = False
|
|
conn.connect()
|
|
|
|
if not timeout_before_headers:
|
|
# Connect but send half the headers only.
|
|
conn.send(b'GET /hello HTTP/1.1')
|
|
conn.send(('Host: %s' % conn.host).encode('ascii'))
|
|
# else: Connect but send nothing.
|
|
|
|
# Wait for our socket timeout
|
|
time.sleep(timeout * 2)
|
|
|
|
# The request should have returned 408 already.
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
response.begin()
|
|
assert response.status == 408
|
|
conn.close()
|
|
|
|
|
|
def test_HTTP11_Timeout_after_request(test_client):
|
|
"""Check timeout after at least one request has succeeded.
|
|
|
|
The server should close the connection without 408.
|
|
"""
|
|
fail_msg = "Writing to timed out socket didn't fail as it should have: %s"
|
|
|
|
# Make an initial request
|
|
conn = test_client.get_connection()
|
|
conn.putrequest('GET', '/timeout?t=%s' % timeout, skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.endheaders()
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
response.begin()
|
|
assert response.status == 200
|
|
actual_body = response.read()
|
|
expected_body = str(timeout).encode()
|
|
assert actual_body == expected_body
|
|
|
|
# Make a second request on the same socket
|
|
conn._output(b'GET /hello HTTP/1.1')
|
|
conn._output(('Host: %s' % conn.host).encode('ascii'))
|
|
conn._send_output()
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
response.begin()
|
|
assert response.status == 200
|
|
actual_body = response.read()
|
|
expected_body = b'Hello, world!'
|
|
assert actual_body == expected_body
|
|
|
|
# Wait for our socket timeout
|
|
time.sleep(timeout * 2)
|
|
|
|
# Make another request on the same socket, which should error
|
|
conn._output(b'GET /hello HTTP/1.1')
|
|
conn._output(('Host: %s' % conn.host).encode('ascii'))
|
|
conn._send_output()
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
try:
|
|
response.begin()
|
|
except (socket.error, http.client.BadStatusLine):
|
|
pass
|
|
except Exception as ex:
|
|
pytest.fail(fail_msg % ex)
|
|
else:
|
|
if response.status != 408:
|
|
pytest.fail(fail_msg % response.read())
|
|
|
|
conn.close()
|
|
|
|
# Make another request on a new socket, which should work
|
|
conn = test_client.get_connection()
|
|
conn.putrequest('GET', '/pov', skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.endheaders()
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
response.begin()
|
|
assert response.status == 200
|
|
actual_body = response.read()
|
|
expected_body = pov.encode()
|
|
assert actual_body == expected_body
|
|
|
|
# Make another request on the same socket,
|
|
# but timeout on the headers
|
|
conn.send(b'GET /hello HTTP/1.1')
|
|
# Wait for our socket timeout
|
|
time.sleep(timeout * 2)
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
try:
|
|
response.begin()
|
|
except (socket.error, http.client.BadStatusLine):
|
|
pass
|
|
except Exception as ex:
|
|
pytest.fail(fail_msg % ex)
|
|
else:
|
|
if response.status != 408:
|
|
pytest.fail(fail_msg % response.read())
|
|
|
|
conn.close()
|
|
|
|
# Retry the request on a new connection, which should work
|
|
conn = test_client.get_connection()
|
|
conn.putrequest('GET', '/pov', skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.endheaders()
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
response.begin()
|
|
assert response.status == 200
|
|
actual_body = response.read()
|
|
expected_body = pov.encode()
|
|
assert actual_body == expected_body
|
|
conn.close()
|
|
|
|
|
|
def test_HTTP11_pipelining(test_client):
|
|
"""Test HTTP/1.1 pipelining.
|
|
|
|
:py:mod:`http.client` doesn't support this directly.
|
|
"""
|
|
conn = test_client.get_connection()
|
|
|
|
# Put request 1
|
|
conn.putrequest('GET', '/hello', skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.endheaders()
|
|
|
|
for trial in range(5):
|
|
# Put next request
|
|
conn._output(
|
|
('GET /hello?%s HTTP/1.1' % trial).encode('iso-8859-1'),
|
|
)
|
|
conn._output(('Host: %s' % conn.host).encode('ascii'))
|
|
conn._send_output()
|
|
|
|
# Retrieve previous response
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
# there is a bug in python3 regarding the buffering of
|
|
# ``conn.sock``. Until that bug get's fixed we will
|
|
# monkey patch the ``response`` instance.
|
|
# https://bugs.python.org/issue23377
|
|
response.fp = conn.sock.makefile('rb', 0)
|
|
response.begin()
|
|
body = response.read(13)
|
|
assert response.status == 200
|
|
assert body == b'Hello, world!'
|
|
|
|
# Retrieve final response
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
response.begin()
|
|
body = response.read()
|
|
assert response.status == 200
|
|
assert body == b'Hello, world!'
|
|
|
|
conn.close()
|
|
|
|
|
|
def test_100_Continue(test_client):
|
|
"""Test 100-continue header processing."""
|
|
conn = test_client.get_connection()
|
|
|
|
# Try a page without an Expect request header first.
|
|
# Note that http.client's response.begin automatically ignores
|
|
# 100 Continue responses, so we must manually check for it.
|
|
conn.putrequest('POST', '/upload', skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.putheader('Content-Type', 'text/plain')
|
|
conn.putheader('Content-Length', '4')
|
|
conn.endheaders()
|
|
conn.send(b"d'oh")
|
|
response = conn.response_class(conn.sock, method='POST')
|
|
_version, status, _reason = response._read_status()
|
|
assert status != 100
|
|
conn.close()
|
|
|
|
# Now try a page with an Expect header...
|
|
conn.connect()
|
|
conn.putrequest('POST', '/upload', skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.putheader('Content-Type', 'text/plain')
|
|
conn.putheader('Content-Length', '17')
|
|
conn.putheader('Expect', '100-continue')
|
|
conn.endheaders()
|
|
response = conn.response_class(conn.sock, method='POST')
|
|
|
|
# ...assert and then skip the 100 response
|
|
version, status, reason = response._read_status()
|
|
assert status == 100
|
|
while True:
|
|
line = response.fp.readline().strip()
|
|
if line:
|
|
pytest.fail(
|
|
'100 Continue should not output any headers. Got %r' %
|
|
line,
|
|
)
|
|
else:
|
|
break
|
|
|
|
# ...send the body
|
|
body = b'I am a small file'
|
|
conn.send(body)
|
|
|
|
# ...get the final response
|
|
response.begin()
|
|
status_line, _actual_headers, actual_resp_body = webtest.shb(response)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
expected_resp_body = f"thanks for '{body.decode() !s}'".encode()
|
|
assert actual_resp_body == expected_resp_body
|
|
conn.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'max_request_body_size',
|
|
(
|
|
0,
|
|
1001,
|
|
),
|
|
)
|
|
def test_readall_or_close(test_client, max_request_body_size):
|
|
"""Test a max_request_body_size of 0 (the default) and 1001."""
|
|
old_max = test_client.server_instance.max_request_body_size
|
|
|
|
test_client.server_instance.max_request_body_size = max_request_body_size
|
|
|
|
conn = test_client.get_connection()
|
|
|
|
# Get a POST page with an error
|
|
conn.putrequest('POST', '/err_before_read', skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.putheader('Content-Type', 'text/plain')
|
|
conn.putheader('Content-Length', '1000')
|
|
conn.putheader('Expect', '100-continue')
|
|
conn.endheaders()
|
|
response = conn.response_class(conn.sock, method='POST')
|
|
|
|
# ...assert and then skip the 100 response
|
|
_version, status, _reason = response._read_status()
|
|
assert status == 100
|
|
skip = True
|
|
while skip:
|
|
skip = response.fp.readline().strip()
|
|
|
|
# ...send the body
|
|
conn.send(b'x' * 1000)
|
|
|
|
# ...get the final response
|
|
response.begin()
|
|
status_line, _actual_headers, actual_resp_body = webtest.shb(response)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 500
|
|
|
|
# Now try a working page with an Expect header...
|
|
conn._output(b'POST /upload HTTP/1.1')
|
|
conn._output(('Host: %s' % conn.host).encode('ascii'))
|
|
conn._output(b'Content-Type: text/plain')
|
|
conn._output(b'Content-Length: 17')
|
|
conn._output(b'Expect: 100-continue')
|
|
conn._send_output()
|
|
response = conn.response_class(conn.sock, method='POST')
|
|
|
|
# ...assert and then skip the 100 response
|
|
version, status, reason = response._read_status()
|
|
assert status == 100
|
|
skip = True
|
|
while skip:
|
|
skip = response.fp.readline().strip()
|
|
|
|
# ...send the body
|
|
body = b'I am a small file'
|
|
conn.send(body)
|
|
|
|
# ...get the final response
|
|
response.begin()
|
|
status_line, actual_headers, actual_resp_body = webtest.shb(response)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
expected_resp_body = f"thanks for '{body.decode() !s}'".encode()
|
|
assert actual_resp_body == expected_resp_body
|
|
conn.close()
|
|
|
|
test_client.server_instance.max_request_body_size = old_max
|
|
|
|
|
|
def test_No_Message_Body(test_client):
|
|
"""Test HTTP queries with an empty response body."""
|
|
# Initialize a persistent HTTP connection
|
|
http_connection = test_client.get_connection()
|
|
http_connection.auto_open = False
|
|
http_connection.connect()
|
|
|
|
# Make the first request and assert there's no "Connection: close".
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/pov', http_conn=http_connection,
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
assert actual_resp_body == pov.encode()
|
|
assert not header_exists('Connection', actual_headers)
|
|
|
|
# Make a 204 request on the same connection.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/custom/204', http_conn=http_connection,
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 204
|
|
assert not header_exists('Content-Length', actual_headers)
|
|
assert actual_resp_body == b''
|
|
assert not header_exists('Connection', actual_headers)
|
|
|
|
# Make a 304 request on the same connection.
|
|
status_line, actual_headers, actual_resp_body = test_client.get(
|
|
'/custom/304', http_conn=http_connection,
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 304
|
|
assert not header_exists('Content-Length', actual_headers)
|
|
assert actual_resp_body == b''
|
|
assert not header_exists('Connection', actual_headers)
|
|
|
|
# Prevent the resource warnings:
|
|
http_connection.close()
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
reason=unwrap(
|
|
trim("""
|
|
Headers from earlier request leak into the request
|
|
line for a subsequent request, resulting in 400
|
|
instead of 413. See cherrypy/cheroot#69 for details.
|
|
"""),
|
|
),
|
|
)
|
|
def test_Chunked_Encoding(test_client):
|
|
"""Test HTTP uploads with chunked transfer-encoding."""
|
|
# Initialize a persistent HTTP connection
|
|
conn = test_client.get_connection()
|
|
|
|
# Try a normal chunked request (with extensions)
|
|
body = (
|
|
b'8;key=value\r\nxx\r\nxxxx\r\n5\r\nyyyyy\r\n0\r\n'
|
|
b'Content-Type: application/json\r\n'
|
|
b'\r\n'
|
|
)
|
|
conn.putrequest('POST', '/upload', skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.putheader('Transfer-Encoding', 'chunked')
|
|
conn.putheader('Trailer', 'Content-Type')
|
|
# Note that this is somewhat malformed:
|
|
# we shouldn't be sending Content-Length.
|
|
# RFC 2616 says the server should ignore it.
|
|
conn.putheader('Content-Length', '3')
|
|
conn.endheaders()
|
|
conn.send(body)
|
|
response = conn.getresponse()
|
|
status_line, _actual_headers, actual_resp_body = webtest.shb(response)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 200
|
|
assert status_line[4:] == 'OK'
|
|
expected_resp_body = ("thanks for '%s'" % b'xx\r\nxxxxyyyyy').encode()
|
|
assert actual_resp_body == expected_resp_body
|
|
|
|
# Try a chunked request that exceeds server.max_request_body_size.
|
|
# Note that the delimiters and trailer are included.
|
|
body = b'\r\n'.join((b'3e3', b'x' * 995, b'0', b'', b''))
|
|
conn.putrequest('POST', '/upload', skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.putheader('Transfer-Encoding', 'chunked')
|
|
conn.putheader('Content-Type', 'text/plain')
|
|
# Chunked requests don't need a content-length
|
|
# conn.putheader("Content-Length", len(body))
|
|
conn.endheaders()
|
|
conn.send(body)
|
|
response = conn.getresponse()
|
|
status_line, actual_headers, actual_resp_body = webtest.shb(response)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 413
|
|
conn.close()
|
|
|
|
|
|
def test_Content_Length_in(test_client):
|
|
"""Try a non-chunked request where Content-Length exceeds limit.
|
|
|
|
(server.max_request_body_size).
|
|
Assert error before body send.
|
|
"""
|
|
# Initialize a persistent HTTP connection
|
|
conn = test_client.get_connection()
|
|
|
|
conn.putrequest('POST', '/upload', skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.putheader('Content-Type', 'text/plain')
|
|
conn.putheader('Content-Length', '9999')
|
|
conn.endheaders()
|
|
response = conn.getresponse()
|
|
status_line, _actual_headers, actual_resp_body = webtest.shb(response)
|
|
actual_status = int(status_line[:3])
|
|
assert actual_status == 413
|
|
expected_resp_body = (
|
|
b'The entity sent with the request exceeds '
|
|
b'the maximum allowed bytes.'
|
|
)
|
|
assert actual_resp_body == expected_resp_body
|
|
conn.close()
|
|
|
|
|
|
def test_Content_Length_not_int(test_client):
|
|
"""Test that malicious Content-Length header returns 400."""
|
|
status_line, _actual_headers, actual_resp_body = test_client.post(
|
|
'/upload',
|
|
headers=[
|
|
('Content-Type', 'text/plain'),
|
|
('Content-Length', 'not-an-integer'),
|
|
],
|
|
)
|
|
actual_status = int(status_line[:3])
|
|
|
|
assert actual_status == 400
|
|
assert actual_resp_body == b'Malformed Content-Length Header.'
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('uri', 'expected_resp_status', 'expected_resp_body'),
|
|
(
|
|
(
|
|
'/wrong_cl_buffered', 500,
|
|
(
|
|
b'The requested resource returned more bytes than the '
|
|
b'declared Content-Length.'
|
|
),
|
|
),
|
|
('/wrong_cl_unbuffered', 200, b'I too'),
|
|
),
|
|
)
|
|
def test_Content_Length_out(
|
|
test_client,
|
|
uri, expected_resp_status, expected_resp_body,
|
|
):
|
|
"""Test response with Content-Length less than the response body.
|
|
|
|
(non-chunked response)
|
|
"""
|
|
conn = test_client.get_connection()
|
|
conn.putrequest('GET', uri, skip_host=True)
|
|
conn.putheader('Host', conn.host)
|
|
conn.endheaders()
|
|
|
|
response = conn.getresponse()
|
|
status_line, _actual_headers, actual_resp_body = webtest.shb(response)
|
|
actual_status = int(status_line[:3])
|
|
|
|
assert actual_status == expected_resp_status
|
|
assert actual_resp_body == expected_resp_body
|
|
|
|
conn.close()
|
|
|
|
# the server logs the exception that we had verified from the
|
|
# client perspective. Tell the error_log verification that
|
|
# it can ignore that message.
|
|
test_client.server_instance.error_log.ignored_msgs.extend((
|
|
# Python 3.7+:
|
|
"ValueError('Response body exceeds the declared Content-Length.')",
|
|
# Python 2.7-3.6 (macOS?):
|
|
"ValueError('Response body exceeds the declared Content-Length.',)",
|
|
))
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
reason='Sometimes this test fails due to low timeout. '
|
|
'Ref: https://github.com/cherrypy/cherrypy/issues/598',
|
|
)
|
|
def test_598(test_client):
|
|
"""Test serving large file with a read timeout in place."""
|
|
# Initialize a persistent HTTP connection
|
|
conn = test_client.get_connection()
|
|
remote_data_conn = urllib.request.urlopen(
|
|
'%s://%s:%s/one_megabyte_of_a'
|
|
% ('http', conn.host, conn.port),
|
|
)
|
|
buf = remote_data_conn.read(512)
|
|
time.sleep(timeout * 0.6)
|
|
remaining = (1024 * 1024) - 512
|
|
while remaining:
|
|
data = remote_data_conn.read(remaining)
|
|
if not data:
|
|
break
|
|
buf += data
|
|
remaining -= len(data)
|
|
|
|
assert len(buf) == 1024 * 1024
|
|
assert buf == b'a' * 1024 * 1024
|
|
assert remaining == 0
|
|
remote_data_conn.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'invalid_terminator',
|
|
(
|
|
b'\n\n',
|
|
b'\r\n\n',
|
|
),
|
|
)
|
|
def test_No_CRLF(test_client, invalid_terminator):
|
|
"""Test HTTP queries with no valid CRLF terminators."""
|
|
# Initialize a persistent HTTP connection
|
|
conn = test_client.get_connection()
|
|
|
|
conn.send(b'GET /hello HTTP/1.1%s' % invalid_terminator)
|
|
response = conn.response_class(conn.sock, method='GET')
|
|
response.begin()
|
|
actual_resp_body = response.read()
|
|
expected_resp_body = b'HTTP requires CRLF terminators'
|
|
assert actual_resp_body == expected_resp_body
|
|
conn.close()
|
|
|
|
|
|
class FaultySelect:
|
|
"""Mock class to insert errors in the selector.select method."""
|
|
|
|
def __init__(self, original_select):
|
|
"""Initilize helper class to wrap the selector.select method."""
|
|
self.original_select = original_select
|
|
self.request_served = False
|
|
self.os_error_triggered = False
|
|
|
|
def __call__(self, timeout):
|
|
"""Intercept the calls to selector.select."""
|
|
if self.request_served:
|
|
self.os_error_triggered = True
|
|
raise OSError('Error while selecting the client socket.')
|
|
|
|
return self.original_select(timeout)
|
|
|
|
|
|
class FaultyGetMap:
|
|
"""Mock class to insert errors in the selector.get_map method."""
|
|
|
|
def __init__(self, original_get_map):
|
|
"""Initilize helper class to wrap the selector.get_map method."""
|
|
self.original_get_map = original_get_map
|
|
self.sabotage_conn = False
|
|
self.conn_closed = False
|
|
|
|
def __call__(self):
|
|
"""Intercept the calls to selector.get_map."""
|
|
sabotage_targets = (
|
|
conn for _, (_, _, _, conn) in self.original_get_map().items()
|
|
if isinstance(conn, cheroot.server.HTTPConnection)
|
|
) if self.sabotage_conn and not self.conn_closed else ()
|
|
|
|
for conn in sabotage_targets:
|
|
# close the socket to cause OSError
|
|
conn.close()
|
|
self.conn_closed = True
|
|
|
|
return self.original_get_map()
|
|
|
|
|
|
def test_invalid_selected_connection(test_client, monkeypatch):
|
|
"""Test the error handling segment of HTTP connection selection.
|
|
|
|
See :py:meth:`cheroot.connections.ConnectionManager.get_conn`.
|
|
"""
|
|
# patch the select method
|
|
faux_select = FaultySelect(
|
|
test_client.server_instance._connections._selector.select,
|
|
)
|
|
monkeypatch.setattr(
|
|
test_client.server_instance._connections._selector,
|
|
'select',
|
|
faux_select,
|
|
)
|
|
|
|
# patch the get_map method
|
|
faux_get_map = FaultyGetMap(
|
|
test_client.server_instance._connections._selector._selector.get_map,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
test_client.server_instance._connections._selector._selector,
|
|
'get_map',
|
|
faux_get_map,
|
|
)
|
|
|
|
# request a page with connection keep-alive to make sure
|
|
# we'll have a connection to be modified.
|
|
resp_status, _resp_headers, _resp_body = test_client.request(
|
|
'/page1', headers=[('Connection', 'Keep-Alive')],
|
|
)
|
|
|
|
assert resp_status == '200 OK'
|
|
# trigger the internal errors
|
|
faux_get_map.sabotage_conn = faux_select.request_served = True
|
|
# give time to make sure the error gets handled
|
|
time.sleep(test_client.server_instance.expiration_interval * 2)
|
|
assert faux_select.os_error_triggered
|
|
assert faux_get_map.conn_closed
|