##// END OF EJS Templates
fix(tests): fixed svn tests
super-admin -
r5336:7c832518 default
parent child Browse files
Show More
@@ -1,269 +1,269 b''
1 1 # Copyright (C) 2016-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import os
20 20 import re
21 21 import sys
22 22 import logging
23 23 import signal
24 24 import tempfile
25 25 from subprocess import Popen, PIPE
26 26 import urllib.parse
27 27
28 28 from rhodecode_tools.lib.utils import safe_str
29 29 from .base import SshVcsServer
30 30
31 31 log = logging.getLogger(__name__)
32 32
33 33
34 34 class SubversionTunnelWrapper(object):
35 35 process = None
36 36
37 37 def __init__(self, server):
38 38 self.server = server
39 39 self.timeout = 30
40 40 self.stdin = sys.stdin
41 41 self.stdout = sys.stdout
42 42 self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp()
43 43 self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp()
44 44
45 45 self.read_only = True # flag that we set to make the hooks readonly
46 46
47 47 def create_svn_config(self):
48 48 content = (
49 49 '[general]\n'
50 50 'hooks-env = {}\n').format(self.hooks_env_path)
51 51 with os.fdopen(self.svn_conf_fd, 'w') as config_file:
52 52 config_file.write(content)
53 53
54 54 def create_hooks_env(self):
55 55 content = (
56 56 '[default]\n'
57 57 'LANG = en_US.UTF-8\n')
58 58 if self.read_only:
59 59 content += 'SSH_READ_ONLY = 1\n'
60 60 with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file:
61 61 hooks_env_file.write(content)
62 62
63 63 def remove_configs(self):
64 64 os.remove(self.svn_conf_path)
65 65 os.remove(self.hooks_env_path)
66 66
67 67 def command(self):
68 68 root = self.server.get_root_store()
69 69 username = self.server.user.username
70 70
71 71 command = [
72 72 self.server.svn_path, '-t',
73 73 '--config-file', self.svn_conf_path,
74 74 '--tunnel-user', username,
75 75 '-r', root]
76 76 log.debug("Final CMD: %s", ' '.join(command))
77 77 return command
78 78
79 79 def start(self):
80 80 command = self.command()
81 81 self.process = Popen(' '.join(command), stdin=PIPE, shell=True)
82 82
83 83 def sync(self):
84 84 while self.process.poll() is None:
85 85 next_byte = self.stdin.buffer.read(1)
86 86 if not next_byte:
87 87 break
88 88 self.process.stdin.write(next_byte)
89 89 self.remove_configs()
90 90
91 91 @property
92 92 def return_code(self):
93 93 return self.process.returncode
94 94
95 95 def get_first_client_response(self):
96 96 signal.signal(signal.SIGALRM, self.interrupt)
97 97 signal.alarm(self.timeout)
98 98 first_response = self._read_first_client_response()
99 99 signal.alarm(0)
100 100 return (self._parse_first_client_response(first_response)
101 101 if first_response else None)
102 102
103 103 def patch_first_client_response(self, response, **kwargs):
104 104 self.create_hooks_env()
105 105
106 106 version = response['version']
107 107 capabilities = response['capabilities']
108 108 client = response['client'] or b''
109 109
110 110 url = self._svn_bytes(response['url'])
111 111 ra_client = self._svn_bytes(response['ra_client'])
112 112
113 113 buffer_ = b"( %b ( %b ) %b%b( %b) ) " % (
114 114 version,
115 115 capabilities,
116 116 url,
117 117 ra_client,
118 118 client
119 119 )
120 120 self.process.stdin.write(buffer_)
121 121
122 122 def fail(self, message):
123 123 fail_msg = b"( failure ( ( 210005 %b 0: 0 ) ) )" % self._svn_bytes(message)
124 124 sys.stdout.buffer.write(fail_msg)
125 125 sys.stdout.flush()
126 126 self.remove_configs()
127 127 self.process.kill()
128 128 return 1
129 129
130 130 def interrupt(self, signum, frame):
131 131 self.fail("Exited by timeout")
132 132
133 133 def _svn_bytes(self, bytes_: bytes) -> bytes:
134 134 if not bytes_:
135 135 return b''
136 136
137 137 return f'{len(bytes_)}:'.encode() + bytes_ + b' '
138 138
139 139 def _read_first_client_response(self):
140 140 buffer_ = b""
141 141 brackets_stack = []
142 142 while True:
143 143 next_byte = self.stdin.buffer.read(1)
144 144 buffer_ += next_byte
145 145 if next_byte == b"(":
146 146 brackets_stack.append(next_byte)
147 147 elif next_byte == b")":
148 148 brackets_stack.pop()
149 149 elif next_byte == b" " and not brackets_stack:
150 150 break
151 151
152 152 return buffer_
153 153
154 154 def _parse_first_client_response(self, buffer_: bytes):
155 155 """
156 156 According to the Subversion RA protocol, the first request
157 157 should look like:
158 158
159 159 ( version:number ( cap:word ... ) url:string ? ra-client:string
160 160 ( ? client:string ) )
161 161
162 162 Please check https://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol
163 163 """
164 164 version_re = br'(?P<version>\d+)'
165 165 capabilities_re = br'\(\s(?P<capabilities>[\w\d\-\ ]+)\s\)'
166 166 url_re = br'\d+\:(?P<url>[\W\w]+)'
167 167 ra_client_re = br'(\d+\:(?P<ra_client>[\W\w]+)\s)'
168 168 client_re = br'(\d+\:(?P<client>[\W\w]+)\s)*'
169 169 regex = re.compile(
170 170 br'^\(\s%b\s%b\s%b\s%b'
171 171 br'\(\s%b\)\s\)\s*$' % (
172 172 version_re,
173 173 capabilities_re,
174 174 url_re,
175 175 ra_client_re,
176 176 client_re)
177 177 )
178 178 matcher = regex.match(buffer_)
179 179
180 180 return matcher.groupdict() if matcher else None
181 181
182 182 def _match_repo_name(self, url):
183 183 """
184 184 Given an server url, try to match it against ALL known repository names.
185 185 This handles a tricky SVN case for SSH and subdir commits.
186 186 E.g if our repo name is my-svn-repo, a svn commit on file in a subdir would
187 187 result in the url with this subdir added.
188 188 """
189 189 # case 1 direct match, we don't do any "heavy" lookups
190 190 if url in self.server.user_permissions:
191 191 return url
192 192
193 193 log.debug('Extracting repository name from subdir path %s', url)
194 194 # case 2 we check all permissions, and match closes possible case...
195 195 # NOTE(dan): In this case we only know that url has a subdir parts, it's safe
196 196 # to assume that it will have the repo name as prefix, we ensure the prefix
197 197 # for similar repositories isn't matched by adding a /
198 198 # e.g subgroup/repo-name/ and subgroup/repo-name-1/ would work correct.
199 199 for repo_name in self.server.user_permissions:
200 200 repo_name_prefix = repo_name + '/'
201 201 if url.startswith(repo_name_prefix):
202 202 log.debug('Found prefix %s match, returning proper repository name',
203 203 repo_name_prefix)
204 204 return repo_name
205 205
206 206 return
207 207
208 208 def run(self, extras):
209 209 action = 'pull'
210 210 self.create_svn_config()
211 211 self.start()
212 212
213 213 first_response = self.get_first_client_response()
214 214 if not first_response:
215 return self.fail("Repository name cannot be extracted")
215 return self.fail(b"Repository name cannot be extracted")
216 216
217 217 url_parts = urllib.parse.urlparse(first_response['url'])
218 218
219 219 self.server.repo_name = self._match_repo_name(safe_str(url_parts.path).strip('/'))
220 220
221 221 exit_code = self.server._check_permissions(action)
222 222 if exit_code:
223 223 return exit_code
224 224
225 225 # set the readonly flag to False if we have proper permissions
226 226 if self.server.has_write_perm():
227 227 self.read_only = False
228 228 self.server.update_environment(action=action, extras=extras)
229 229
230 230 self.patch_first_client_response(first_response)
231 231 self.sync()
232 232 return self.return_code
233 233
234 234
235 235 class SubversionServer(SshVcsServer):
236 236 backend = 'svn'
237 237 repo_user_agent = 'svn'
238 238
239 239 def __init__(self, store, ini_path, repo_name, user, user_permissions, settings, env):
240 240 super().__init__(user, user_permissions, settings, env)
241 241 self.store = store
242 242 self.ini_path = ini_path
243 243 # NOTE(dan): repo_name at this point is empty,
244 244 # this is set later in .run() based from parsed input stream
245 245 self.repo_name = repo_name
246 246 self._path = self.svn_path = settings['ssh.executable.svn']
247 247
248 248 self.tunnel = SubversionTunnelWrapper(server=self)
249 249
250 250 def _handle_tunnel(self, extras):
251 251
252 252 # pre-auth
253 253 action = 'pull'
254 254 # Special case for SVN, we extract repo name at later stage
255 255 # exit_code = self._check_permissions(action)
256 256 # if exit_code:
257 257 # return exit_code, False
258 258
259 259 req = self.env.get('request')
260 260 if req:
261 261 server_url = req.host_url + req.script_name
262 262 extras['server_url'] = server_url
263 263
264 264 log.debug('Using %s binaries from path %s', self.backend, self._path)
265 265 exit_code = self.tunnel.run(extras)
266 266
267 267 return exit_code, action == "push"
268 268
269 269
@@ -1,202 +1,203 b''
1 1 # Copyright (C) 2016-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18 import os
19 19 import mock
20 20 import pytest
21 21
22 22 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionServer
23 23 from rhodecode.apps.ssh_support.tests.conftest import plain_dummy_env, plain_dummy_user
24 24
25 25
26 26 class SubversionServerCreator(object):
27 27 root = '/tmp/repo/path/'
28 28 svn_path = '/usr/local/bin/svnserve'
29 29 config_data = {
30 30 'app:main': {
31 31 'ssh.executable.svn': svn_path,
32 32 'vcs.hooks.protocol': 'http',
33 33 }
34 34 }
35 35 repo_name = 'test-svn'
36 36 user = plain_dummy_user()
37 37
38 38 def __init__(self):
39 39 pass
40 40
41 41 def create(self, **kwargs):
42 42 parameters = {
43 43 'store': self.root,
44 44 'repo_name': self.repo_name,
45 45 'ini_path': '',
46 46 'user': self.user,
47 47 'user_permissions': {
48 48 self.repo_name: 'repository.admin'
49 49 },
50 50 'settings': self.config_data['app:main'],
51 51 'env': plain_dummy_env()
52 52 }
53 53
54 54 parameters.update(kwargs)
55 55 server = SubversionServer(**parameters)
56 56 return server
57 57
58 58
59 59 @pytest.fixture()
60 60 def svn_server(app):
61 61 return SubversionServerCreator()
62 62
63 63
64 64 class TestSubversionServer(object):
65
65 66 def test_command(self, svn_server):
66 67 server = svn_server.create()
67 68 expected_command = [
68 69 svn_server.svn_path, '-t',
69 70 '--config-file', server.tunnel.svn_conf_path,
70 71 '--tunnel-user', svn_server.user.username,
71 72 '-r', svn_server.root
72 73 ]
73 74
74 75 assert expected_command == server.tunnel.command()
75 76
76 77 @pytest.mark.parametrize('permissions, action, code', [
77 78 ({}, 'pull', -2),
78 79 ({'test-svn': 'repository.read'}, 'pull', 0),
79 80 ({'test-svn': 'repository.read'}, 'push', -2),
80 81 ({'test-svn': 'repository.write'}, 'push', 0),
81 82 ({'test-svn': 'repository.admin'}, 'push', 0),
82 83
83 84 ])
84 85 def test_permission_checks(self, svn_server, permissions, action, code):
85 86 server = svn_server.create(user_permissions=permissions)
86 87 result = server._check_permissions(action)
87 88 assert result is code
88 89
89 90 @pytest.mark.parametrize('permissions, access_paths, expected_match', [
90 91 # not matched repository name
91 92 ({
92 93 'test-svn': ''
93 94 }, ['test-svn-1', 'test-svn-1/subpath'],
94 95 None),
95 96
96 97 # exact match
97 98 ({
98 99 'test-svn': ''
99 100 },
100 101 ['test-svn'],
101 102 'test-svn'),
102 103
103 104 # subdir commits
104 105 ({
105 106 'test-svn': ''
106 107 },
107 108 ['test-svn/foo',
108 109 'test-svn/foo/test-svn',
109 110 'test-svn/trunk/development.txt',
110 111 ],
111 112 'test-svn'),
112 113
113 114 # subgroups + similar patterns
114 115 ({
115 116 'test-svn': '',
116 117 'test-svn-1': '',
117 118 'test-svn-subgroup/test-svn': '',
118 119
119 120 },
120 121 ['test-svn-1',
121 122 'test-svn-1/foo/test-svn',
122 123 'test-svn-1/test-svn',
123 124 ],
124 125 'test-svn-1'),
125 126
126 127 # subgroups + similar patterns
127 128 ({
128 129 'test-svn-1': '',
129 130 'test-svn-10': '',
130 131 'test-svn-100': '',
131 132 },
132 133 ['test-svn-10',
133 134 'test-svn-10/foo/test-svn',
134 135 'test-svn-10/test-svn',
135 136 ],
136 137 'test-svn-10'),
137 138
138 139 # subgroups + similar patterns
139 140 ({
140 141 'name': '',
141 142 'nameContains': '',
142 143 'nameContainsThis': '',
143 144 },
144 145 ['nameContains',
145 146 'nameContains/This',
146 147 'nameContains/This/test-svn',
147 148 ],
148 149 'nameContains'),
149 150
150 151 # subgroups + similar patterns
151 152 ({
152 153 'test-svn': '',
153 154 'test-svn-1': '',
154 155 'test-svn-subgroup/test-svn': '',
155 156
156 157 },
157 158 ['test-svn-subgroup/test-svn',
158 159 'test-svn-subgroup/test-svn/foo/test-svn',
159 160 'test-svn-subgroup/test-svn/trunk/example.txt',
160 161 ],
161 162 'test-svn-subgroup/test-svn'),
162 163 ])
163 164 def test_repo_extraction_on_subdir(self, svn_server, permissions, access_paths, expected_match):
164 165 server = svn_server.create(user_permissions=permissions)
165 166 for path in access_paths:
166 167 repo_name = server.tunnel._match_repo_name(path)
167 168 assert repo_name == expected_match
168 169
169 170 def test_run_returns_executes_command(self, svn_server):
170 171 server = svn_server.create()
171 172 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionTunnelWrapper
172 173 os.environ['SSH_CLIENT'] = '127.0.0.1'
173 174 with mock.patch.object(
174 175 SubversionTunnelWrapper, 'get_first_client_response',
175 176 return_value={'url': 'http://server/test-svn'}):
176 177 with mock.patch.object(
177 178 SubversionTunnelWrapper, 'patch_first_client_response',
178 179 return_value=0):
179 180 with mock.patch.object(
180 181 SubversionTunnelWrapper, 'sync',
181 182 return_value=0):
182 183 with mock.patch.object(
183 184 SubversionTunnelWrapper, 'command',
184 185 return_value=['date']):
185 186
186 187 exit_code = server.run()
187 188 # SVN has this differently configured, and we get in our mock env
188 189 # None as return code
189 190 assert exit_code == (None, False)
190 191
191 192 def test_run_returns_executes_command_that_cannot_extract_repo_name(self, svn_server):
192 193 server = svn_server.create()
193 194 from rhodecode.apps.ssh_support.lib.backends.svn import SubversionTunnelWrapper
194 195 with mock.patch.object(
195 196 SubversionTunnelWrapper, 'command',
196 197 return_value=['date']):
197 198 with mock.patch.object(
198 199 SubversionTunnelWrapper, 'get_first_client_response',
199 200 return_value=None):
200 201 exit_code = server.run()
201 202
202 203 assert exit_code == (1, False)
General Comments 0
You need to be logged in to leave comments. Login now