From b41aea4873319df15ccb9145a4940ff1702d123a Mon Sep 17 00:00:00 2001 From: Andrey Rakhmatullin Date: Tue, 27 May 2025 18:19:47 +0500 Subject: [PATCH] Restructure download handler tests. (#6821) * Restructure download handler tests. * Typo. * Use mixins to reduce boilerplate. --- .../test_downloader_handler_twisted_http10.py | 46 ++ .../test_downloader_handler_twisted_http11.py | 69 ++ ... test_downloader_handler_twisted_http2.py} | 108 ++- tests/test_downloader_handlers.py | 719 +----------------- tests/test_downloader_handlers_http_base.py | 698 +++++++++++++++++ 5 files changed, 862 insertions(+), 778 deletions(-) create mode 100644 tests/test_downloader_handler_twisted_http10.py create mode 100644 tests/test_downloader_handler_twisted_http11.py rename tests/{test_downloader_handlers_http2.py => test_downloader_handler_twisted_http2.py} (73%) create mode 100644 tests/test_downloader_handlers_http_base.py diff --git a/tests/test_downloader_handler_twisted_http10.py b/tests/test_downloader_handler_twisted_http10.py new file mode 100644 index 000000000..807c8c4cb --- /dev/null +++ b/tests/test_downloader_handler_twisted_http10.py @@ -0,0 +1,46 @@ +"""Tests for scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler +from scrapy.http import Request +from scrapy.spiders import Spider +from tests.test_downloader_handlers_http_base import TestHttpBase, TestHttpProxyBase + +if TYPE_CHECKING: + from scrapy.core.downloader.handlers import DownloadHandlerProtocol + + +class HTTP10DownloadHandlerMixin: + @property + def download_handler_cls(self) -> type[DownloadHandlerProtocol]: + return HTTP10DownloadHandler + + +@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") +class TestHttp10(HTTP10DownloadHandlerMixin, TestHttpBase): + """HTTP 1.0 test case""" + + def test_protocol(self): + request = Request(self.getURL("host"), method="GET") + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.protocol) + d.addCallback(self.assertEqual, "HTTP/1.0") + return d + + +class TestHttps10(TestHttp10): + scheme = "https" + + +@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") +class TestHttp10Proxy(HTTP10DownloadHandlerMixin, TestHttpProxyBase): + def test_download_with_proxy_https_timeout(self): + pytest.skip("Not implemented") + + def test_download_with_proxy_without_http_scheme(self): + pytest.skip("Not implemented") diff --git a/tests/test_downloader_handler_twisted_http11.py b/tests/test_downloader_handler_twisted_http11.py new file mode 100644 index 000000000..70f55e787 --- /dev/null +++ b/tests/test_downloader_handler_twisted_http11.py @@ -0,0 +1,69 @@ +"""Tests for scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler +from tests.test_downloader_handlers_http_base import ( + TestHttp11Base, + TestHttpMockServerBase, + TestHttpProxyBase, + TestHttps11Base, + TestHttpsCustomCiphersBase, + TestHttpsInvalidDNSIdBase, + TestHttpsInvalidDNSPatternBase, + TestHttpsWrongHostnameBase, + TestSimpleHttpsBase, +) + +if TYPE_CHECKING: + from scrapy.core.downloader.handlers import DownloadHandlerProtocol + + +class HTTP11DownloadHandlerMixin: + @property + def download_handler_cls(self) -> type[DownloadHandlerProtocol]: + return HTTP11DownloadHandler + + +class TestHttp11(HTTP11DownloadHandlerMixin, TestHttp11Base): + pass + + +class TestHttps11(HTTP11DownloadHandlerMixin, TestHttps11Base): + pass + + +class TestSimpleHttps(HTTP11DownloadHandlerMixin, TestSimpleHttpsBase): + pass + + +class Https11WrongHostnameTestCase( + HTTP11DownloadHandlerMixin, TestHttpsWrongHostnameBase +): + pass + + +class Https11InvalidDNSId(HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): + pass + + +class Https11InvalidDNSPattern( + HTTP11DownloadHandlerMixin, TestHttpsInvalidDNSPatternBase +): + pass + + +class Https11CustomCiphers(HTTP11DownloadHandlerMixin, TestHttpsCustomCiphersBase): + pass + + +class TestHttp11MockServer(TestHttpMockServerBase): + @property + def settings_dict(self) -> dict[str, Any] | None: + return None # default handler settings + + +class TestHttp11Proxy(HTTP11DownloadHandlerMixin, TestHttpProxyBase): + pass diff --git a/tests/test_downloader_handlers_http2.py b/tests/test_downloader_handler_twisted_http2.py similarity index 73% rename from tests/test_downloader_handlers_http2.py rename to tests/test_downloader_handler_twisted_http2.py index c74c09cbb..46322a747 100644 --- a/tests/test_downloader_handlers_http2.py +++ b/tests/test_downloader_handler_twisted_http2.py @@ -1,4 +1,9 @@ +"""Tests for scrapy.core.downloader.handlers.http2.H2DownloadHandler.""" + +from __future__ import annotations + import json +from typing import TYPE_CHECKING, Any from unittest import mock import pytest @@ -8,60 +13,42 @@ from twisted.web import server from twisted.web.error import SchemeNotSupported from twisted.web.http import H2_ENABLED -from scrapy.core.downloader.handlers import DownloadHandlerProtocol from scrapy.http import Request from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.test import get_crawler from tests.mockserver import ssl_context_factory -from tests.test_downloader_handlers import ( +from tests.test_downloader_handlers_http_base import ( + TestHttpMockServerBase, + TestHttpProxyBase, + TestHttps11Base, + TestHttpsCustomCiphersBase, + TestHttpsInvalidDNSIdBase, + TestHttpsInvalidDNSPatternBase, + TestHttpsWrongHostnameBase, UriResource, ) +if TYPE_CHECKING: + from scrapy.core.downloader.handlers import DownloadHandlerProtocol + + pytestmark = pytest.mark.skipif( not H2_ENABLED, reason="HTTP/2 support in Twisted is not enabled" ) -class BaseTestClasses: - # A hack to prevent tests from the imported classes to run here too. - # See https://stackoverflow.com/q/1323455/113586 for other ways. - from tests.test_downloader_handlers import ( - TestHttp11MockServer as TestHttp11MockServer, - ) - from tests.test_downloader_handlers import ( - TestHttp11Proxy as TestHttp11Proxy, - ) - from tests.test_downloader_handlers import ( - TestHttps11 as TestHttps11, - ) - from tests.test_downloader_handlers import ( - TestHttps11CustomCiphers as TestHttps11CustomCiphers, - ) - from tests.test_downloader_handlers import ( - TestHttps11InvalidDNSId as TestHttps11InvalidDNSId, - ) - from tests.test_downloader_handlers import ( - TestHttps11InvalidDNSPattern as TestHttps11InvalidDNSPattern, - ) - from tests.test_downloader_handlers import ( - TestHttps11WrongHostname as TestHttps11WrongHostname, - ) - - -def _get_dh() -> type[DownloadHandlerProtocol]: - from scrapy.core.downloader.handlers.http2 import H2DownloadHandler - - return H2DownloadHandler - - -class TestHttps2(BaseTestClasses.TestHttps11): - scheme = "https" - HTTP2_DATALOSS_SKIP_REASON = "Content-Length mismatch raises InvalidBodyLengthError" - +class H2DownloadHandlerMixin: @property def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return _get_dh() + # the import can fail when H2_ENABLED is False + from scrapy.core.downloader.handlers.http2 import H2DownloadHandler + + return H2DownloadHandler + + +class TestHttps2(H2DownloadHandlerMixin, TestHttps11Base): + HTTP2_DATALOSS_SKIP_REASON = "Content-Length mismatch raises InvalidBodyLengthError" def test_protocol(self): request = Request(self.getURL("host"), method="GET") @@ -179,42 +166,37 @@ class TestHttps2(BaseTestClasses.TestHttps11): return d -class Https2WrongHostnameTestCase(BaseTestClasses.TestHttps11WrongHostname): - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return _get_dh() +class Https2WrongHostnameTestCase(H2DownloadHandlerMixin, TestHttpsWrongHostnameBase): + pass -class Https2InvalidDNSId(BaseTestClasses.TestHttps11InvalidDNSId): - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return _get_dh() +class Https2InvalidDNSId(H2DownloadHandlerMixin, TestHttpsInvalidDNSIdBase): + pass -class Https2InvalidDNSPattern(BaseTestClasses.TestHttps11InvalidDNSPattern): - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return _get_dh() +class Https2InvalidDNSPattern(H2DownloadHandlerMixin, TestHttpsInvalidDNSPatternBase): + pass -class Https2CustomCiphers(BaseTestClasses.TestHttps11CustomCiphers): - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return _get_dh() +class Https2CustomCiphers(H2DownloadHandlerMixin, TestHttpsCustomCiphersBase): + pass -class Http2MockServerTestCase(BaseTestClasses.TestHttp11MockServer): +class Http2MockServerTestCase(TestHttpMockServerBase): """HTTP 2.0 test case with MockServer""" - settings_dict = { - "DOWNLOAD_HANDLERS": { - "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler" + @property + def settings_dict(self) -> dict[str, Any] | None: + return { + "DOWNLOAD_HANDLERS": { + "https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler" + } } - } + is_secure = True -class Https2ProxyTestCase(BaseTestClasses.TestHttp11Proxy): +class Https2ProxyTestCase(H2DownloadHandlerMixin, TestHttpProxyBase): # only used for HTTPS tests keyfile = "keys/localhost.key" certfile = "keys/localhost.crt" @@ -224,10 +206,6 @@ class Https2ProxyTestCase(BaseTestClasses.TestHttp11Proxy): expected_http_proxy_request_body = b"/" - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return _get_dh() - def setUp(self): site = server.Site(UriResource(), timeout=None) self.port = reactor.listenSSL( diff --git a/tests/test_downloader_handlers.py b/tests/test_downloader_handlers.py index bc18e76e1..fc6ac5aee 100644 --- a/tests/test_downloader_handlers.py +++ b/tests/test_downloader_handlers.py @@ -1,52 +1,35 @@ +"""Tests for DownloadHandlers and for specific non-HTTP download handlers.""" + from __future__ import annotations import contextlib import os import shutil import sys -from abc import ABC, abstractmethod from pathlib import Path from tempfile import mkdtemp, mkstemp -from unittest import SkipTest, mock +from unittest import mock import pytest -from testfixtures import LogCapture from twisted.cred import checkers, credentials, portal -from twisted.internet import defer, error, reactor +from twisted.internet import reactor from twisted.protocols.ftp import FTPFactory, FTPRealm -from twisted.protocols.policies import WrappingFactory from twisted.trial import unittest -from twisted.web import resource, server, static, util -from twisted.web.client import ResponseFailed -from twisted.web.http import _DataLoss from w3lib.url import path_to_file_uri -from scrapy.core.downloader.handlers import DownloadHandlerProtocol, DownloadHandlers +from scrapy.core.downloader.handlers import DownloadHandlers from scrapy.core.downloader.handlers.datauri import DataURIDownloadHandler from scrapy.core.downloader.handlers.file import FileDownloadHandler from scrapy.core.downloader.handlers.ftp import FTPDownloadHandler -from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler -from scrapy.core.downloader.handlers.http11 import HTTP11DownloadHandler from scrapy.core.downloader.handlers.s3 import S3DownloadHandler from scrapy.exceptions import NotConfigured -from scrapy.http import Headers, HtmlResponse, Request +from scrapy.http import HtmlResponse, Request from scrapy.http.response.text import TextResponse from scrapy.responsetypes import responsetypes from scrapy.spiders import Spider from scrapy.utils.misc import build_from_crawler from scrapy.utils.python import to_bytes from scrapy.utils.test import get_crawler -from tests import NON_EXISTING_RESOLVABLE -from tests.mockserver import ( - Echo, - ForeverTakingResource, - HostHeaderResource, - MockServer, - NoLengthResource, - PayloadResource, - ssl_context_factory, -) -from tests.spiders import SingleRequestSpider class DummyDH: @@ -137,696 +120,6 @@ class TestFile(unittest.TestCase): return self.assertFailure(d, OSError) -class ContentLengthHeaderResource(resource.Resource): - """ - A testing resource which renders itself as the value of the Content-Length - header from the request. - """ - - def render(self, request): - return request.requestHeaders.getRawHeaders(b"content-length")[0] - - -class ChunkedResource(resource.Resource): - def render(self, request): - def response(): - request.write(b"chunked ") - request.write(b"content\n") - request.finish() - - reactor.callLater(0, response) - return server.NOT_DONE_YET - - -class BrokenChunkedResource(resource.Resource): - def render(self, request): - def response(): - request.write(b"chunked ") - request.write(b"content\n") - # Disable terminating chunk on finish. - request.chunked = False - closeConnection(request) - - reactor.callLater(0, response) - return server.NOT_DONE_YET - - -class BrokenDownloadResource(resource.Resource): - def render(self, request): - def response(): - request.setHeader(b"Content-Length", b"20") - request.write(b"partial") - closeConnection(request) - - reactor.callLater(0, response) - return server.NOT_DONE_YET - - -def closeConnection(request): - # We have to force a disconnection for HTTP/1.1 clients. Otherwise - # client keeps the connection open waiting for more data. - request.channel.loseConnection() - request.finish() - - -class EmptyContentTypeHeaderResource(resource.Resource): - """ - A testing resource which renders itself as the value of request body - without content-type header in response. - """ - - def render(self, request): - request.setHeader("content-type", "") - return request.content.read() - - -class LargeChunkedFileResource(resource.Resource): - def render(self, request): - def response(): - for i in range(1024): - request.write(b"x" * 1024) - request.finish() - - reactor.callLater(0, response) - return server.NOT_DONE_YET - - -class DuplicateHeaderResource(resource.Resource): - def render(self, request): - request.responseHeaders.setRawHeaders(b"Set-Cookie", [b"a=b", b"c=d"]) - return b"" - - -class TestHttp(unittest.TestCase, ABC): - scheme = "http" - - # only used for HTTPS tests - keyfile = "keys/localhost.key" - certfile = "keys/localhost.crt" - - @property - @abstractmethod - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - raise NotImplementedError - - def setUp(self): - self.tmpname = Path(mkdtemp()) - (self.tmpname / "file").write_bytes(b"0123456789") - r = static.File(str(self.tmpname)) - r.putChild(b"redirect", util.Redirect(b"/file")) - r.putChild(b"wait", ForeverTakingResource()) - r.putChild(b"hang-after-headers", ForeverTakingResource(write=True)) - r.putChild(b"nolength", NoLengthResource()) - r.putChild(b"host", HostHeaderResource()) - r.putChild(b"payload", PayloadResource()) - r.putChild(b"broken", BrokenDownloadResource()) - r.putChild(b"chunked", ChunkedResource()) - r.putChild(b"broken-chunked", BrokenChunkedResource()) - r.putChild(b"contentlength", ContentLengthHeaderResource()) - r.putChild(b"nocontenttype", EmptyContentTypeHeaderResource()) - r.putChild(b"largechunkedfile", LargeChunkedFileResource()) - r.putChild(b"duplicate-header", DuplicateHeaderResource()) - r.putChild(b"echo", Echo()) - self.site = server.Site(r, timeout=None) - self.wrapper = WrappingFactory(self.site) - self.host = "localhost" - if self.scheme == "https": - # Using WrappingFactory do not enable HTTP/2 failing all the - # tests with H2DownloadHandler - self.port = reactor.listenSSL( - 0, - self.site, - ssl_context_factory(self.keyfile, self.certfile), - interface=self.host, - ) - else: - self.port = reactor.listenTCP(0, self.wrapper, interface=self.host) - self.portno = self.port.getHost().port - self.download_handler = build_from_crawler( - self.download_handler_cls, get_crawler() - ) - self.download_request = self.download_handler.download_request - - @defer.inlineCallbacks - def tearDown(self): - yield self.port.stopListening() - if hasattr(self.download_handler, "close"): - yield self.download_handler.close() - shutil.rmtree(self.tmpname) - - def getURL(self, path): - return f"{self.scheme}://{self.host}:{self.portno}/{path}" - - def test_download(self): - request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - return d - - def test_download_head(self): - request = Request(self.getURL("file"), method="HEAD") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"") - return d - - def test_redirect_status(self): - request = Request(self.getURL("redirect")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.status) - d.addCallback(self.assertEqual, 302) - return d - - def test_redirect_status_head(self): - request = Request(self.getURL("redirect"), method="HEAD") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.status) - d.addCallback(self.assertEqual, 302) - return d - - @defer.inlineCallbacks - def test_timeout_download_from_spider_nodata_rcvd(self): - if self.reactor_pytest != "default" and sys.platform == "win32": - # https://twistedmatrix.com/trac/ticket/10279 - raise unittest.SkipTest( - "This test produces DirtyReactorAggregateError on Windows with asyncio" - ) - - # client connects but no data is received - spider = Spider("foo") - meta = {"download_timeout": 0.5} - request = Request(self.getURL("wait"), meta=meta) - d = self.download_request(request, spider) - yield self.assertFailure(d, defer.TimeoutError, error.TimeoutError) - - @defer.inlineCallbacks - def test_timeout_download_from_spider_server_hangs(self): - if self.reactor_pytest != "default" and sys.platform == "win32": - # https://twistedmatrix.com/trac/ticket/10279 - raise unittest.SkipTest( - "This test produces DirtyReactorAggregateError on Windows with asyncio" - ) - # client connects, server send headers and some body bytes but hangs - spider = Spider("foo") - meta = {"download_timeout": 0.5} - request = Request(self.getURL("hang-after-headers"), meta=meta) - d = self.download_request(request, spider) - yield self.assertFailure(d, defer.TimeoutError, error.TimeoutError) - - def test_host_header_not_in_request_headers(self): - def _test(response): - assert response.body == to_bytes(f"{self.host}:{self.portno}") - assert not request.headers - - request = Request(self.getURL("host")) - return self.download_request(request, Spider("foo")).addCallback(_test) - - def test_host_header_seted_in_request_headers(self): - host = self.host + ":" + str(self.portno) - - def _test(response): - assert response.body == host.encode() - assert request.headers.get("Host") == host.encode() - - request = Request(self.getURL("host"), headers={"Host": host}) - return self.download_request(request, Spider("foo")).addCallback(_test) - - def test_content_length_zero_bodyless_post_request_headers(self): - """Tests if "Content-Length: 0" is sent for bodyless POST requests. - - This is not strictly required by HTTP RFCs but can cause trouble - for some web servers. - See: - https://github.com/scrapy/scrapy/issues/823 - https://issues.apache.org/jira/browse/TS-2902 - https://github.com/kennethreitz/requests/issues/405 - https://bugs.python.org/issue14721 - """ - - def _test(response): - assert response.body == b"0" - - request = Request(self.getURL("contentlength"), method="POST") - return self.download_request(request, Spider("foo")).addCallback(_test) - - def test_content_length_zero_bodyless_post_only_one(self): - def _test(response): - import json - - headers = Headers(json.loads(response.text)["headers"]) - contentlengths = headers.getlist("Content-Length") - assert len(contentlengths) == 1 - assert contentlengths == [b"0"] - - request = Request(self.getURL("echo"), method="POST") - return self.download_request(request, Spider("foo")).addCallback(_test) - - def test_payload(self): - body = b"1" * 100 # PayloadResource requires body length to be 100 - request = Request(self.getURL("payload"), method="POST", body=body) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, body) - return d - - def test_response_header_content_length(self): - request = Request(self.getURL("file"), method=b"GET") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.headers[b"content-length"]) - d.addCallback(self.assertEqual, b"159") - return d - - def _test_response_class(self, filename, body, response_class): - def _test(response): - assert type(response) is response_class # pylint: disable=unidiomatic-typecheck - - request = Request(self.getURL(filename), body=body) - return self.download_request(request, Spider("foo")).addCallback(_test) - - def test_response_class_from_url(self): - return self._test_response_class("foo.html", b"", HtmlResponse) - - def test_response_class_from_body(self): - return self._test_response_class( - "foo", - b"\n.", - HtmlResponse, - ) - - def test_get_duplicate_header(self): - def _test(response): - assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"] - - request = Request(self.getURL("duplicate-header")) - return self.download_request(request, Spider("foo")).addCallback(_test) - - -@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestHttp10(TestHttp): - """HTTP 1.0 test case""" - - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return HTTP10DownloadHandler - - def test_protocol(self): - request = Request(self.getURL("host"), method="GET") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.protocol) - d.addCallback(self.assertEqual, "HTTP/1.0") - return d - - -class TestHttps10(TestHttp10): - scheme = "https" - - -class TestHttp11(TestHttp): - """HTTP 1.1 test case""" - - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return HTTP11DownloadHandler - - def test_download_without_maxsize_limit(self): - request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - return d - - def test_response_class_choosing_request(self): - """Tests choosing of correct response type - in case of Content-Type is empty but body contains text. - """ - body = b"Some plain text\ndata with tabs\t and null bytes\0" - - def _test_type(response): - assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck - - request = Request(self.getURL("nocontenttype"), body=body) - d = self.download_request(request, Spider("foo")) - d.addCallback(_test_type) - return d - - @defer.inlineCallbacks - def test_download_with_maxsize(self): - request = Request(self.getURL("file")) - - # 10 is minimal size for this request and the limit is only counted on - # response body. (regardless of headers) - d = self.download_request(request, Spider("foo", download_maxsize=10)) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - yield d - - d = self.download_request(request, Spider("foo", download_maxsize=9)) - yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) - - @defer.inlineCallbacks - def test_download_with_maxsize_very_large_file(self): - with mock.patch("scrapy.core.downloader.handlers.http11.logger") as logger: - request = Request(self.getURL("largechunkedfile")) - - def check(logger): - logger.warning.assert_called_once_with(mock.ANY, mock.ANY) - - d = self.download_request(request, Spider("foo", download_maxsize=1500)) - yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) - - # As the error message is logged in the dataReceived callback, we - # have to give a bit of time to the reactor to process the queue - # after closing the connection. - d = defer.Deferred() - d.addCallback(check) - reactor.callLater(0.1, d.callback, logger) - yield d - - @defer.inlineCallbacks - def test_download_with_maxsize_per_req(self): - meta = {"download_maxsize": 2} - request = Request(self.getURL("file"), meta=meta) - d = self.download_request(request, Spider("foo")) - yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) - - @defer.inlineCallbacks - def test_download_with_small_maxsize_per_spider(self): - request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo", download_maxsize=2)) - yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) - - def test_download_with_large_maxsize_per_spider(self): - request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo", download_maxsize=100)) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - return d - - def test_download_chunked_content(self): - request = Request(self.getURL("chunked")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"chunked content\n") - return d - - def test_download_broken_content_cause_data_loss(self, url="broken"): - request = Request(self.getURL(url)) - d = self.download_request(request, Spider("foo")) - - def checkDataLoss(failure): - if failure.check(ResponseFailed) and any( - r.check(_DataLoss) for r in failure.value.reasons - ): - return None - return failure - - d.addCallback(lambda _: self.fail("No DataLoss exception")) - d.addErrback(checkDataLoss) - return d - - def test_download_broken_chunked_content_cause_data_loss(self): - return self.test_download_broken_content_cause_data_loss("broken-chunked") - - def test_download_broken_content_allow_data_loss(self, url="broken"): - request = Request(self.getURL(url), meta={"download_fail_on_dataloss": False}) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.flags) - d.addCallback(self.assertEqual, ["dataloss"]) - return d - - def test_download_broken_chunked_content_allow_data_loss(self): - return self.test_download_broken_content_allow_data_loss("broken-chunked") - - def test_download_broken_content_allow_data_loss_via_setting(self, url="broken"): - crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False}) - download_handler = build_from_crawler(self.download_handler_cls, crawler) - request = Request(self.getURL(url)) - d = download_handler.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.flags) - d.addCallback(self.assertEqual, ["dataloss"]) - return d - - def test_download_broken_chunked_content_allow_data_loss_via_setting(self): - return self.test_download_broken_content_allow_data_loss_via_setting( - "broken-chunked" - ) - - def test_protocol(self): - request = Request(self.getURL("host"), method="GET") - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.protocol) - d.addCallback(self.assertEqual, "HTTP/1.1") - return d - - -class TestHttps11(TestHttp11): - scheme = "https" - - tls_log_message = ( - 'SSL connection certificate: issuer "/C=IE/O=Scrapy/CN=localhost", ' - 'subject "/C=IE/O=Scrapy/CN=localhost"' - ) - - @defer.inlineCallbacks - def test_tls_logging(self): - crawler = get_crawler( - settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} - ) - download_handler = build_from_crawler(self.download_handler_cls, crawler) - try: - with LogCapture() as log_capture: - request = Request(self.getURL("file")) - d = download_handler.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - yield d - log_capture.check_present( - ("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message) - ) - finally: - yield download_handler.close() - - -class TestSimpleHttps(unittest.TestCase): - """Base class for special cases tested with just one simple request""" - - keyfile = "keys/localhost.key" - certfile = "keys/localhost.crt" - cipher_string: str | None = None - - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return HTTP11DownloadHandler - - def setUp(self): - self.tmpname = Path(mkdtemp()) - (self.tmpname / "file").write_bytes(b"0123456789") - r = static.File(str(self.tmpname)) - self.site = server.Site(r, timeout=None) - self.host = "localhost" - self.port = reactor.listenSSL( - 0, - self.site, - ssl_context_factory( - self.keyfile, self.certfile, cipher_string=self.cipher_string - ), - interface=self.host, - ) - self.portno = self.port.getHost().port - if self.cipher_string is not None: - settings_dict = {"DOWNLOADER_CLIENT_TLS_CIPHERS": self.cipher_string} - else: - settings_dict = None - crawler = get_crawler(settings_dict=settings_dict) - self.download_handler = build_from_crawler(self.download_handler_cls, crawler) - self.download_request = self.download_handler.download_request - - @defer.inlineCallbacks - def tearDown(self): - yield self.port.stopListening() - if hasattr(self.download_handler, "close"): - yield self.download_handler.close() - shutil.rmtree(self.tmpname) - - def getURL(self, path): - return f"https://{self.host}:{self.portno}/{path}" - - def test_download(self): - request = Request(self.getURL("file")) - d = self.download_request(request, Spider("foo")) - d.addCallback(lambda r: r.body) - d.addCallback(self.assertEqual, b"0123456789") - return d - - -class TestHttps11WrongHostname(TestSimpleHttps): - # above tests use a server certificate for "localhost", - # client connection to "localhost" too. - # here we test that even if the server certificate is for another domain, - # "www.example.com" in this case, - # the tests still pass - keyfile = "keys/example-com.key.pem" - certfile = "keys/example-com.cert.pem" - - -class TestHttps11InvalidDNSId(TestSimpleHttps): - """Connect to HTTPS hosts with IP while certificate uses domain names IDs.""" - - def setUp(self): - super().setUp() - self.host = "127.0.0.1" - - -class TestHttps11InvalidDNSPattern(TestSimpleHttps): - """Connect to HTTPS hosts where the certificate are issued to an ip instead of a domain.""" - - keyfile = "keys/localhost.ip.key" - certfile = "keys/localhost.ip.crt" - - -class TestHttps11CustomCiphers(TestSimpleHttps): - cipher_string = "CAMELLIA256-SHA" - - -class TestHttp11MockServer(unittest.TestCase): - """HTTP 1.1 test case with MockServer""" - - settings_dict: dict | None = None - is_secure = False - - @classmethod - def setUpClass(cls): - cls.mockserver = MockServer() - cls.mockserver.__enter__() - - @classmethod - def tearDownClass(cls): - cls.mockserver.__exit__(None, None, None) - - @defer.inlineCallbacks - def test_download_with_content_length(self): - crawler = get_crawler(SingleRequestSpider, self.settings_dict) - # http://localhost:8998/partial set Content-Length to 1024, use download_maxsize= 1000 to avoid - # download it - yield crawler.crawl( - seed=Request( - url=self.mockserver.url("/partial", is_secure=self.is_secure), - meta={"download_maxsize": 1000}, - ) - ) - failure = crawler.spider.meta["failure"] - assert isinstance(failure.value, defer.CancelledError) - - @defer.inlineCallbacks - def test_download(self): - crawler = get_crawler(SingleRequestSpider, self.settings_dict) - yield crawler.crawl( - seed=Request(url=self.mockserver.url("", is_secure=self.is_secure)) - ) - failure = crawler.spider.meta.get("failure") - assert failure is None - reason = crawler.spider.meta["close_reason"] - assert reason == "finished" - - -class UriResource(resource.Resource): - """Return the full uri that was requested""" - - def getChild(self, path, request): - return self - - def render(self, request): - # Note: this is an ugly hack for CONNECT request timeout test. - # Returning some data here fail SSL/TLS handshake - # ToDo: implement proper HTTPS proxy tests, not faking them. - if request.method != b"CONNECT": - return request.uri - return b"" - - -class TestHttpProxy(unittest.TestCase, ABC): - expected_http_proxy_request_body = b"http://example.com" - - @property - @abstractmethod - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - raise NotImplementedError - - def setUp(self): - site = server.Site(UriResource(), timeout=None) - wrapper = WrappingFactory(site) - self.port = reactor.listenTCP(0, wrapper, interface="127.0.0.1") - self.portno = self.port.getHost().port - self.download_handler = build_from_crawler( - self.download_handler_cls, get_crawler() - ) - self.download_request = self.download_handler.download_request - - @defer.inlineCallbacks - def tearDown(self): - yield self.port.stopListening() - if hasattr(self.download_handler, "close"): - yield self.download_handler.close() - - def getURL(self, path): - return f"http://127.0.0.1:{self.portno}/{path}" - - def test_download_with_proxy(self): - def _test(response): - assert response.status == 200 - assert response.url == request.url - assert response.body == self.expected_http_proxy_request_body - - http_proxy = self.getURL("") - request = Request("http://example.com", meta={"proxy": http_proxy}) - return self.download_request(request, Spider("foo")).addCallback(_test) - - def test_download_without_proxy(self): - def _test(response): - assert response.status == 200 - assert response.url == request.url - assert response.body == b"/path/to/resource" - - request = Request(self.getURL("path/to/resource")) - return self.download_request(request, Spider("foo")).addCallback(_test) - - -@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning") -class TestHttp10Proxy(TestHttpProxy): - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return HTTP10DownloadHandler - - -class TestHttp11Proxy(TestHttpProxy): - @property - def download_handler_cls(self) -> type[DownloadHandlerProtocol]: - return HTTP11DownloadHandler - - @defer.inlineCallbacks - def test_download_with_proxy_https_timeout(self): - """Test TunnelingTCP4ClientEndpoint""" - if NON_EXISTING_RESOLVABLE: - raise SkipTest("Non-existing hosts are resolvable") - http_proxy = self.getURL("") - domain = "https://no-such-domain.nosuch" - request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2}) - d = self.download_request(request, Spider("foo")) - timeout = yield self.assertFailure(d, error.TimeoutError) - assert domain in timeout.osError - - def test_download_with_proxy_without_http_scheme(self): - def _test(response): - assert response.status == 200 - assert response.url == request.url - assert response.body == self.expected_http_proxy_request_body - - http_proxy = self.getURL("").replace("http://", "") - request = Request("http://example.com", meta={"proxy": http_proxy}) - return self.download_request(request, Spider("foo")).addCallback(_test) - - class HttpDownloadHandlerMock: def __init__(self, *args, **kwargs): pass diff --git a/tests/test_downloader_handlers_http_base.py b/tests/test_downloader_handlers_http_base.py new file mode 100644 index 000000000..46e5972f7 --- /dev/null +++ b/tests/test_downloader_handlers_http_base.py @@ -0,0 +1,698 @@ +"""Base classes for HTTP download handler tests.""" + +from __future__ import annotations + +import shutil +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from tempfile import mkdtemp +from typing import TYPE_CHECKING, Any +from unittest import mock + +import pytest +from testfixtures import LogCapture +from twisted.internet import defer, error, reactor +from twisted.protocols.policies import WrappingFactory +from twisted.trial import unittest +from twisted.web import resource, server, static, util +from twisted.web._newclient import ResponseFailed +from twisted.web.http import _DataLoss + +from scrapy.http import Headers, HtmlResponse, Request, TextResponse +from scrapy.spiders import Spider +from scrapy.utils.misc import build_from_crawler +from scrapy.utils.python import to_bytes +from scrapy.utils.test import get_crawler +from tests import NON_EXISTING_RESOLVABLE +from tests.mockserver import ( + Echo, + ForeverTakingResource, + HostHeaderResource, + MockServer, + NoLengthResource, + PayloadResource, + ssl_context_factory, +) +from tests.spiders import SingleRequestSpider + +if TYPE_CHECKING: + from scrapy.core.downloader.handlers import DownloadHandlerProtocol + + +class ContentLengthHeaderResource(resource.Resource): + """ + A testing resource which renders itself as the value of the Content-Length + header from the request. + """ + + def render(self, request): + return request.requestHeaders.getRawHeaders(b"content-length")[0] + + +class ChunkedResource(resource.Resource): + def render(self, request): + def response(): + request.write(b"chunked ") + request.write(b"content\n") + request.finish() + + reactor.callLater(0, response) + return server.NOT_DONE_YET + + +class BrokenChunkedResource(resource.Resource): + def render(self, request): + def response(): + request.write(b"chunked ") + request.write(b"content\n") + # Disable terminating chunk on finish. + request.chunked = False + closeConnection(request) + + reactor.callLater(0, response) + return server.NOT_DONE_YET + + +class BrokenDownloadResource(resource.Resource): + def render(self, request): + def response(): + request.setHeader(b"Content-Length", b"20") + request.write(b"partial") + closeConnection(request) + + reactor.callLater(0, response) + return server.NOT_DONE_YET + + +def closeConnection(request): + # We have to force a disconnection for HTTP/1.1 clients. Otherwise + # client keeps the connection open waiting for more data. + request.channel.loseConnection() + request.finish() + + +class EmptyContentTypeHeaderResource(resource.Resource): + """ + A testing resource which renders itself as the value of request body + without content-type header in response. + """ + + def render(self, request): + request.setHeader("content-type", "") + return request.content.read() + + +class LargeChunkedFileResource(resource.Resource): + def render(self, request): + def response(): + for i in range(1024): + request.write(b"x" * 1024) + request.finish() + + reactor.callLater(0, response) + return server.NOT_DONE_YET + + +class DuplicateHeaderResource(resource.Resource): + def render(self, request): + request.responseHeaders.setRawHeaders(b"Set-Cookie", [b"a=b", b"c=d"]) + return b"" + + +class TestHttpBase(unittest.TestCase, ABC): + scheme = "http" + + # only used for HTTPS tests + keyfile = "keys/localhost.key" + certfile = "keys/localhost.crt" + + @property + @abstractmethod + def download_handler_cls(self) -> type[DownloadHandlerProtocol]: + raise NotImplementedError + + def setUp(self): + self.tmpname = Path(mkdtemp()) + (self.tmpname / "file").write_bytes(b"0123456789") + r = static.File(str(self.tmpname)) + r.putChild(b"redirect", util.Redirect(b"/file")) + r.putChild(b"wait", ForeverTakingResource()) + r.putChild(b"hang-after-headers", ForeverTakingResource(write=True)) + r.putChild(b"nolength", NoLengthResource()) + r.putChild(b"host", HostHeaderResource()) + r.putChild(b"payload", PayloadResource()) + r.putChild(b"broken", BrokenDownloadResource()) + r.putChild(b"chunked", ChunkedResource()) + r.putChild(b"broken-chunked", BrokenChunkedResource()) + r.putChild(b"contentlength", ContentLengthHeaderResource()) + r.putChild(b"nocontenttype", EmptyContentTypeHeaderResource()) + r.putChild(b"largechunkedfile", LargeChunkedFileResource()) + r.putChild(b"duplicate-header", DuplicateHeaderResource()) + r.putChild(b"echo", Echo()) + self.site = server.Site(r, timeout=None) + self.wrapper = WrappingFactory(self.site) + self.host = "localhost" + if self.scheme == "https": + # Using WrappingFactory do not enable HTTP/2 failing all the + # tests with H2DownloadHandler + self.port = reactor.listenSSL( + 0, + self.site, + ssl_context_factory(self.keyfile, self.certfile), + interface=self.host, + ) + else: + self.port = reactor.listenTCP(0, self.wrapper, interface=self.host) + self.portno = self.port.getHost().port + self.download_handler = build_from_crawler( + self.download_handler_cls, get_crawler() + ) + self.download_request = self.download_handler.download_request + + @defer.inlineCallbacks + def tearDown(self): + yield self.port.stopListening() + if hasattr(self.download_handler, "close"): + yield self.download_handler.close() + shutil.rmtree(self.tmpname) + + def getURL(self, path): + return f"{self.scheme}://{self.host}:{self.portno}/{path}" + + def test_download(self): + request = Request(self.getURL("file")) + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.body) + d.addCallback(self.assertEqual, b"0123456789") + return d + + def test_download_head(self): + request = Request(self.getURL("file"), method="HEAD") + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.body) + d.addCallback(self.assertEqual, b"") + return d + + def test_redirect_status(self): + request = Request(self.getURL("redirect")) + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.status) + d.addCallback(self.assertEqual, 302) + return d + + def test_redirect_status_head(self): + request = Request(self.getURL("redirect"), method="HEAD") + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.status) + d.addCallback(self.assertEqual, 302) + return d + + @defer.inlineCallbacks + def test_timeout_download_from_spider_nodata_rcvd(self): + if self.reactor_pytest != "default" and sys.platform == "win32": + # https://twistedmatrix.com/trac/ticket/10279 + raise unittest.SkipTest( + "This test produces DirtyReactorAggregateError on Windows with asyncio" + ) + + # client connects but no data is received + spider = Spider("foo") + meta = {"download_timeout": 0.5} + request = Request(self.getURL("wait"), meta=meta) + d = self.download_request(request, spider) + yield self.assertFailure(d, defer.TimeoutError, error.TimeoutError) + + @defer.inlineCallbacks + def test_timeout_download_from_spider_server_hangs(self): + if self.reactor_pytest != "default" and sys.platform == "win32": + # https://twistedmatrix.com/trac/ticket/10279 + raise unittest.SkipTest( + "This test produces DirtyReactorAggregateError on Windows with asyncio" + ) + # client connects, server send headers and some body bytes but hangs + spider = Spider("foo") + meta = {"download_timeout": 0.5} + request = Request(self.getURL("hang-after-headers"), meta=meta) + d = self.download_request(request, spider) + yield self.assertFailure(d, defer.TimeoutError, error.TimeoutError) + + def test_host_header_not_in_request_headers(self): + def _test(response): + assert response.body == to_bytes(f"{self.host}:{self.portno}") + assert not request.headers + + request = Request(self.getURL("host")) + return self.download_request(request, Spider("foo")).addCallback(_test) + + def test_host_header_seted_in_request_headers(self): + host = self.host + ":" + str(self.portno) + + def _test(response): + assert response.body == host.encode() + assert request.headers.get("Host") == host.encode() + + request = Request(self.getURL("host"), headers={"Host": host}) + return self.download_request(request, Spider("foo")).addCallback(_test) + + def test_content_length_zero_bodyless_post_request_headers(self): + """Tests if "Content-Length: 0" is sent for bodyless POST requests. + + This is not strictly required by HTTP RFCs but can cause trouble + for some web servers. + See: + https://github.com/scrapy/scrapy/issues/823 + https://issues.apache.org/jira/browse/TS-2902 + https://github.com/kennethreitz/requests/issues/405 + https://bugs.python.org/issue14721 + """ + + def _test(response): + assert response.body == b"0" + + request = Request(self.getURL("contentlength"), method="POST") + return self.download_request(request, Spider("foo")).addCallback(_test) + + def test_content_length_zero_bodyless_post_only_one(self): + def _test(response): + import json + + headers = Headers(json.loads(response.text)["headers"]) + contentlengths = headers.getlist("Content-Length") + assert len(contentlengths) == 1 + assert contentlengths == [b"0"] + + request = Request(self.getURL("echo"), method="POST") + return self.download_request(request, Spider("foo")).addCallback(_test) + + def test_payload(self): + body = b"1" * 100 # PayloadResource requires body length to be 100 + request = Request(self.getURL("payload"), method="POST", body=body) + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.body) + d.addCallback(self.assertEqual, body) + return d + + def test_response_header_content_length(self): + request = Request(self.getURL("file"), method=b"GET") + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.headers[b"content-length"]) + d.addCallback(self.assertEqual, b"159") + return d + + def _test_response_class(self, filename, body, response_class): + def _test(response): + assert type(response) is response_class # pylint: disable=unidiomatic-typecheck + + request = Request(self.getURL(filename), body=body) + return self.download_request(request, Spider("foo")).addCallback(_test) + + def test_response_class_from_url(self): + return self._test_response_class("foo.html", b"", HtmlResponse) + + def test_response_class_from_body(self): + return self._test_response_class( + "foo", + b"\n.", + HtmlResponse, + ) + + def test_get_duplicate_header(self): + def _test(response): + assert response.headers.getlist(b"Set-Cookie") == [b"a=b", b"c=d"] + + request = Request(self.getURL("duplicate-header")) + return self.download_request(request, Spider("foo")).addCallback(_test) + + +class TestHttp11Base(TestHttpBase): + """HTTP 1.1 test case""" + + def test_download_without_maxsize_limit(self): + request = Request(self.getURL("file")) + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.body) + d.addCallback(self.assertEqual, b"0123456789") + return d + + def test_response_class_choosing_request(self): + """Tests choosing of correct response type + in case of Content-Type is empty but body contains text. + """ + body = b"Some plain text\ndata with tabs\t and null bytes\0" + + def _test_type(response): + assert type(response) is TextResponse # pylint: disable=unidiomatic-typecheck + + request = Request(self.getURL("nocontenttype"), body=body) + d = self.download_request(request, Spider("foo")) + d.addCallback(_test_type) + return d + + @defer.inlineCallbacks + def test_download_with_maxsize(self): + request = Request(self.getURL("file")) + + # 10 is minimal size for this request and the limit is only counted on + # response body. (regardless of headers) + d = self.download_request(request, Spider("foo", download_maxsize=10)) + d.addCallback(lambda r: r.body) + d.addCallback(self.assertEqual, b"0123456789") + yield d + + d = self.download_request(request, Spider("foo", download_maxsize=9)) + yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) + + @defer.inlineCallbacks + def test_download_with_maxsize_very_large_file(self): + with mock.patch("scrapy.core.downloader.handlers.http11.logger") as logger: + request = Request(self.getURL("largechunkedfile")) + + def check(logger): + logger.warning.assert_called_once_with(mock.ANY, mock.ANY) + + d = self.download_request(request, Spider("foo", download_maxsize=1500)) + yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) + + # As the error message is logged in the dataReceived callback, we + # have to give a bit of time to the reactor to process the queue + # after closing the connection. + d = defer.Deferred() + d.addCallback(check) + reactor.callLater(0.1, d.callback, logger) + yield d + + @defer.inlineCallbacks + def test_download_with_maxsize_per_req(self): + meta = {"download_maxsize": 2} + request = Request(self.getURL("file"), meta=meta) + d = self.download_request(request, Spider("foo")) + yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) + + @defer.inlineCallbacks + def test_download_with_small_maxsize_per_spider(self): + request = Request(self.getURL("file")) + d = self.download_request(request, Spider("foo", download_maxsize=2)) + yield self.assertFailure(d, defer.CancelledError, error.ConnectionAborted) + + def test_download_with_large_maxsize_per_spider(self): + request = Request(self.getURL("file")) + d = self.download_request(request, Spider("foo", download_maxsize=100)) + d.addCallback(lambda r: r.body) + d.addCallback(self.assertEqual, b"0123456789") + return d + + def test_download_chunked_content(self): + request = Request(self.getURL("chunked")) + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.body) + d.addCallback(self.assertEqual, b"chunked content\n") + return d + + def test_download_broken_content_cause_data_loss(self, url="broken"): + # TODO: this one checks for Twisted-specific exceptions + request = Request(self.getURL(url)) + d = self.download_request(request, Spider("foo")) + + def checkDataLoss(failure): + if failure.check(ResponseFailed) and any( + r.check(_DataLoss) for r in failure.value.reasons + ): + return None + return failure + + d.addCallback(lambda _: self.fail("No DataLoss exception")) + d.addErrback(checkDataLoss) + return d + + def test_download_broken_chunked_content_cause_data_loss(self): + return self.test_download_broken_content_cause_data_loss("broken-chunked") + + def test_download_broken_content_allow_data_loss(self, url="broken"): + request = Request(self.getURL(url), meta={"download_fail_on_dataloss": False}) + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.flags) + d.addCallback(self.assertEqual, ["dataloss"]) + return d + + def test_download_broken_chunked_content_allow_data_loss(self): + return self.test_download_broken_content_allow_data_loss("broken-chunked") + + def test_download_broken_content_allow_data_loss_via_setting(self, url="broken"): + crawler = get_crawler(settings_dict={"DOWNLOAD_FAIL_ON_DATALOSS": False}) + download_handler = build_from_crawler(self.download_handler_cls, crawler) + request = Request(self.getURL(url)) + d = download_handler.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.flags) + d.addCallback(self.assertEqual, ["dataloss"]) + return d + + def test_download_broken_chunked_content_allow_data_loss_via_setting(self): + return self.test_download_broken_content_allow_data_loss_via_setting( + "broken-chunked" + ) + + def test_protocol(self): + request = Request(self.getURL("host"), method="GET") + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.protocol) + d.addCallback(self.assertEqual, "HTTP/1.1") + return d + + +class TestHttps11Base(TestHttp11Base): + scheme = "https" + + tls_log_message = ( + 'SSL connection certificate: issuer "/C=IE/O=Scrapy/CN=localhost", ' + 'subject "/C=IE/O=Scrapy/CN=localhost"' + ) + + @defer.inlineCallbacks + def test_tls_logging(self): + crawler = get_crawler( + settings_dict={"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING": True} + ) + download_handler = build_from_crawler(self.download_handler_cls, crawler) + try: + with LogCapture() as log_capture: + request = Request(self.getURL("file")) + d = download_handler.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.body) + d.addCallback(self.assertEqual, b"0123456789") + yield d + log_capture.check_present( + ("scrapy.core.downloader.tls", "DEBUG", self.tls_log_message) + ) + finally: + yield download_handler.close() + + +class TestSimpleHttpsBase(unittest.TestCase, ABC): + """Base class for special cases tested with just one simple request""" + + keyfile = "keys/localhost.key" + certfile = "keys/localhost.crt" + cipher_string: str | None = None + + @property + @abstractmethod + def download_handler_cls(self) -> type[DownloadHandlerProtocol]: + raise NotImplementedError + + def setUp(self): + self.tmpname = Path(mkdtemp()) + (self.tmpname / "file").write_bytes(b"0123456789") + r = static.File(str(self.tmpname)) + self.site = server.Site(r, timeout=None) + self.host = "localhost" + self.port = reactor.listenSSL( + 0, + self.site, + ssl_context_factory( + self.keyfile, self.certfile, cipher_string=self.cipher_string + ), + interface=self.host, + ) + self.portno = self.port.getHost().port + if self.cipher_string is not None: + settings_dict = {"DOWNLOADER_CLIENT_TLS_CIPHERS": self.cipher_string} + else: + settings_dict = None + crawler = get_crawler(settings_dict=settings_dict) + self.download_handler = build_from_crawler(self.download_handler_cls, crawler) + self.download_request = self.download_handler.download_request + + @defer.inlineCallbacks + def tearDown(self): + yield self.port.stopListening() + if hasattr(self.download_handler, "close"): + yield self.download_handler.close() + shutil.rmtree(self.tmpname) + + def getURL(self, path): + return f"https://{self.host}:{self.portno}/{path}" + + def test_download(self): + request = Request(self.getURL("file")) + d = self.download_request(request, Spider("foo")) + d.addCallback(lambda r: r.body) + d.addCallback(self.assertEqual, b"0123456789") + return d + + +class TestHttpsWrongHostnameBase(TestSimpleHttpsBase): + # above tests use a server certificate for "localhost", + # client connection to "localhost" too. + # here we test that even if the server certificate is for another domain, + # "www.example.com" in this case, + # the tests still pass + keyfile = "keys/example-com.key.pem" + certfile = "keys/example-com.cert.pem" + + +class TestHttpsInvalidDNSIdBase(TestSimpleHttpsBase): + """Connect to HTTPS hosts with IP while certificate uses domain names IDs.""" + + def setUp(self): + super().setUp() + self.host = "127.0.0.1" + + +class TestHttpsInvalidDNSPatternBase(TestSimpleHttpsBase): + """Connect to HTTPS hosts where the certificate are issued to an ip instead of a domain.""" + + keyfile = "keys/localhost.ip.key" + certfile = "keys/localhost.ip.crt" + + +class TestHttpsCustomCiphersBase(TestSimpleHttpsBase): + cipher_string = "CAMELLIA256-SHA" + + +class TestHttpMockServerBase(unittest.TestCase, ABC): + """HTTP 1.1 test case with MockServer""" + + @property + @abstractmethod + def settings_dict(self) -> dict[str, Any] | None: + raise NotImplementedError + + is_secure = False + + @classmethod + def setUpClass(cls): + cls.mockserver = MockServer() + cls.mockserver.__enter__() + + @classmethod + def tearDownClass(cls): + cls.mockserver.__exit__(None, None, None) + + @defer.inlineCallbacks + def test_download_with_content_length(self): + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + # http://localhost:8998/partial set Content-Length to 1024, use download_maxsize= 1000 to avoid + # download it + yield crawler.crawl( + seed=Request( + url=self.mockserver.url("/partial", is_secure=self.is_secure), + meta={"download_maxsize": 1000}, + ) + ) + failure = crawler.spider.meta["failure"] + assert isinstance(failure.value, defer.CancelledError) + + @defer.inlineCallbacks + def test_download(self): + crawler = get_crawler(SingleRequestSpider, self.settings_dict) + yield crawler.crawl( + seed=Request(url=self.mockserver.url("", is_secure=self.is_secure)) + ) + failure = crawler.spider.meta.get("failure") + assert failure is None + reason = crawler.spider.meta["close_reason"] + assert reason == "finished" + + +class UriResource(resource.Resource): + """Return the full uri that was requested""" + + def getChild(self, path, request): + return self + + def render(self, request): + # Note: this is an ugly hack for CONNECT request timeout test. + # Returning some data here fail SSL/TLS handshake + # ToDo: implement proper HTTPS proxy tests, not faking them. + if request.method != b"CONNECT": + return request.uri + return b"" + + +class TestHttpProxyBase(unittest.TestCase, ABC): + expected_http_proxy_request_body = b"http://example.com" + + @property + @abstractmethod + def download_handler_cls(self) -> type[DownloadHandlerProtocol]: + raise NotImplementedError + + def setUp(self): + site = server.Site(UriResource(), timeout=None) + wrapper = WrappingFactory(site) + self.port = reactor.listenTCP(0, wrapper, interface="127.0.0.1") + self.portno = self.port.getHost().port + self.download_handler = build_from_crawler( + self.download_handler_cls, get_crawler() + ) + self.download_request = self.download_handler.download_request + + @defer.inlineCallbacks + def tearDown(self): + yield self.port.stopListening() + if hasattr(self.download_handler, "close"): + yield self.download_handler.close() + + def getURL(self, path): + return f"http://127.0.0.1:{self.portno}/{path}" + + def test_download_with_proxy(self): + def _test(response): + assert response.status == 200 + assert response.url == request.url + assert response.body == self.expected_http_proxy_request_body + + http_proxy = self.getURL("") + request = Request("http://example.com", meta={"proxy": http_proxy}) + return self.download_request(request, Spider("foo")).addCallback(_test) + + def test_download_without_proxy(self): + def _test(response): + assert response.status == 200 + assert response.url == request.url + assert response.body == b"/path/to/resource" + + request = Request(self.getURL("path/to/resource")) + return self.download_request(request, Spider("foo")).addCallback(_test) + + @defer.inlineCallbacks + def test_download_with_proxy_https_timeout(self): + if NON_EXISTING_RESOLVABLE: + pytest.skip("Non-existing hosts are resolvable") + http_proxy = self.getURL("") + domain = "https://no-such-domain.nosuch" + request = Request(domain, meta={"proxy": http_proxy, "download_timeout": 0.2}) + d = self.download_request(request, Spider("foo")) + timeout = yield self.assertFailure(d, error.TimeoutError) + assert domain in timeout.osError + + def test_download_with_proxy_without_http_scheme(self): + def _test(response): + assert response.status == 200 + assert response.url == request.url + assert response.body == self.expected_http_proxy_request_body + + http_proxy = self.getURL("").replace("http://", "") + request = Request("http://example.com", meta={"proxy": http_proxy}) + return self.download_request(request, Spider("foo")).addCallback(_test)