# passerelle - uniform access to multiple data sources and services # Copyright (C) 2023 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import datetime import itertools import logging import pytest import requests from django.core.exceptions import ValidationError from django.db import transaction from django.utils.log import AdminEmailHandler from httmock import HTTMock import tests.utils from passerelle.apps.feeds.models import Feed from passerelle.base.models import ProxyLogger, ResourceLog from passerelle.contrib.stub_invoices.models import StubInvoicesConnector from passerelle.utils.api import endpoint from passerelle.utils.defer import run_later_scope from passerelle.utils.jsonresponse import APIError from tests.test_availability import down_mock, up_mock @pytest.fixture def connector(): connector, _ = Feed.objects.get_or_create(slug='some-slug') connector.set_log_level('DEBUG') connector.url = 'http://example.net/' connector.save() return connector @pytest.fixture def email_handler(): root = logging.getLogger() handler = AdminEmailHandler(include_html=True) handler.level = logging.ERROR root.handlers.append(handler) try: yield finally: root.handlers.remove(handler) def test_proxy_logger_basic(db, connector): pr = ProxyLogger(connector) pr.debug('some message') rl_query = ResourceLog.objects.all() assert len(rl_query) == 1 rl = rl_query.first() assert rl.message == 'some message' assert rl.levelno == logging.DEBUG assert rl.appname == 'feeds' assert rl.slug == 'some-slug' def test_proxy_logger_std_interpolation(db, connector): ResourceLog.objects.all().delete() pr = ProxyLogger(connector) pr.debug('some message %s', 'some var') rl_query = ResourceLog.objects.all() rl = rl_query.first() assert rl.message == 'some message some var' def test_proxy_logger_dict_interpolation(db, connector): ResourceLog.objects.all().delete() pr = ProxyLogger(connector) pr.debug('some message %(var_name)s', {'var_name': 'some var'}) rl_query = ResourceLog.objects.all() rl = rl_query.first() assert rl.message == 'some message some var' def test_proxy_logger_ignore(db, connector): ResourceLog.objects.all().delete() connector.set_log_level('INFO') pr = ProxyLogger(connector) pr.debug('some message') assert len(ResourceLog.objects.all()) == 0 def test_proxy_logger_ignore_when_down(db, connector): with HTTMock(down_mock): # set connector as down connector.availability() assert connector.down() is True ResourceLog.objects.all().delete() pr = ProxyLogger(connector) pr.debug('some message') assert len(ResourceLog.objects.all()) == 0 @pytest.mark.parametrize( 'error_msg, expected', [ ('Foo Bar', 'Foo Bar'), ('http://badurl', 'http://badurl'), ( 'GET http://tcl.example.net/tclpassagearret (=> 502)', 'GET http://tcl.example.net/tclpassagearret (=> 502)', ), ( 'GET https://tcl.example.net/tclpassagearret (=> 502)', 'GET https://tcl.example.net/tclpassagearret (=> 502)', ), ( 'GET http://username:password@tcl.example.net/tclpassagearret (=> 502)', 'GET http://***:***@tcl.example.net/tclpassagearret (=> 502)', ), ( 'GET https://username:password@tcl.example.net/tclpassagearret (=> 502)', 'GET https://***:***@tcl.example.net/tclpassagearret (=> 502)', ), ], ) def test_proxy_logger_hide_url_credentials( db, settings, email_handler, mailoutbox, connector, error_msg, expected ): settings.ADMINS = [('admin', 'admin@example.net')] pr = ProxyLogger(connector) pr.error(error_msg) assert len(mailoutbox) == 1 msg = mailoutbox[0] assert msg.to == ['admin@example.net'] assert msg.subject == '[Django] ERROR: %s' % expected assert expected in msg.body rl = ResourceLog.objects.latest('pk') assert rl.message == expected def test_validate_notification_delays(db, connector): def take(iterator, count): return list(itertools.compress(iterator, range(1, count + 1))) availability_parameters = connector.availability_parameters assert availability_parameters.notification_delays == '0' availability_parameters.full_clean() assert availability_parameters.has_zero_delay() assert list(availability_parameters.notification_delays_generator()) == [0] availability_parameters.notification_delays = '0,5,100,1000' availability_parameters.full_clean() assert availability_parameters.has_zero_delay() assert take(availability_parameters.notification_delays_generator(), 6) == [0, 5, 100, 1000, 2000, 3000] availability_parameters.notification_delays = '5,100,1000' availability_parameters.full_clean() assert not availability_parameters.has_zero_delay() assert take(availability_parameters.notification_delays_generator(), 6) == [ 5, 100, 1000, 2000, 3000, 4000, ] availability_parameters.notification_delays = '5' availability_parameters.full_clean() assert not availability_parameters.has_zero_delay() assert take(availability_parameters.notification_delays_generator(), 3) == [5, 10, 15] # validation errors availability_parameters.notification_delays = '5,100,100' with pytest.raises(ValidationError): availability_parameters.full_clean() availability_parameters.notification_delays = '' with pytest.raises(ValidationError): availability_parameters.full_clean() availability_parameters.notification_delays = '1,x' with pytest.raises(ValidationError): availability_parameters.full_clean() availability_parameters.notification_delays = '10,1' with pytest.raises(ValidationError): availability_parameters.full_clean() @pytest.mark.parametrize('notification_delays', ['0', '0,5,100', '5,100']) def test_log_on_connector_availability_change(db, connector, freezer, notification_delays): connector.title = 'éléphant' availability_parameters = connector.availability_parameters availability_parameters.notification_delays = notification_delays availability_parameters.save() with HTTMock(up_mock): # set connector as up connector.availability() assert ResourceLog.objects.count() == 1 assert ResourceLog.objects.latest('id').level == 'info' assert ResourceLog.objects.latest('id').message == 'GET http://example.net/ (=> 200)' ResourceLog.objects.all().delete() # move 5 minutes in the future freezer.move_to(datetime.timedelta(seconds=60 * 5 + 1)) with HTTMock(down_mock): # set connector as down connector.availability() assert connector.down() last_count1 = ResourceLog.objects.count() assert last_count1 == 2 assert ResourceLog.objects.all()[0].message == 'GET http://example.net/ (=> 404)' assert ResourceLog.objects.all()[1].level == 'error' if notification_delays.startswith('0') else 'warning' assert 'connector "éléphant" (Feed) is now down: 404 Client' in ResourceLog.objects.all()[1].message # move 5 minutes in the future freezer.move_to(datetime.timedelta(seconds=60 * 5 + 1)) # second time log as error with HTTMock(down_mock): # connector is still down connector.availability() assert connector.down() last_count2 = ResourceLog.objects.count() if notification_delays == '0': assert last_count2 == last_count1 else: assert last_count2 == last_count1 + 1 assert ResourceLog.objects.all()[2].level == 'error' if notification_delays != '0' else 'warning' assert ( 'connector "éléphant" (Feed) has been down for 5 minutes: 404' in ResourceLog.objects.all()[2].message ) # move 3 minutes in the future freezer.move_to(datetime.timedelta(seconds=60 * 3 + 1)) # third time no log with HTTMock(down_mock): # connector is still down connector.availability() assert ResourceLog.objects.count() == last_count2 # move 3 minutes in the future freezer.move_to(datetime.timedelta(seconds=60 * 3 + 1)) with HTTMock(up_mock): # set connector as up connector.availability() assert not connector.down() last_count3 = ResourceLog.objects.count() assert last_count3 == last_count2 + 1 assert ResourceLog.objects.latest('id').level == 'info' assert ResourceLog.objects.latest('id').message == 'connector "éléphant" (Feed) is back up' # move 3 minutes in the future freezer.move_to(datetime.timedelta(seconds=60 * 3 + 1)) # last message is a GET with HTTMock(up_mock): # set connector as up connector.availability() assert not connector.down() last_count4 = ResourceLog.objects.count() assert last_count4 == last_count3 + 1 assert ResourceLog.objects.latest('id').level == 'info' assert ResourceLog.objects.latest('id').message == 'GET http://example.net/ (=> 200)' def test_proxy_logger_context(db, connector): base_logger = ProxyLogger(connector) base_logger.debug('test') log = ResourceLog.objects.latest('id') assert log.extra == {} context1_logger = base_logger.context(extra1='toto') context1_logger.debug('test') log = ResourceLog.objects.latest('id') assert log.extra == {'extra1': 'toto'} # Check extra is updated not overwritten context2_logger = context1_logger.context(extra2='titi') context2_logger.debug('test') log = ResourceLog.objects.latest('id') assert log.extra == {'extra1': 'toto', 'extra2': 'titi'} # Check other logger were not modified context1_logger.debug('test') log = ResourceLog.objects.latest('id') assert log.extra == {'extra1': 'toto'} base_logger.debug('test') log = ResourceLog.objects.latest('id') assert log.extra == {} def test_logged_requests_and_responses_max_size(app, db, monkeypatch, settings): URL = 'http://whatever.invalid' @endpoint(methods=['post'], perm='OPEN') def httpcall(self, request): connector_payload = {'connector_query_var': '2' * 20} self.requests.post(URL, connector_payload) raise APIError({'connector_error_var': '4' * 20}) monkeypatch.setattr(StubInvoicesConnector, 'httpcall', httpcall, raising=False) connector = StubInvoicesConnector(slug='fake') connector.save() url = '/%s/%s/httpcall/' % (connector.get_connector_slug(), connector.slug) user_payload = {'user_query_var': '1' * 20} service_response = {'service_reply_var': '3' * 20} service_headers = {'Content-Type': 'foo/bar'} # default configuration assert connector.logging_parameters.requests_max_size is None assert connector.logging_parameters.responses_max_size is None with tests.utils.mock_url(URL, service_response, headers=service_headers): app.post(url, params=user_payload, status=200) assert len(ResourceLog.objects.all()) == 3 # - user POST query assert ResourceLog.objects.all()[0].extra['connector_payload'] == 'user_query_var=11111111111111111111' # - connector POST queries assert not ResourceLog.objects.all()[1].extra.get('request_payload') assert not ResourceLog.objects.all()[1].extra.get('response_headers') assert not ResourceLog.objects.all()[1].extra.get('response_content') # - connector error assert ResourceLog.objects.all()[2].extra['body'] == 'user_query_var=11111111111111111111' assert ( ResourceLog.objects.all()[2].extra['exception'] == "{'connector_error_var': '44444444444444444444'}" ) # log connector payload and service response connector.set_log_level('DEBUG') settings.LOGGED_CONTENT_TYPES_MESSAGES = 'foo/bar' # log response content ResourceLog.objects.all().delete() with tests.utils.mock_url(URL, service_response, headers=service_headers): app.post(url, params=user_payload, status=200) assert len(ResourceLog.objects.all()) == 3 # - connector POST queries assert ResourceLog.objects.all()[1].extra['request_payload'] == 'connector_query_var=22222222222222222222' assert ResourceLog.objects.all()[1].extra.get('response_headers') == {'Content-Type': 'foo/bar'} assert ( ResourceLog.objects.all()[1].extra.get('response_content') == '{"service_reply_var": "33333333333333333333"}' ) # log troncated payloads parameters = connector.logging_parameters parameters.requests_max_size = 25 parameters.save() ResourceLog.objects.all().delete() with tests.utils.mock_url(URL, service_response, headers=service_headers): app.post(url, params=user_payload, status=200) assert len(ResourceLog.objects.all()) == 3 # - user POST query assert ResourceLog.objects.all()[0].extra['connector_payload'] == 'user_query_var=1111111111' # - connector POST queries assert ResourceLog.objects.all()[1].extra['request_payload'] == 'connector_query_var=22222' # - connector error assert ResourceLog.objects.all()[2].extra['body'] == 'user_query_var=1111111111' # log troncated service response parameters = connector.logging_parameters parameters.responses_max_size = 25 parameters.save() ResourceLog.objects.all().delete() with tests.utils.mock_url(URL, service_response, headers=service_headers): app.post(url, params=user_payload, status=200) assert len(ResourceLog.objects.all()) == 3 # - connector POST queries assert ResourceLog.objects.all()[1].extra.get('response_content') == '{"service_reply_var": "33' def test_proxy_logger_email_traceback(app, db, email_handler, settings, mailoutbox, connector, monkeypatch): settings.ADMINS = [('admin', 'admin@example.net')] endpoint_url = tests.utils.generic_endpoint_url('feeds', 'json', slug=connector.slug) @endpoint(perm='OPEN') def json(self, request): raise requests.ConnectionError('timeout') monkeypatch.setattr(Feed, 'json', json) app.get(endpoint_url, status=500) assert any('Exception Type: ConnectionError' in mail.body for mail in mailoutbox) def test_proxy_logger_bytes(db, connector): base_logger = ProxyLogger(connector) base_logger.debug('test', extra={'payload': b'bytes'}) log = ResourceLog.objects.latest('id') assert log.extra == {'payload': 'bytes'} base_logger = ProxyLogger(connector) base_logger.debug('test', extra={'payload': b'\xff\xff'}) log = ResourceLog.objects.latest('id') assert log.extra == {'payload': '\\xff\\xff'} def test_log_in_transaction(transactional_db, connector): qs = ResourceLog.objects.all() assert not qs.exists() class MyError(Exception): pass # without run_later_scope logs inside transactions are lost try: with transaction.atomic(): connector.logger.info('info') connector.logger.warning('warning') raise MyError except MyError: pass assert qs.count() == 0 # with run_later_scope logs inside transaction are kept, because they # inserted in the db after the rollback try: with run_later_scope(): with transaction.atomic(): connector.logger.info('info') connector.logger.warning('warning') raise MyError except MyError: pass assert qs.count() == 2