Fix database encoding and exception handling in update service (#14851)

* Fix database encoding and exception handling in update service

* Remove leftover ipdb

* Cleaner way to override SERVICES_DATABASE in tests through pytest fixture

* Remove now unused imports
This commit is contained in:
Mathieu Pillard 2020-07-07 13:23:13 +02:00 коммит произвёл GitHub
Родитель faf140b02d
Коммит 461f0695a7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
4 изменённых файлов: 120 добавлений и 43 удалений

Просмотреть файл

@ -81,6 +81,11 @@ def mock_basket(settings):
json={'status': 'ok', 'token': USER_TOKEN})
@pytest.fixture(autouse=True)
def update_services_db_name_to_follow_test_db_name(db, settings, request):
settings.SERVICES_DATABASE['NAME'] = settings.DATABASES['default']['NAME']
def pytest_configure(config):
import django
# Forcefully call `django.setup`, pytest-django tries to be very lazy

Просмотреть файл

@ -1,5 +1,4 @@
import json
import sys
from django.utils.encoding import force_bytes
from email.utils import formatdate
@ -24,7 +23,7 @@ import olympia.core.logger
# Go configure the log.
log_configure()
error_log = olympia.core.logger.getLogger('z.services')
log = olympia.core.logger.getLogger('z.services')
class Update(object):
@ -155,7 +154,6 @@ class Update(object):
sql.append('AND appmax.version_int >= %(version_int)s ')
sql.append('ORDER BY versions.id DESC LIMIT 1;')
self.cursor.execute(''.join(sql), data)
result = self.cursor.fetchone()
@ -237,11 +235,6 @@ class Update(object):
('Content-Length', str(length))]
def log_exception(data):
(typ, value, traceback) = sys.exc_info()
error_log.error(u'Type: %s, %s. Query: %s' % (typ, value, data))
def application(environ, start_response):
status = '200 OK'
with statsd.timer('services.update'):
@ -251,7 +244,7 @@ def application(environ, start_response):
update = Update(data, compat_mode)
output = force_bytes(update.get_output())
start_response(status, update.get_headers(len(output)))
except Exception:
log_exception(data)
except Exception as e:
log.exception(e)
raise
return [output]

Просмотреть файл

@ -76,7 +76,8 @@ def get_cdn_url(id, row):
def getconn():
db = settings.SERVICES_DATABASE
return mysql.connect(host=db['HOST'], user=db['USER'],
passwd=db['PASSWORD'], db=db['NAME'])
passwd=db['PASSWORD'], db=db['NAME'],
charset=db['OPTIONS']['charset'])
mypool = pool.QueuePool(getconn, max_overflow=10, pool_size=5, recycle=300)

Просмотреть файл

