mirror of https://github.com/scrapy/scrapy.git
331 lines
13 KiB
Python
331 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import warnings
|
|
from typing import TYPE_CHECKING, cast
|
|
|
|
import OpenSSL.SSL
|
|
import pytest
|
|
from pytest_twisted import async_yield_fixture
|
|
from twisted.internet.protocol import Factory
|
|
from twisted.internet.protocol import Protocol as TxProtocol
|
|
from twisted.internet.ssl import AcceptableCiphers, optionsForClientTLS
|
|
from twisted.protocols.tls import TLSMemoryBIOFactory, TLSMemoryBIOProtocol
|
|
from twisted.web import server, static
|
|
from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody
|
|
from twisted.web.client import Response as TxResponse
|
|
|
|
from scrapy.core.downloader import Downloader, Slot, tls
|
|
from scrapy.core.downloader.contextfactory import (
|
|
_load_context_factory_from_settings,
|
|
_ScrapyClientContextFactory,
|
|
)
|
|
from scrapy.core.downloader.handlers.http11 import _RequestBodyProducer
|
|
from scrapy.exceptions import ScrapyDeprecationWarning
|
|
from scrapy.utils._deps_compat import (
|
|
PYOPENSSL_SET_CIPHER_LIST_TMP_CONN,
|
|
TWISTED_TLS_NEW_IMPL,
|
|
)
|
|
from scrapy.utils.defer import maybe_deferred_to_future
|
|
from scrapy.utils.misc import build_from_crawler
|
|
from scrapy.utils.python import to_bytes
|
|
from scrapy.utils.spider import DefaultSpider
|
|
from scrapy.utils.test import get_crawler
|
|
from tests.mockserver.http_resources import PayloadResource
|
|
from tests.mockserver.utils import ssl_context_factory
|
|
from tests.utils.decorators import coroutine_test
|
|
|
|
if TYPE_CHECKING:
|
|
from twisted.internet.defer import Deferred
|
|
from twisted.web.iweb import IBodyProducer
|
|
|
|
|
|
class TestSlot:
|
|
def test_repr(self):
|
|
slot = Slot(concurrency=8, delay=0.1, randomize_delay=True)
|
|
assert repr(slot) == "Slot(concurrency=8, delay=0.1, randomize_delay=True)"
|
|
|
|
|
|
@pytest.mark.requires_reactor # this test is related to the Twisted HTTP code
|
|
class TestContextFactoryBase:
|
|
@async_yield_fixture
|
|
async def server_url(self, tmp_path):
|
|
(tmp_path / "file").write_bytes(b"0123456789")
|
|
r = static.File(str(tmp_path))
|
|
r.putChild(b"payload", PayloadResource())
|
|
site = server.Site(r, timeout=None)
|
|
port = self._listen(site)
|
|
portno = port.getHost().port
|
|
|
|
yield f"https://127.0.0.1:{portno}/"
|
|
|
|
await port.stopListening()
|
|
|
|
def _listen(self, site):
|
|
from twisted.internet import reactor
|
|
|
|
return reactor.listenSSL(
|
|
0,
|
|
site,
|
|
contextFactory=ssl_context_factory(),
|
|
interface="127.0.0.1",
|
|
)
|
|
|
|
@staticmethod
|
|
async def get_page(
|
|
url: str,
|
|
client_context_factory: BrowserLikePolicyForHTTPS,
|
|
body: str | None = None,
|
|
) -> bytes:
|
|
from twisted.internet import reactor
|
|
|
|
agent = Agent(reactor, contextFactory=client_context_factory)
|
|
body_producer = _RequestBodyProducer(body.encode()) if body else None
|
|
response: TxResponse = cast(
|
|
"TxResponse",
|
|
await maybe_deferred_to_future(
|
|
agent.request(
|
|
b"GET",
|
|
url.encode(),
|
|
bodyProducer=cast("IBodyProducer", body_producer),
|
|
)
|
|
),
|
|
)
|
|
with warnings.catch_warnings():
|
|
# https://github.com/twisted/twisted/issues/8227
|
|
warnings.filterwarnings(
|
|
"ignore",
|
|
category=DeprecationWarning,
|
|
message=r".*does not have an abortConnection method",
|
|
)
|
|
d: Deferred[bytes] = readBody(response) # type: ignore[arg-type]
|
|
return await maybe_deferred_to_future(d)
|
|
|
|
|
|
class TestContextFactory(TestContextFactoryBase):
|
|
@pytest.fixture
|
|
def factory(self) -> _ScrapyClientContextFactory:
|
|
crawler = get_crawler()
|
|
return _load_context_factory_from_settings(crawler)
|
|
|
|
@staticmethod
|
|
def _get_dummy_protocol() -> TLSMemoryBIOProtocol:
|
|
# from Twisted src/twisted/web/test/test_agent.py::dummyTLSProtocol()
|
|
factory = TLSMemoryBIOFactory(
|
|
optionsForClientTLS("example.com"), True, Factory.forProtocol(TxProtocol)
|
|
)
|
|
return factory.buildProtocol(None)
|
|
|
|
@coroutine_test
|
|
async def test_payload(
|
|
self, factory: _ScrapyClientContextFactory, server_url: str
|
|
) -> None:
|
|
s = "0123456789" * 10
|
|
body = await self.get_page(server_url + "payload", factory, body=s)
|
|
assert body == to_bytes(s)
|
|
|
|
@pytest.mark.skipif(
|
|
TWISTED_TLS_NEW_IMPL,
|
|
reason="The context is not stored on this Twisted version",
|
|
)
|
|
def test_no_context_sharing(self, factory: _ScrapyClientContextFactory) -> None:
|
|
"""Every call to creatorForNetloc() should give a fresh context."""
|
|
creator1 = factory.creatorForNetloc(b"website1.tld", 443)
|
|
assert creator1._hostnameBytes == b"website1.tld"
|
|
creator2 = factory.creatorForNetloc(b"website2.tld", 443)
|
|
assert creator2._hostnameBytes == b"website2.tld"
|
|
assert creator1._ctx is not creator2._ctx # type: ignore[attr-defined]
|
|
|
|
def test_no_context_sharing_with_conn(
|
|
self, factory: _ScrapyClientContextFactory
|
|
) -> None:
|
|
"""Like test_no_context_sharing() but get the context from a connection."""
|
|
creator1 = factory.creatorForNetloc(b"website1.tld", 443)
|
|
assert creator1._hostnameBytes == b"website1.tld"
|
|
conn1 = creator1.clientConnectionForTLS(self._get_dummy_protocol())
|
|
|
|
creator2 = factory.creatorForNetloc(b"website2.tld", 443)
|
|
assert creator2._hostnameBytes == b"website2.tld"
|
|
conn2 = creator2.clientConnectionForTLS(self._get_dummy_protocol())
|
|
|
|
assert conn1.get_context() is not conn2.get_context()
|
|
|
|
@pytest.mark.skipif(
|
|
PYOPENSSL_SET_CIPHER_LIST_TMP_CONN,
|
|
reason="Fails or doesn't make sense on this pyOpenSSL version",
|
|
)
|
|
def test_no_immutable_ctx_warning(
|
|
self, factory: _ScrapyClientContextFactory
|
|
) -> None:
|
|
"""There should be no pyOpenSSL context modification warning.
|
|
|
|
pyOpenSSL < 25.1.0 doesn't produce this warning, and on 25.1.0 it's
|
|
always produced due to
|
|
https://github.com/scrapy/scrapy/issues/6859#issuecomment-4294917851.
|
|
"""
|
|
with warnings.catch_warnings():
|
|
warnings.filterwarnings(
|
|
"error",
|
|
category=DeprecationWarning,
|
|
message="Attempting to mutate a Context after a Connection was created",
|
|
)
|
|
factory.creatorForNetloc(b"website.tld", 443)
|
|
|
|
def test_ctx_flags(self, factory: _ScrapyClientContextFactory) -> None:
|
|
"""The context should have the expected flags set."""
|
|
creator = factory.creatorForNetloc(b"website.tld", 443)
|
|
conn = creator.clientConnectionForTLS(self._get_dummy_protocol())
|
|
ctx = conn.get_context()
|
|
# fragile but pyOpenSSL doesn't have Context.get_options()
|
|
options = OpenSSL.SSL._lib.SSL_CTX_get_options(ctx._context) # type: ignore[attr-defined]
|
|
assert options & 0x4 # OP_LEGACY_SERVER_CONNECT
|
|
|
|
|
|
class TestContextFactoryCiphers(TestContextFactoryBase):
|
|
async def _assert_factory_works(
|
|
self, server_url: str, client_context_factory: _ScrapyClientContextFactory
|
|
) -> None:
|
|
s = "0123456789" * 10
|
|
body = await self.get_page(
|
|
server_url + "payload", client_context_factory, body=s
|
|
)
|
|
assert body == to_bytes(s)
|
|
|
|
def test_default(self) -> None:
|
|
"""The default 'DEFAULT' value is passed to Twisted as is."""
|
|
crawler = get_crawler()
|
|
factory = build_from_crawler(_ScrapyClientContextFactory, crawler)
|
|
assert factory.tls_ciphers is not None
|
|
# OpenSSLAcceptableCiphers has no __eq__, so compare the parsed ciphers.
|
|
assert (
|
|
factory.tls_ciphers._ciphers
|
|
== AcceptableCiphers.fromOpenSSLCipherString("DEFAULT")._ciphers
|
|
)
|
|
assert factory._get_cert_options_kwargs()["acceptableCiphers"] is not None
|
|
|
|
def test_custom(self) -> None:
|
|
crawler = get_crawler(
|
|
settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": "CAMELLIA256-SHA"}
|
|
)
|
|
factory = build_from_crawler(_ScrapyClientContextFactory, crawler)
|
|
assert factory.tls_ciphers is not None
|
|
assert (
|
|
factory.tls_ciphers._ciphers
|
|
== AcceptableCiphers.fromOpenSSLCipherString("CAMELLIA256-SHA")._ciphers
|
|
)
|
|
|
|
@coroutine_test
|
|
async def test_none(self, server_url: str) -> None:
|
|
"""A None value enables the Twisted default ciphers."""
|
|
crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_CIPHERS": None})
|
|
factory = build_from_crawler(_ScrapyClientContextFactory, crawler)
|
|
assert factory.tls_ciphers is None
|
|
assert factory._get_cert_options_kwargs()["acceptableCiphers"] is None
|
|
await self._assert_factory_works(server_url, factory)
|
|
|
|
|
|
class TestContextFactoryTLSMethod(TestContextFactoryBase):
|
|
async def _assert_factory_works(
|
|
self, server_url: str, client_context_factory: _ScrapyClientContextFactory
|
|
) -> None:
|
|
s = "0123456789" * 10
|
|
body = await self.get_page(
|
|
server_url + "payload", client_context_factory, body=s
|
|
)
|
|
assert body == to_bytes(s)
|
|
|
|
@coroutine_test
|
|
async def test_setting_default(self, server_url: str) -> None:
|
|
crawler = get_crawler()
|
|
client_context_factory = _load_context_factory_from_settings(crawler)
|
|
assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD
|
|
await self._assert_factory_works(server_url, client_context_factory)
|
|
|
|
def test_setting_none(self):
|
|
crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": None})
|
|
with (
|
|
pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match="Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is deprecated",
|
|
),
|
|
pytest.raises(KeyError),
|
|
):
|
|
_load_context_factory_from_settings(crawler)
|
|
|
|
def test_setting_bad(self):
|
|
crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "bad"})
|
|
with (
|
|
pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match="Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is deprecated",
|
|
),
|
|
pytest.raises(KeyError),
|
|
):
|
|
_load_context_factory_from_settings(crawler)
|
|
|
|
@pytest.mark.filterwarnings(
|
|
r"ignore:Passing method to twisted\.internet\.ssl\.CertificateOptions:DeprecationWarning"
|
|
)
|
|
@coroutine_test
|
|
async def test_setting_explicit(self, server_url: str) -> None:
|
|
crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "TLSv1.2"})
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match="Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is deprecated",
|
|
):
|
|
client_context_factory = _load_context_factory_from_settings(crawler)
|
|
assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD
|
|
await self._assert_factory_works(server_url, client_context_factory)
|
|
|
|
@coroutine_test
|
|
async def test_direct_from_crawler(self, server_url: str) -> None:
|
|
# the setting is ignored
|
|
crawler = get_crawler(settings_dict={"DOWNLOADER_CLIENT_TLS_METHOD": "bad"})
|
|
client_context_factory = build_from_crawler(
|
|
_ScrapyClientContextFactory, crawler
|
|
)
|
|
assert client_context_factory._ssl_method == OpenSSL.SSL.SSLv23_METHOD
|
|
await self._assert_factory_works(server_url, client_context_factory)
|
|
|
|
@pytest.mark.filterwarnings(
|
|
r"ignore:Passing method to twisted\.internet\.ssl\.CertificateOptions:DeprecationWarning"
|
|
)
|
|
@coroutine_test
|
|
async def test_direct_init(self, server_url: str) -> None:
|
|
client_context_factory = _ScrapyClientContextFactory(OpenSSL.SSL.TLSv1_2_METHOD)
|
|
assert client_context_factory._ssl_method == OpenSSL.SSL.TLSv1_2_METHOD
|
|
await self._assert_factory_works(server_url, client_context_factory)
|
|
|
|
|
|
@coroutine_test
|
|
async def test_fetch_deprecated_spider_arg():
|
|
class CustomDownloader(Downloader):
|
|
def fetch(self, request, spider): # pylint: disable=signature-differs
|
|
return super().fetch(request, spider)
|
|
|
|
crawler = get_crawler(DefaultSpider, {"DOWNLOADER": CustomDownloader})
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match=r"The fetch\(\) method of .+\.CustomDownloader requires a spider argument",
|
|
):
|
|
await crawler.crawl_async()
|
|
|
|
|
|
def test_deprecated_tls_module_names() -> None:
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match="scrapy.core.downloader.tls.METHOD_TLS is deprecated",
|
|
):
|
|
assert tls.METHOD_TLS == "TLS"
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match="scrapy.core.downloader.tls.openssl_methods is deprecated",
|
|
):
|
|
assert isinstance(tls.openssl_methods, dict)
|
|
with pytest.warns(
|
|
ScrapyDeprecationWarning,
|
|
match="scrapy.core.downloader.tls.DEFAULT_CIPHERS is deprecated",
|
|
):
|
|
assert tls.DEFAULT_CIPHERS._ciphers == (
|
|
AcceptableCiphers.fromOpenSSLCipherString("DEFAULT")._ciphers
|
|
)
|