|
|
|
|
|
# Copyright (C) 2010-2023 RhodeCode GmbH
|
|
|
#
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
|
# it under the terms of the GNU Affero General Public License, version 3
|
|
|
# (only), as published by the Free Software Foundation.
|
|
|
#
|
|
|
# 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 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/>.
|
|
|
#
|
|
|
# This program is dual-licensed. If you wish to learn more about the
|
|
|
# RhodeCode Enterprise Edition, including its added features, Support services,
|
|
|
# and proprietary license terms, please see https://rhodecode.com/licenses/
|
|
|
|
|
|
import logging
|
|
|
import io
|
|
|
|
|
|
import mock
|
|
|
import msgpack
|
|
|
import pytest
|
|
|
|
|
|
from rhodecode.lib import hooks_daemon
|
|
|
from rhodecode.lib.str_utils import safe_bytes
|
|
|
from rhodecode.tests.utils import assert_message_in_log
|
|
|
from rhodecode.lib.ext_json import json
|
|
|
|
|
|
test_proto = hooks_daemon.HooksHttpHandler.MSGPACK_HOOKS_PROTO
|
|
|
|
|
|
|
|
|
class TestDummyHooksCallbackDaemon(object):
|
|
|
def test_hooks_module_path_set_properly(self):
|
|
|
daemon = hooks_daemon.DummyHooksCallbackDaemon()
|
|
|
assert daemon.hooks_module == 'rhodecode.lib.hooks_daemon'
|
|
|
|
|
|
def test_logs_entering_the_hook(self):
|
|
|
daemon = hooks_daemon.DummyHooksCallbackDaemon()
|
|
|
with mock.patch.object(hooks_daemon.log, 'debug') as log_mock:
|
|
|
with daemon as return_value:
|
|
|
log_mock.assert_called_once_with(
|
|
|
'Running `%s` callback daemon', 'DummyHooksCallbackDaemon')
|
|
|
assert return_value == daemon
|
|
|
|
|
|
def test_logs_exiting_the_hook(self):
|
|
|
daemon = hooks_daemon.DummyHooksCallbackDaemon()
|
|
|
with mock.patch.object(hooks_daemon.log, 'debug') as log_mock:
|
|
|
with daemon:
|
|
|
pass
|
|
|
log_mock.assert_called_with(
|
|
|
'Exiting `%s` callback daemon', 'DummyHooksCallbackDaemon')
|
|
|
|
|
|
|
|
|
class TestHooks(object):
|
|
|
def test_hooks_can_be_used_as_a_context_processor(self):
|
|
|
hooks = hooks_daemon.Hooks()
|
|
|
with hooks as return_value:
|
|
|
pass
|
|
|
assert hooks == return_value
|
|
|
|
|
|
|
|
|
class TestHooksHttpHandler(object):
|
|
|
def test_read_request_parses_method_name_and_arguments(self):
|
|
|
data = {
|
|
|
'method': 'test',
|
|
|
'extras': {
|
|
|
'param1': 1,
|
|
|
'param2': 'a'
|
|
|
}
|
|
|
}
|
|
|
request = self._generate_post_request(data)
|
|
|
hooks_patcher = mock.patch.object(
|
|
|
hooks_daemon.Hooks, data['method'], create=True, return_value=1)
|
|
|
|
|
|
with hooks_patcher as hooks_mock:
|
|
|
handler = hooks_daemon.HooksHttpHandler
|
|
|
handler.DEFAULT_HOOKS_PROTO = test_proto
|
|
|
handler.wbufsize = 10240
|
|
|
MockServer(handler, request)
|
|
|
|
|
|
hooks_mock.assert_called_once_with(data['extras'])
|
|
|
|
|
|
def test_hooks_serialized_result_is_returned(self):
|
|
|
request = self._generate_post_request({})
|
|
|
rpc_method = 'test'
|
|
|
hook_result = {
|
|
|
'first': 'one',
|
|
|
'second': 2
|
|
|
}
|
|
|
extras = {}
|
|
|
|
|
|
# patching our _read to return test method and proto used
|
|
|
read_patcher = mock.patch.object(
|
|
|
hooks_daemon.HooksHttpHandler, '_read_request',
|
|
|
return_value=(test_proto, rpc_method, extras))
|
|
|
|
|
|
# patch Hooks instance to return hook_result data on 'test' call
|
|
|
hooks_patcher = mock.patch.object(
|
|
|
hooks_daemon.Hooks, rpc_method, create=True,
|
|
|
return_value=hook_result)
|
|
|
|
|
|
with read_patcher, hooks_patcher:
|
|
|
handler = hooks_daemon.HooksHttpHandler
|
|
|
handler.DEFAULT_HOOKS_PROTO = test_proto
|
|
|
handler.wbufsize = 10240
|
|
|
server = MockServer(handler, request)
|
|
|
|
|
|
expected_result = hooks_daemon.HooksHttpHandler.serialize_data(hook_result)
|
|
|
|
|
|
server.request.output_stream.seek(0)
|
|
|
assert server.request.output_stream.readlines()[-1] == expected_result
|
|
|
|
|
|
def test_exception_is_returned_in_response(self):
|
|
|
request = self._generate_post_request({})
|
|
|
rpc_method = 'test'
|
|
|
|
|
|
read_patcher = mock.patch.object(
|
|
|
hooks_daemon.HooksHttpHandler, '_read_request',
|
|
|
return_value=(test_proto, rpc_method, {}))
|
|
|
|
|
|
hooks_patcher = mock.patch.object(
|
|
|
hooks_daemon.Hooks, rpc_method, create=True,
|
|
|
side_effect=Exception('Test exception'))
|
|
|
|
|
|
with read_patcher, hooks_patcher:
|
|
|
handler = hooks_daemon.HooksHttpHandler
|
|
|
handler.DEFAULT_HOOKS_PROTO = test_proto
|
|
|
handler.wbufsize = 10240
|
|
|
server = MockServer(handler, request)
|
|
|
|
|
|
server.request.output_stream.seek(0)
|
|
|
data = server.request.output_stream.readlines()
|
|
|
msgpack_data = b''.join(data[5:])
|
|
|
org_exc = hooks_daemon.HooksHttpHandler.deserialize_data(msgpack_data)
|
|
|
expected_result = {
|
|
|
'exception': 'Exception',
|
|
|
'exception_traceback': org_exc['exception_traceback'],
|
|
|
'exception_args': ['Test exception']
|
|
|
}
|
|
|
assert org_exc == expected_result
|
|
|
|
|
|
def test_log_message_writes_to_debug_log(self, caplog):
|
|
|
ip_port = ('0.0.0.0', 8888)
|
|
|
handler = hooks_daemon.HooksHttpHandler(
|
|
|
MockRequest('POST /'), ip_port, mock.Mock())
|
|
|
fake_date = '1/Nov/2015 00:00:00'
|
|
|
date_patcher = mock.patch.object(
|
|
|
handler, 'log_date_time_string', return_value=fake_date)
|
|
|
|
|
|
with date_patcher, caplog.at_level(logging.DEBUG):
|
|
|
handler.log_message('Some message %d, %s', 123, 'string')
|
|
|
|
|
|
expected_message = f"HOOKS: client={ip_port} - - [{fake_date}] Some message 123, string"
|
|
|
|
|
|
assert_message_in_log(
|
|
|
caplog.records, expected_message,
|
|
|
levelno=logging.DEBUG, module='hooks_daemon')
|
|
|
|
|
|
def _generate_post_request(self, data, proto=test_proto):
|
|
|
if proto == hooks_daemon.HooksHttpHandler.MSGPACK_HOOKS_PROTO:
|
|
|
payload = msgpack.packb(data)
|
|
|
else:
|
|
|
payload = json.dumps(data)
|
|
|
|
|
|
return b'POST / HTTP/1.0\nContent-Length: %d\n\n%b' % (
|
|
|
len(payload), payload)
|
|
|
|
|
|
|
|
|
class ThreadedHookCallbackDaemon(object):
|
|
|
def test_constructor_calls_prepare(self):
|
|
|
prepare_daemon_patcher = mock.patch.object(
|
|
|
hooks_daemon.ThreadedHookCallbackDaemon, '_prepare')
|
|
|
with prepare_daemon_patcher as prepare_daemon_mock:
|
|
|
hooks_daemon.ThreadedHookCallbackDaemon()
|
|
|
prepare_daemon_mock.assert_called_once_with()
|
|
|
|
|
|
def test_run_is_called_on_context_start(self):
|
|
|
patchers = mock.patch.multiple(
|
|
|
hooks_daemon.ThreadedHookCallbackDaemon,
|
|
|
_run=mock.DEFAULT, _prepare=mock.DEFAULT, __exit__=mock.DEFAULT)
|
|
|
|
|
|
with patchers as mocks:
|
|
|
daemon = hooks_daemon.ThreadedHookCallbackDaemon()
|
|
|
with daemon as daemon_context:
|
|
|
pass
|
|
|
mocks['_run'].assert_called_once_with()
|
|
|
assert daemon_context == daemon
|
|
|
|
|
|
def test_stop_is_called_on_context_exit(self):
|
|
|
patchers = mock.patch.multiple(
|
|
|
hooks_daemon.ThreadedHookCallbackDaemon,
|
|
|
_run=mock.DEFAULT, _prepare=mock.DEFAULT, _stop=mock.DEFAULT)
|
|
|
|
|
|
with patchers as mocks:
|
|
|
daemon = hooks_daemon.ThreadedHookCallbackDaemon()
|
|
|
with daemon as daemon_context:
|
|
|
assert mocks['_stop'].call_count == 0
|
|
|
|
|
|
mocks['_stop'].assert_called_once_with()
|
|
|
assert daemon_context == daemon
|
|
|
|
|
|
|
|
|
class TestHttpHooksCallbackDaemon(object):
|
|
|
def test_hooks_callback_generates_new_port(self, caplog):
|
|
|
with caplog.at_level(logging.DEBUG):
|
|
|
daemon = hooks_daemon.HttpHooksCallbackDaemon(host='127.0.0.1', port=8881)
|
|
|
assert daemon._daemon.server_address == ('127.0.0.1', 8881)
|
|
|
|
|
|
with caplog.at_level(logging.DEBUG):
|
|
|
daemon = hooks_daemon.HttpHooksCallbackDaemon(host=None, port=None)
|
|
|
assert daemon._daemon.server_address[1] in range(0, 66000)
|
|
|
assert daemon._daemon.server_address[0] != '127.0.0.1'
|
|
|
|
|
|
def test_prepare_inits_daemon_variable(self, tcp_server, caplog):
|
|
|
with self._tcp_patcher(tcp_server), caplog.at_level(logging.DEBUG):
|
|
|
daemon = hooks_daemon.HttpHooksCallbackDaemon(host='127.0.0.1', port=8881)
|
|
|
assert daemon._daemon == tcp_server
|
|
|
|
|
|
_, port = tcp_server.server_address
|
|
|
|
|
|
msg = f"HOOKS: 127.0.0.1:{port} Preparing HTTP callback daemon registering " \
|
|
|
f"hook object: <class 'rhodecode.lib.hooks_daemon.HooksHttpHandler'>"
|
|
|
assert_message_in_log(
|
|
|
caplog.records, msg, levelno=logging.DEBUG, module='hooks_daemon')
|
|
|
|
|
|
def test_prepare_inits_hooks_uri_and_logs_it(
|
|
|
self, tcp_server, caplog):
|
|
|
with self._tcp_patcher(tcp_server), caplog.at_level(logging.DEBUG):
|
|
|
daemon = hooks_daemon.HttpHooksCallbackDaemon(host='127.0.0.1', port=8881)
|
|
|
|
|
|
_, port = tcp_server.server_address
|
|
|
expected_uri = '{}:{}'.format('127.0.0.1', port)
|
|
|
assert daemon.hooks_uri == expected_uri
|
|
|
|
|
|
msg = f"HOOKS: 127.0.0.1:{port} Preparing HTTP callback daemon registering " \
|
|
|
f"hook object: <class 'rhodecode.lib.hooks_daemon.HooksHttpHandler'>"
|
|
|
assert_message_in_log(
|
|
|
caplog.records, msg,
|
|
|
levelno=logging.DEBUG, module='hooks_daemon')
|
|
|
|
|
|
def test_run_creates_a_thread(self, tcp_server):
|
|
|
thread = mock.Mock()
|
|
|
|
|
|
with self._tcp_patcher(tcp_server):
|
|
|
daemon = hooks_daemon.HttpHooksCallbackDaemon()
|
|
|
|
|
|
with self._thread_patcher(thread) as thread_mock:
|
|
|
daemon._run()
|
|
|
|
|
|
thread_mock.assert_called_once_with(
|
|
|
target=tcp_server.serve_forever,
|
|
|
kwargs={'poll_interval': daemon.POLL_INTERVAL})
|
|
|
assert thread.daemon is True
|
|
|
thread.start.assert_called_once_with()
|
|
|
|
|
|
def test_run_logs(self, tcp_server, caplog):
|
|
|
|
|
|
with self._tcp_patcher(tcp_server):
|
|
|
daemon = hooks_daemon.HttpHooksCallbackDaemon()
|
|
|
|
|
|
with self._thread_patcher(mock.Mock()), caplog.at_level(logging.DEBUG):
|
|
|
daemon._run()
|
|
|
|
|
|
assert_message_in_log(
|
|
|
caplog.records,
|
|
|
'Running thread-based loop of callback daemon in background',
|
|
|
levelno=logging.DEBUG, module='hooks_daemon')
|
|
|
|
|
|
def test_stop_cleans_up_the_connection(self, tcp_server, caplog):
|
|
|
thread = mock.Mock()
|
|
|
|
|
|
with self._tcp_patcher(tcp_server):
|
|
|
daemon = hooks_daemon.HttpHooksCallbackDaemon()
|
|
|
|
|
|
with self._thread_patcher(thread), caplog.at_level(logging.DEBUG):
|
|
|
with daemon:
|
|
|
assert daemon._daemon == tcp_server
|
|
|
assert daemon._callback_thread == thread
|
|
|
|
|
|
assert daemon._daemon is None
|
|
|
assert daemon._callback_thread is None
|
|
|
tcp_server.shutdown.assert_called_with()
|
|
|
thread.join.assert_called_once_with()
|
|
|
|
|
|
assert_message_in_log(
|
|
|
caplog.records, 'Waiting for background thread to finish.',
|
|
|
levelno=logging.DEBUG, module='hooks_daemon')
|
|
|
|
|
|
def _tcp_patcher(self, tcp_server):
|
|
|
return mock.patch.object(
|
|
|
hooks_daemon, 'TCPServer', return_value=tcp_server)
|
|
|
|
|
|
def _thread_patcher(self, thread):
|
|
|
return mock.patch.object(
|
|
|
hooks_daemon.threading, 'Thread', return_value=thread)
|
|
|
|
|
|
|
|
|
class TestPrepareHooksDaemon(object):
|
|
|
@pytest.mark.parametrize('protocol', ('http',))
|
|
|
def test_returns_dummy_hooks_callback_daemon_when_using_direct_calls(
|
|
|
self, protocol):
|
|
|
expected_extras = {'extra1': 'value1'}
|
|
|
callback, extras = hooks_daemon.prepare_callback_daemon(
|
|
|
expected_extras.copy(), protocol=protocol,
|
|
|
host='127.0.0.1', use_direct_calls=True)
|
|
|
assert isinstance(callback, hooks_daemon.DummyHooksCallbackDaemon)
|
|
|
expected_extras['hooks_module'] = 'rhodecode.lib.hooks_daemon'
|
|
|
expected_extras['time'] = extras['time']
|
|
|
assert 'extra1' in extras
|
|
|
|
|
|
@pytest.mark.parametrize('protocol, expected_class', (
|
|
|
('http', hooks_daemon.HttpHooksCallbackDaemon),
|
|
|
))
|
|
|
def test_returns_real_hooks_callback_daemon_when_protocol_is_specified(
|
|
|
self, protocol, expected_class):
|
|
|
expected_extras = {
|
|
|
'extra1': 'value1',
|
|
|
'txn_id': 'txnid2',
|
|
|
'hooks_protocol': protocol.lower()
|
|
|
}
|
|
|
callback, extras = hooks_daemon.prepare_callback_daemon(
|
|
|
expected_extras.copy(), protocol=protocol, host='127.0.0.1',
|
|
|
use_direct_calls=False,
|
|
|
txn_id='txnid2')
|
|
|
assert isinstance(callback, expected_class)
|
|
|
extras.pop('hooks_uri')
|
|
|
expected_extras['time'] = extras['time']
|
|
|
assert extras == expected_extras
|
|
|
|
|
|
@pytest.mark.parametrize('protocol', (
|
|
|
'invalid',
|
|
|
'Http',
|
|
|
'HTTP',
|
|
|
))
|
|
|
def test_raises_on_invalid_protocol(self, protocol):
|
|
|
expected_extras = {
|
|
|
'extra1': 'value1',
|
|
|
'hooks_protocol': protocol.lower()
|
|
|
}
|
|
|
with pytest.raises(Exception):
|
|
|
callback, extras = hooks_daemon.prepare_callback_daemon(
|
|
|
expected_extras.copy(),
|
|
|
protocol=protocol, host='127.0.0.1',
|
|
|
use_direct_calls=False)
|
|
|
|
|
|
|
|
|
class MockRequest(object):
|
|
|
|
|
|
def __init__(self, request):
|
|
|
self.request = request
|
|
|
self.input_stream = io.BytesIO(safe_bytes(self.request))
|
|
|
self.output_stream = io.BytesIO() # make it un-closable for testing invesitagion
|
|
|
self.output_stream.close = lambda: None
|
|
|
|
|
|
def makefile(self, mode, *args, **kwargs):
|
|
|
return self.output_stream if mode == 'wb' else self.input_stream
|
|
|
|
|
|
|
|
|
class MockServer(object):
|
|
|
|
|
|
def __init__(self, handler_cls, request):
|
|
|
ip_port = ('0.0.0.0', 8888)
|
|
|
self.request = MockRequest(request)
|
|
|
self.server_address = ip_port
|
|
|
self.handler = handler_cls(self.request, ip_port, self)
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
def tcp_server():
|
|
|
server = mock.Mock()
|
|
|
server.server_address = ('127.0.0.1', 8881)
|
|
|
server.wbufsize = 1024
|
|
|
return server
|
|
|
|