@ -5,13 +5,15 @@ from unittest import mock
from datetime import datetime, timedelta
from email import utils
from django.conf import settings
from django.db import connection
from django.test.testcases import TransactionTestCase
from services import update
from olympia import amo
from olympia.addons.models import Addon
from olympia.amo.tests import TestCase
from olympia.amo.tests import addon_factory, TestCase
from olympia.applications.models import AppVersion
from olympia.files.models import File
from olympia.versions.models import ApplicationsVersions, Version
@ -406,37 +408,6 @@ class TestDefaultToCompat(VersionCheckMixin, TestCase):
expected['-'.join([version, mode])]
)
def test_application(self):
# Basic test making sure application() is returning the output of
# Update.get_output(). Have to mock Update(): otherwise, the real
# database would be hit, not the test one, because of how services
# use a different setting and database connection APIs.
environ = {
'QUERY_STRING': ''
}
self.start_response_call_count = 0
expected_headers = [
('FakeHeader', 'FakeHeaderValue')
]
expected_output = b'{"fake": "output"}'
def start_response_inspector(status, headers):
self.start_response_call_count += 1
assert status == '200 OK'
assert headers == expected_headers
with mock.patch('services.update.Update') as UpdateMock:
update_instance = UpdateMock.return_value
update_instance.get_headers.return_value = expected_headers
update_instance.get_output.return_value = expected_output
output = update.application(environ, start_response_inspector)
assert self.start_response_call_count == 1
# Output is an array with a single string containing the body of the
# response.
assert output == [expected_output]
def test_baseline(self):
# Tests simple add-on (non-binary-components, non-strict).
self.check(self.expected)
@ -651,3 +622,110 @@ class TestResponse(VersionCheckMixin, TestCase):
assert (
json.loads(instance.get_output()) ==
instance.get_no_updates_output())
def test_application(self):
# Basic test making sure application() is returning the output of
# Update.get_output(). Have to mock Update(): otherwise, even though
# we're setting SERVICES_DATABASE to point to the test database in
# settings_test.py, we wouldn't see results because the data wouldn't
# exist with the cursor the update service is using, which is different
# from the one used by django tests.
environ = {
'QUERY_STRING': ''
}
self.start_response_call_count = 0
expected_headers = [
('FakeHeader', 'FakeHeaderValue')
]
expected_output = b'{"fake": "output"}'
def start_response_inspector(status, headers):
self.start_response_call_count += 1
assert status == '200 OK'
assert headers == expected_headers
with mock.patch('services.update.Update') as UpdateMock:
update_instance = UpdateMock.return_value
update_instance.get_headers.return_value = expected_headers
update_instance.get_output.return_value = expected_output
output = update.application(environ, start_response_inspector)
assert self.start_response_call_count == 1
# Output is an array with a single string containing the body of the
# response.
assert output == [expected_output]
@mock.patch('services.update.log')
@mock.patch('services.update.Update')
def test_exception_handling(self, UpdateMock, log_mock):
"""Test ensuring exceptions are raised and logged properly."""
class CustomException(Exception):
pass
self.inspector_call_count = 0
update_instance = UpdateMock.return_value
update_instance.get_output.side_effect = CustomException('Boom!')
def inspector(status, headers):
self.inspector_call_count += 1
with self.assertRaises(CustomException):
update.application({'QUERY_STRING': ''}, inspector)
assert self.inspector_call_count == 0
# The log should be present.
assert log_mock.exception.call_count == 1
log_mock.exception.assert_called_with(
update_instance.get_output.side_effect)
# This test needs to be a TransactionTestCase because we want to test the
# behavior of database cursor created by the update service. Since the data is
# written by a different cursor, it needs to be committed for the update
# service to see it (Other tests above that aren't explicitly mocking the
# service and care about the output cheat and override the cursor to use
# django's).
class TestUpdateConnectionEncoding(TransactionTestCase):
def setUp(self):
self.addon = addon_factory()
def test_service_database_setting(self):
from services.utils import mypool
expected_name = settings.DATABASES['default']['NAME']
assert 'test' in expected_name
assert settings.SERVICES_DATABASE['NAME'] == expected_name
connection = mypool.connect()
cursor = connection.cursor()
cursor.execute('SELECT DATABASE();')
assert cursor.fetchone()[0] == expected_name
connection.close()
def test_mypool_encoding(self):
from services.utils import mypool
connection = mypool.connect()
assert connection.connection.encoding == 'utf8'
connection.close()
def test_unicode_data(self):
# To trigger the error this test is trying to cover, we need 2 things:
# - An update request that would be considered 'valid', i.e. the
# necessary parameters are presentTestUpdateConnectionEncoding and
# the add-on exists.
# - A database cursor instantiated from the update service, not by
# django tests.
# Note that this test would hang before the fix to pass charset when
# connecting in services.utils.getconn().
data = {
'id': self.addon.guid,
'reqVersion': '2鎈',
'appID': amo.FIREFOX.guid,
'appVersion': '78.0',
}
instance = update.Update(data)
output = instance.get_output()
update_data = json.loads(output)
assert update_data