[ADD] core: block requests calls during tests

Non-external tests should not be performing requests to external
websites or services.

Add a mock to handle such requests:

- if the test is not tagged external
- and the request is not to localhost
- and the request is not to `file:` (it's possible to install a
  FileAdapter in a session to resolve file URLs)
- raise a connection error (and log, both with some details of the
  blocked request for debugging)

The mock is layered on the lowest possible level (`Session.send`), so
tests can either:

- reconfigure the mock to handle cases differently (the mock is
  re-created on every test)
- layer their own mock at a higher level of the library

Eventually we might also built-in a routing / dispatch mechanism so
it's easier to declare external services you want to mock, somewhat
similar to what's available client-side.

Whitelist `file:` because e.g. zeep performs `file:` request, using a
bespoke adapter installed in its session.

Part-of: odoo/odoo#128497
This commit is contained in:
Xavier Morel
2023-07-15 12:00:11 +02:00
parent 5e4dbe2616
commit 4111f02fb3
+29 -1
View File
@@ -43,6 +43,7 @@ from xmlrpc import client as xmlrpclib
import requests import requests
import werkzeug.urls import werkzeug.urls
from lxml import etree, html from lxml import etree, html
from requests import PreparedRequest, Session
import odoo import odoo
from odoo import api from odoo import api
@@ -243,7 +244,7 @@ def _normalize_arch_for_assert(arch_string, parser_method="xml"):
arch_string = etree.fromstring(arch_string, parser=parser) arch_string = etree.fromstring(arch_string, parser=parser)
return etree.tostring(arch_string, pretty_print=True, encoding='unicode') return etree.tostring(arch_string, pretty_print=True, encoding='unicode')
_super_send = requests.Session.send
class BaseCase(case.TestCase, metaclass=MetaCase): class BaseCase(case.TestCase, metaclass=MetaCase):
""" Subclass of TestCase for Odoo-specific code. This class is abstract and """ Subclass of TestCase for Odoo-specific code. This class is abstract and
expects self.registry, self.cr and self.uid to be initialized by subclasses. expects self.registry, self.cr and self.uid to be initialized by subclasses.
@@ -257,6 +258,33 @@ class BaseCase(case.TestCase, metaclass=MetaCase):
super().__init__(methodName) super().__init__(methodName)
self.addTypeEqualityFunc(etree._Element, self.assertTreesEqual) self.addTypeEqualityFunc(etree._Element, self.assertTreesEqual)
self.addTypeEqualityFunc(html.HtmlElement, self.assertTreesEqual) self.addTypeEqualityFunc(html.HtmlElement, self.assertTreesEqual)
self.request_mock = None
def setUp(self):
super().setUp()
if 'external' not in self.test_tags:
# if the method is passed directly `patch` discards the session
# object which we need
# pylint: disable=unnecessary-lambda
self.patch(
requests.sessions.Session,
'send',
lambda s, r, **kwargs: self._request_handler(s, r, **kwargs),
)
def _request_handler(self, s: Session, r: PreparedRequest, /, **kw):
# allow localhost requests
# TODO: also check port?
url = werkzeug.urls.url_parse(r.url)
if url.host in (HOST, 'localhost'):
return _super_send(s, r, **kw)
if url.scheme == 'file':
return _super_send(s, r, **kw)
_logger.getChild('requests').info(
"Blocking un-mocked external HTTP request %s %s", r.method, r.url)
raise requests.exceptions.ConnectionError(
f"External requests verboten (was {r.method} {r.url})")
def run(self, result): def run(self, result):
testMethod = getattr(self, self._testMethodName) testMethod = getattr(self, self._testMethodName)