|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
# Copyright (C) 2016-2017 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 subprocess
|
|
|
from io import BytesIO
|
|
|
from time import sleep
|
|
|
|
|
|
import pytest
|
|
|
from mock import patch, Mock, MagicMock, call
|
|
|
|
|
|
from rhodecode.apps.ssh_support.lib.ssh_wrapper import SubversionTunnelWrapper
|
|
|
from rhodecode.tests import no_newline_id_generator
|
|
|
|
|
|
|
|
|
class TestSubversionTunnelWrapper(object):
|
|
|
@pytest.mark.parametrize(
|
|
|
'input_string, output_string', [
|
|
|
[None, ''],
|
|
|
['abcde', '5:abcde '],
|
|
|
['abcdefghijk', '11:abcdefghijk ']
|
|
|
])
|
|
|
def test_svn_string(self, input_string, output_string):
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
assert wrapper._svn_string(input_string) == output_string
|
|
|
|
|
|
def test_read_first_client_response(self):
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
buffer_ = '( abcd ( efg hij ) ) '
|
|
|
wrapper.stdin = BytesIO(buffer_)
|
|
|
result = wrapper._read_first_client_response()
|
|
|
assert result == buffer_
|
|
|
|
|
|
def test_parse_first_client_response_returns_dict(self):
|
|
|
response = (
|
|
|
'( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo'
|
|
|
' log-revprops ) 26:svn+ssh://vcs@vm/hello-svn 38:SVN/1.8.11'
|
|
|
' (x86_64-apple-darwin14.1.0) ( ) ) ')
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
result = wrapper._parse_first_client_response(response)
|
|
|
assert result['version'] == '2'
|
|
|
assert (
|
|
|
result['capabilities'] ==
|
|
|
'edit-pipeline svndiff1 absent-entries depth mergeinfo'
|
|
|
' log-revprops')
|
|
|
assert result['url'] == 'svn+ssh://vcs@vm/hello-svn'
|
|
|
assert result['ra_client'] == 'SVN/1.8.11 (x86_64-apple-darwin14.1.0)'
|
|
|
assert result['client'] is None
|
|
|
|
|
|
def test_parse_first_client_response_returns_none_when_not_matched(self):
|
|
|
response = (
|
|
|
'( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo'
|
|
|
' log-revprops ) ) ')
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
result = wrapper._parse_first_client_response(response)
|
|
|
assert result is None
|
|
|
|
|
|
def test_interrupt(self):
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
with patch.object(wrapper, 'fail') as fail_mock:
|
|
|
wrapper.interrupt(1, 'frame')
|
|
|
fail_mock.assert_called_once_with("Exited by timeout")
|
|
|
|
|
|
def test_fail(self):
|
|
|
process_mock = Mock()
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
with patch.object(wrapper, 'remove_configs') as remove_configs_mock:
|
|
|
with patch('sys.stdout', new_callable=BytesIO) as stdout_mock:
|
|
|
with patch.object(wrapper, 'process') as process_mock:
|
|
|
wrapper.fail('test message')
|
|
|
assert (
|
|
|
stdout_mock.getvalue() ==
|
|
|
'( failure ( ( 210005 12:test message 0: 0 ) ) )\n')
|
|
|
process_mock.kill.assert_called_once_with()
|
|
|
remove_configs_mock.assert_called_once_with()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
'client, expected_client', [
|
|
|
['test ', 'test '],
|
|
|
['', ''],
|
|
|
[None, '']
|
|
|
])
|
|
|
def test_client_in_patch_first_client_response(
|
|
|
self, client, expected_client):
|
|
|
response = {
|
|
|
'version': 2,
|
|
|
'capabilities': 'edit-pipeline svndiff1 absent-entries depth',
|
|
|
'url': 'svn+ssh://example.com/svn',
|
|
|
'ra_client': 'SVN/1.8.11 (x86_64-apple-darwin14.1.0)',
|
|
|
'client': client
|
|
|
}
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
stdin = BytesIO()
|
|
|
with patch.object(wrapper, 'process') as process_mock:
|
|
|
process_mock.stdin = stdin
|
|
|
wrapper.patch_first_client_response(response)
|
|
|
assert (
|
|
|
stdin.getvalue() ==
|
|
|
'( 2 ( edit-pipeline svndiff1 absent-entries depth )'
|
|
|
' 25:svn+ssh://example.com/svn 38:SVN/1.8.11'
|
|
|
' (x86_64-apple-darwin14.1.0) ( {expected_client}) ) '.format(
|
|
|
expected_client=expected_client))
|
|
|
|
|
|
def test_kwargs_override_data_in_patch_first_client_response(self):
|
|
|
response = {
|
|
|
'version': 2,
|
|
|
'capabilities': 'edit-pipeline svndiff1 absent-entries depth',
|
|
|
'url': 'svn+ssh://example.com/svn',
|
|
|
'ra_client': 'SVN/1.8.11 (x86_64-apple-darwin14.1.0)',
|
|
|
'client': 'test'
|
|
|
}
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
stdin = BytesIO()
|
|
|
with patch.object(wrapper, 'process') as process_mock:
|
|
|
process_mock.stdin = stdin
|
|
|
wrapper.patch_first_client_response(
|
|
|
response, version=3, client='abcde ',
|
|
|
capabilities='absent-entries depth',
|
|
|
url='svn+ssh://example.org/test',
|
|
|
ra_client='SVN/1.8.12 (ubuntu 14.04)')
|
|
|
assert (
|
|
|
stdin.getvalue() ==
|
|
|
'( 3 ( absent-entries depth ) 26:svn+ssh://example.org/test'
|
|
|
' 25:SVN/1.8.12 (ubuntu 14.04) ( abcde ) ) ')
|
|
|
|
|
|
def test_patch_first_client_response_sets_environment(self):
|
|
|
response = {
|
|
|
'version': 2,
|
|
|
'capabilities': 'edit-pipeline svndiff1 absent-entries depth',
|
|
|
'url': 'svn+ssh://example.com/svn',
|
|
|
'ra_client': 'SVN/1.8.11 (x86_64-apple-darwin14.1.0)',
|
|
|
'client': 'test'
|
|
|
}
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
stdin = BytesIO()
|
|
|
with patch.object(wrapper, 'create_hooks_env') as create_hooks_mock:
|
|
|
with patch.object(wrapper, 'process') as process_mock:
|
|
|
process_mock.stdin = stdin
|
|
|
wrapper.patch_first_client_response(response)
|
|
|
create_hooks_mock.assert_called_once_with()
|
|
|
|
|
|
def test_get_first_client_response_exits_by_signal(self):
|
|
|
wrapper = SubversionTunnelWrapper(timeout=1)
|
|
|
read_patch = patch.object(wrapper, '_read_first_client_response')
|
|
|
parse_patch = patch.object(wrapper, '_parse_first_client_response')
|
|
|
interrupt_patch = patch.object(wrapper, 'interrupt')
|
|
|
|
|
|
with read_patch as read_mock, parse_patch as parse_mock, \
|
|
|
interrupt_patch as interrupt_mock:
|
|
|
read_mock.side_effect = lambda: sleep(3)
|
|
|
wrapper.get_first_client_response()
|
|
|
|
|
|
assert parse_mock.call_count == 0
|
|
|
assert interrupt_mock.call_count == 1
|
|
|
|
|
|
def test_get_first_client_response_parses_data(self):
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
response = (
|
|
|
'( 2 ( edit-pipeline svndiff1 absent-entries depth mergeinfo'
|
|
|
' log-revprops ) 26:svn+ssh://vcs@vm/hello-svn 38:SVN/1.8.11'
|
|
|
' (x86_64-apple-darwin14.1.0) ( ) ) ')
|
|
|
read_patch = patch.object(wrapper, '_read_first_client_response')
|
|
|
parse_patch = patch.object(wrapper, '_parse_first_client_response')
|
|
|
|
|
|
with read_patch as read_mock, parse_patch as parse_mock:
|
|
|
read_mock.return_value = response
|
|
|
wrapper.get_first_client_response()
|
|
|
|
|
|
parse_mock.assert_called_once_with(response)
|
|
|
|
|
|
def test_return_code(self):
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
with patch.object(wrapper, 'process') as process_mock:
|
|
|
process_mock.returncode = 1
|
|
|
assert wrapper.return_code == 1
|
|
|
|
|
|
def test_sync_loop_breaks_when_process_cannot_be_polled(self):
|
|
|
self.counter = 0
|
|
|
buffer_ = 'abcdefghij'
|
|
|
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
wrapper.stdin = BytesIO(buffer_)
|
|
|
with patch.object(wrapper, 'remove_configs') as remove_configs_mock:
|
|
|
with patch.object(wrapper, 'process') as process_mock:
|
|
|
process_mock.poll.side_effect = self._poll
|
|
|
process_mock.stdin = BytesIO()
|
|
|
wrapper.sync()
|
|
|
assert process_mock.stdin.getvalue() == 'abcde'
|
|
|
remove_configs_mock.assert_called_once_with()
|
|
|
|
|
|
def test_sync_loop_breaks_when_nothing_to_read(self):
|
|
|
self.counter = 0
|
|
|
buffer_ = 'abcdefghij'
|
|
|
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
wrapper.stdin = BytesIO(buffer_)
|
|
|
with patch.object(wrapper, 'remove_configs') as remove_configs_mock:
|
|
|
with patch.object(wrapper, 'process') as process_mock:
|
|
|
process_mock.poll.return_value = None
|
|
|
process_mock.stdin = BytesIO()
|
|
|
wrapper.sync()
|
|
|
assert process_mock.stdin.getvalue() == buffer_
|
|
|
remove_configs_mock.assert_called_once_with()
|
|
|
|
|
|
def test_start_without_repositories_root(self):
|
|
|
svn_path = '/usr/local/bin/svnserve'
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5, svn_path=svn_path)
|
|
|
with patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.Popen') as popen_mock:
|
|
|
wrapper.start()
|
|
|
expected_command = [
|
|
|
svn_path, '-t', '--config-file', wrapper.svn_conf_path]
|
|
|
popen_mock.assert_called_once_with(
|
|
|
expected_command, stdin=subprocess.PIPE)
|
|
|
assert wrapper.process == popen_mock()
|
|
|
|
|
|
def test_start_with_repositories_root(self):
|
|
|
svn_path = '/usr/local/bin/svnserve'
|
|
|
repositories_root = '/home/repos'
|
|
|
wrapper = SubversionTunnelWrapper(
|
|
|
timeout=5, svn_path=svn_path, repositories_root=repositories_root)
|
|
|
with patch('rhodecode.apps.ssh_support.lib.ssh_wrapper.Popen') as popen_mock:
|
|
|
wrapper.start()
|
|
|
expected_command = [
|
|
|
svn_path, '-t', '--config-file', wrapper.svn_conf_path,
|
|
|
'-r', repositories_root]
|
|
|
popen_mock.assert_called_once_with(
|
|
|
expected_command, stdin=subprocess.PIPE)
|
|
|
assert wrapper.process == popen_mock()
|
|
|
|
|
|
def test_create_svn_config(self):
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
file_mock = MagicMock(spec=file)
|
|
|
with patch('os.fdopen', create=True) as open_mock:
|
|
|
open_mock.return_value = file_mock
|
|
|
wrapper.create_svn_config()
|
|
|
open_mock.assert_called_once_with(wrapper.svn_conf_fd, 'w')
|
|
|
expected_content = '[general]\nhooks-env = {}\n'.format(
|
|
|
wrapper.hooks_env_path)
|
|
|
file_handle = file_mock.__enter__.return_value
|
|
|
file_handle.write.assert_called_once_with(expected_content)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
'read_only, expected_content', [
|
|
|
[True, '[default]\nLANG = en_US.UTF-8\nSSH_READ_ONLY = 1\n'],
|
|
|
[False, '[default]\nLANG = en_US.UTF-8\n']
|
|
|
], ids=no_newline_id_generator)
|
|
|
def test_create_hooks_env(self, read_only, expected_content):
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
wrapper.read_only = read_only
|
|
|
file_mock = MagicMock(spec=file)
|
|
|
with patch('os.fdopen', create=True) as open_mock:
|
|
|
open_mock.return_value = file_mock
|
|
|
wrapper.create_hooks_env()
|
|
|
open_mock.assert_called_once_with(wrapper.hooks_env_fd, 'w')
|
|
|
file_handle = file_mock.__enter__.return_value
|
|
|
file_handle.write.assert_called_once_with(expected_content)
|
|
|
|
|
|
def test_remove_configs(self):
|
|
|
wrapper = SubversionTunnelWrapper(timeout=5)
|
|
|
with patch('os.remove') as remove_mock:
|
|
|
wrapper.remove_configs()
|
|
|
expected_calls = [
|
|
|
call(wrapper.svn_conf_path), call(wrapper.hooks_env_path)]
|
|
|
assert sorted(remove_mock.call_args_list) == sorted(expected_calls)
|
|
|
|
|
|
def _poll(self):
|
|
|
self.counter += 1
|
|
|
return None if self.counter < 6 else 1
|
|
|
|