439 lines
16 KiB
Python
439 lines
16 KiB
Python
# 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 <http://www.gnu.org/licenses/>.
|
|
|
|
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
|