##// END OF EJS Templates
vcs-ops: store user_id inside the extras for vcs context operations....
marcink -
r2411:f2cd50d7 default
parent child Browse files
Show More
@@ -1,149 +1,150 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import os
22 22 import sys
23 23 import json
24 24 import logging
25 25
26 26 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
27 27 from rhodecode.lib.vcs.conf import settings as vcs_settings
28 28 from rhodecode.model.scm import ScmModel
29 29
30 30 log = logging.getLogger(__name__)
31 31
32 32
33 33 class VcsServer(object):
34 34 _path = None # set executable path for hg/git/svn binary
35 35 backend = None # set in child classes
36 36 tunnel = None # subprocess handling tunnel
37 37 write_perms = ['repository.admin', 'repository.write']
38 38 read_perms = ['repository.read', 'repository.admin', 'repository.write']
39 39
40 40 def __init__(self, user, user_permissions, config, env):
41 41 self.user = user
42 42 self.user_permissions = user_permissions
43 43 self.config = config
44 44 self.env = env
45 45 self.stdin = sys.stdin
46 46
47 47 self.repo_name = None
48 48 self.repo_mode = None
49 49 self.store = ''
50 50 self.ini_path = ''
51 51
52 52 def _invalidate_cache(self, repo_name):
53 53 """
54 54 Set's cache for this repository for invalidation on next access
55 55
56 56 :param repo_name: full repo name, also a cache key
57 57 """
58 58 ScmModel().mark_for_invalidation(repo_name)
59 59
60 60 def has_write_perm(self):
61 61 permission = self.user_permissions.get(self.repo_name)
62 62 if permission in ['repository.write', 'repository.admin']:
63 63 return True
64 64
65 65 return False
66 66
67 67 def _check_permissions(self, action):
68 68 permission = self.user_permissions.get(self.repo_name)
69 69 log.debug(
70 70 'permission for %s on %s are: %s',
71 71 self.user, self.repo_name, permission)
72 72
73 73 if action == 'pull':
74 74 if permission in self.read_perms:
75 75 log.info(
76 76 'READ Permissions for User "%s" detected to repo "%s"!',
77 77 self.user, self.repo_name)
78 78 return 0
79 79 else:
80 80 if permission in self.write_perms:
81 81 log.info(
82 82 'WRITE+ Permissions for User "%s" detected to repo "%s"!',
83 83 self.user, self.repo_name)
84 84 return 0
85 85
86 86 log.error('Cannot properly fetch or allow user %s permissions. '
87 87 'Return value is: %s, req action: %s',
88 88 self.user, permission, action)
89 89 return -2
90 90
91 91 def update_environment(self, action, extras=None):
92 92
93 93 scm_data = {
94 94 'ip': os.environ['SSH_CLIENT'].split()[0],
95 95 'username': self.user.username,
96 'user_id': self.user.user_id,
96 97 'action': action,
97 98 'repository': self.repo_name,
98 99 'scm': self.backend,
99 100 'config': self.ini_path,
100 101 'make_lock': None,
101 102 'locked_by': [None, None],
102 103 'server_url': None,
103 104 'is_shadow_repo': False,
104 105 'hooks_module': 'rhodecode.lib.hooks_daemon',
105 106 'hooks': ['push', 'pull'],
106 107 'SSH': True,
107 108 'SSH_PERMISSIONS': self.user_permissions.get(self.repo_name)
108 109 }
109 110 if extras:
110 111 scm_data.update(extras)
111 112 os.putenv("RC_SCM_DATA", json.dumps(scm_data))
112 113
113 114 def get_root_store(self):
114 115 root_store = self.store
115 116 if not root_store.endswith('/'):
116 117 # always append trailing slash
117 118 root_store = root_store + '/'
118 119 return root_store
119 120
120 121 def _handle_tunnel(self, extras):
121 122 # pre-auth
122 123 action = 'pull'
123 124 exit_code = self._check_permissions(action)
124 125 if exit_code:
125 126 return exit_code, False
126 127
127 128 req = self.env['request']
128 129 server_url = req.host_url + req.script_name
129 130 extras['server_url'] = server_url
130 131
131 132 log.debug('Using %s binaries from path %s', self.backend, self._path)
132 133 exit_code = self.tunnel.run(extras)
133 134
134 135 return exit_code, action == "push"
135 136
136 137 def run(self):
137 138 extras = {}
138 139
139 140 callback_daemon, extras = prepare_callback_daemon(
140 141 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
141 142 use_direct_calls=False)
142 143
143 144 with callback_daemon:
144 145 try:
145 146 return self._handle_tunnel(extras)
146 147 finally:
147 148 log.debug('Running cleanup with cache invalidation')
148 149 if self.repo_name:
149 150 self._invalidate_cache(self.repo_name)
@@ -1,145 +1,146 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import json
22 22 import mock
23 23 import pytest
24 24
25 25 from rhodecode.apps.ssh_support.lib.backends.git import GitServer
26 26 from rhodecode.apps.ssh_support.tests.conftest import dummy_env, dummy_user
27 27
28 28
29 29 class GitServerCreator(object):
30 30 root = '/tmp/repo/path/'
31 31 git_path = '/usr/local/bin/git'
32 32 config_data = {
33 33 'app:main': {
34 34 'ssh.executable.git': git_path,
35 35 'vcs.hooks.protocol': 'http',
36 36 }
37 37 }
38 38 repo_name = 'test_git'
39 39 repo_mode = 'receive-pack'
40 40 user = dummy_user()
41 41
42 42 def __init__(self):
43 43 def config_get(part, key):
44 44 return self.config_data.get(part, {}).get(key)
45 45 self.config_mock = mock.Mock()
46 46 self.config_mock.get = mock.Mock(side_effect=config_get)
47 47
48 48 def create(self, **kwargs):
49 49 parameters = {
50 50 'store': self.root,
51 51 'ini_path': '',
52 52 'user': self.user,
53 53 'repo_name': self.repo_name,
54 54 'repo_mode': self.repo_mode,
55 55 'user_permissions': {
56 56 self.repo_name: 'repository.admin'
57 57 },
58 58 'config': self.config_mock,
59 59 'env': dummy_env()
60 60 }
61 61 parameters.update(kwargs)
62 62 server = GitServer(**parameters)
63 63 return server
64 64
65 65
66 66 @pytest.fixture
67 67 def git_server(app):
68 68 return GitServerCreator()
69 69
70 70
71 71 class TestGitServer(object):
72 72
73 73 def test_command(self, git_server):
74 74 server = git_server.create()
75 75 expected_command = (
76 76 'cd {root}; {git_path} {repo_mode} \'{root}{repo_name}\''.format(
77 77 root=git_server.root, git_path=git_server.git_path,
78 78 repo_mode=git_server.repo_mode, repo_name=git_server.repo_name)
79 79 )
80 80 assert expected_command == server.tunnel.command()
81 81
82 82 @pytest.mark.parametrize('permissions, action, code', [
83 83 ({}, 'pull', -2),
84 84 ({'test_git': 'repository.read'}, 'pull', 0),
85 85 ({'test_git': 'repository.read'}, 'push', -2),
86 86 ({'test_git': 'repository.write'}, 'push', 0),
87 87 ({'test_git': 'repository.admin'}, 'push', 0),
88 88
89 89 ])
90 90 def test_permission_checks(self, git_server, permissions, action, code):
91 91 server = git_server.create(user_permissions=permissions)
92 92 result = server._check_permissions(action)
93 93 assert result is code
94 94
95 95 @pytest.mark.parametrize('permissions, value', [
96 96 ({}, False),
97 97 ({'test_git': 'repository.read'}, False),
98 98 ({'test_git': 'repository.write'}, True),
99 99 ({'test_git': 'repository.admin'}, True),
100 100
101 101 ])
102 102 def test_has_write_permissions(self, git_server, permissions, value):
103 103 server = git_server.create(user_permissions=permissions)
104 104 result = server.has_write_perm()
105 105 assert result is value
106 106
107 107 def test_run_returns_executes_command(self, git_server):
108 108 server = git_server.create()
109 109 from rhodecode.apps.ssh_support.lib.backends.git import GitTunnelWrapper
110 110 with mock.patch.object(GitTunnelWrapper, 'create_hooks_env') as _patch:
111 111 _patch.return_value = 0
112 112 with mock.patch.object(GitTunnelWrapper, 'command', return_value='date'):
113 113 exit_code = server.run()
114 114
115 115 assert exit_code == (0, False)
116 116
117 117 @pytest.mark.parametrize(
118 118 'repo_mode, action', [
119 119 ['receive-pack', 'push'],
120 120 ['upload-pack', 'pull']
121 121 ])
122 122 def test_update_environment(self, git_server, repo_mode, action):
123 123 server = git_server.create(repo_mode=repo_mode)
124 124 with mock.patch('os.environ', {'SSH_CLIENT': '10.10.10.10 b'}):
125 125 with mock.patch('os.putenv') as putenv_mock:
126 126 server.update_environment(action)
127 127
128 128 expected_data = {
129 129 'username': git_server.user.username,
130 'user_id': git_server.user.user_id,
130 131 'scm': 'git',
131 132 'repository': git_server.repo_name,
132 133 'make_lock': None,
133 134 'action': action,
134 135 'ip': '10.10.10.10',
135 136 'locked_by': [None, None],
136 137 'config': '',
137 138 'server_url': None,
138 139 'hooks': ['push', 'pull'],
139 140 'is_shadow_repo': False,
140 141 'hooks_module': 'rhodecode.lib.hooks_daemon',
141 142 'SSH': True,
142 143 'SSH_PERMISSIONS': 'repository.admin',
143 144 }
144 145 args, kwargs = putenv_mock.call_args
145 146 assert json.loads(args[1]) == expected_data
@@ -1,541 +1,542 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 The base Controller API
23 23 Provides the BaseController class for subclassing. And usage in different
24 24 controllers
25 25 """
26 26
27 27 import logging
28 28 import socket
29 29
30 30 import markupsafe
31 31 import ipaddress
32 32
33 33 from paste.auth.basic import AuthBasicAuthenticator
34 34 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
35 35 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
36 36
37 37 import rhodecode
38 38 from rhodecode.authentication.base import VCS_TYPE
39 39 from rhodecode.lib import auth, utils2
40 40 from rhodecode.lib import helpers as h
41 41 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
42 42 from rhodecode.lib.exceptions import UserCreationError
43 43 from rhodecode.lib.utils import (password_changed, get_enabled_hook_classes)
44 44 from rhodecode.lib.utils2 import (
45 45 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist, safe_str)
46 46 from rhodecode.model.db import Repository, User, ChangesetComment
47 47 from rhodecode.model.notification import NotificationModel
48 48 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
49 49
50 50 log = logging.getLogger(__name__)
51 51
52 52
53 53 def _filter_proxy(ip):
54 54 """
55 55 Passed in IP addresses in HEADERS can be in a special format of multiple
56 56 ips. Those comma separated IPs are passed from various proxies in the
57 57 chain of request processing. The left-most being the original client.
58 58 We only care about the first IP which came from the org. client.
59 59
60 60 :param ip: ip string from headers
61 61 """
62 62 if ',' in ip:
63 63 _ips = ip.split(',')
64 64 _first_ip = _ips[0].strip()
65 65 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
66 66 return _first_ip
67 67 return ip
68 68
69 69
70 70 def _filter_port(ip):
71 71 """
72 72 Removes a port from ip, there are 4 main cases to handle here.
73 73 - ipv4 eg. 127.0.0.1
74 74 - ipv6 eg. ::1
75 75 - ipv4+port eg. 127.0.0.1:8080
76 76 - ipv6+port eg. [::1]:8080
77 77
78 78 :param ip:
79 79 """
80 80 def is_ipv6(ip_addr):
81 81 if hasattr(socket, 'inet_pton'):
82 82 try:
83 83 socket.inet_pton(socket.AF_INET6, ip_addr)
84 84 except socket.error:
85 85 return False
86 86 else:
87 87 # fallback to ipaddress
88 88 try:
89 89 ipaddress.IPv6Address(safe_unicode(ip_addr))
90 90 except Exception:
91 91 return False
92 92 return True
93 93
94 94 if ':' not in ip: # must be ipv4 pure ip
95 95 return ip
96 96
97 97 if '[' in ip and ']' in ip: # ipv6 with port
98 98 return ip.split(']')[0][1:].lower()
99 99
100 100 # must be ipv6 or ipv4 with port
101 101 if is_ipv6(ip):
102 102 return ip
103 103 else:
104 104 ip, _port = ip.split(':')[:2] # means ipv4+port
105 105 return ip
106 106
107 107
108 108 def get_ip_addr(environ):
109 109 proxy_key = 'HTTP_X_REAL_IP'
110 110 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
111 111 def_key = 'REMOTE_ADDR'
112 112 _filters = lambda x: _filter_port(_filter_proxy(x))
113 113
114 114 ip = environ.get(proxy_key)
115 115 if ip:
116 116 return _filters(ip)
117 117
118 118 ip = environ.get(proxy_key2)
119 119 if ip:
120 120 return _filters(ip)
121 121
122 122 ip = environ.get(def_key, '0.0.0.0')
123 123 return _filters(ip)
124 124
125 125
126 126 def get_server_ip_addr(environ, log_errors=True):
127 127 hostname = environ.get('SERVER_NAME')
128 128 try:
129 129 return socket.gethostbyname(hostname)
130 130 except Exception as e:
131 131 if log_errors:
132 132 # in some cases this lookup is not possible, and we don't want to
133 133 # make it an exception in logs
134 134 log.exception('Could not retrieve server ip address: %s', e)
135 135 return hostname
136 136
137 137
138 138 def get_server_port(environ):
139 139 return environ.get('SERVER_PORT')
140 140
141 141
142 142 def get_access_path(environ):
143 143 path = environ.get('PATH_INFO')
144 144 org_req = environ.get('pylons.original_request')
145 145 if org_req:
146 146 path = org_req.environ.get('PATH_INFO')
147 147 return path
148 148
149 149
150 150 def get_user_agent(environ):
151 151 return environ.get('HTTP_USER_AGENT')
152 152
153 153
154 154 def vcs_operation_context(
155 155 environ, repo_name, username, action, scm, check_locking=True,
156 156 is_shadow_repo=False):
157 157 """
158 158 Generate the context for a vcs operation, e.g. push or pull.
159 159
160 160 This context is passed over the layers so that hooks triggered by the
161 161 vcs operation know details like the user, the user's IP address etc.
162 162
163 163 :param check_locking: Allows to switch of the computation of the locking
164 164 data. This serves mainly the need of the simplevcs middleware to be
165 165 able to disable this for certain operations.
166 166
167 167 """
168 168 # Tri-state value: False: unlock, None: nothing, True: lock
169 169 make_lock = None
170 170 locked_by = [None, None, None]
171 171 is_anonymous = username == User.DEFAULT_USER
172 user = User.get_by_username(username)
172 173 if not is_anonymous and check_locking:
173 174 log.debug('Checking locking on repository "%s"', repo_name)
174 user = User.get_by_username(username)
175 175 repo = Repository.get_by_repo_name(repo_name)
176 176 make_lock, __, locked_by = repo.get_locking_state(
177 177 action, user.user_id)
178
178 user_id = user.user_id
179 179 settings_model = VcsSettingsModel(repo=repo_name)
180 180 ui_settings = settings_model.get_ui_settings()
181 181
182 182 extras = {
183 183 'ip': get_ip_addr(environ),
184 184 'username': username,
185 'user_id': user_id,
185 186 'action': action,
186 187 'repository': repo_name,
187 188 'scm': scm,
188 189 'config': rhodecode.CONFIG['__file__'],
189 190 'make_lock': make_lock,
190 191 'locked_by': locked_by,
191 192 'server_url': utils2.get_server_url(environ),
192 193 'user_agent': get_user_agent(environ),
193 194 'hooks': get_enabled_hook_classes(ui_settings),
194 195 'is_shadow_repo': is_shadow_repo,
195 196 }
196 197 return extras
197 198
198 199
199 200 class BasicAuth(AuthBasicAuthenticator):
200 201
201 202 def __init__(self, realm, authfunc, registry, auth_http_code=None,
202 203 initial_call_detection=False, acl_repo_name=None):
203 204 self.realm = realm
204 205 self.initial_call = initial_call_detection
205 206 self.authfunc = authfunc
206 207 self.registry = registry
207 208 self.acl_repo_name = acl_repo_name
208 209 self._rc_auth_http_code = auth_http_code
209 210
210 211 def _get_response_from_code(self, http_code):
211 212 try:
212 213 return get_exception(safe_int(http_code))
213 214 except Exception:
214 215 log.exception('Failed to fetch response for code %s' % http_code)
215 216 return HTTPForbidden
216 217
217 218 def get_rc_realm(self):
218 219 return safe_str(self.registry.rhodecode_settings.get('rhodecode_realm'))
219 220
220 221 def build_authentication(self):
221 222 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
222 223 if self._rc_auth_http_code and not self.initial_call:
223 224 # return alternative HTTP code if alternative http return code
224 225 # is specified in RhodeCode config, but ONLY if it's not the
225 226 # FIRST call
226 227 custom_response_klass = self._get_response_from_code(
227 228 self._rc_auth_http_code)
228 229 return custom_response_klass(headers=head)
229 230 return HTTPUnauthorized(headers=head)
230 231
231 232 def authenticate(self, environ):
232 233 authorization = AUTHORIZATION(environ)
233 234 if not authorization:
234 235 return self.build_authentication()
235 236 (authmeth, auth) = authorization.split(' ', 1)
236 237 if 'basic' != authmeth.lower():
237 238 return self.build_authentication()
238 239 auth = auth.strip().decode('base64')
239 240 _parts = auth.split(':', 1)
240 241 if len(_parts) == 2:
241 242 username, password = _parts
242 243 auth_data = self.authfunc(
243 244 username, password, environ, VCS_TYPE,
244 245 registry=self.registry, acl_repo_name=self.acl_repo_name)
245 246 if auth_data:
246 247 return {'username': username, 'auth_data': auth_data}
247 248 if username and password:
248 249 # we mark that we actually executed authentication once, at
249 250 # that point we can use the alternative auth code
250 251 self.initial_call = False
251 252
252 253 return self.build_authentication()
253 254
254 255 __call__ = authenticate
255 256
256 257
257 258 def calculate_version_hash(config):
258 259 return md5(
259 260 config.get('beaker.session.secret', '') +
260 261 rhodecode.__version__)[:8]
261 262
262 263
263 264 def get_current_lang(request):
264 265 # NOTE(marcink): remove after pyramid move
265 266 try:
266 267 return translation.get_lang()[0]
267 268 except:
268 269 pass
269 270
270 271 return getattr(request, '_LOCALE_', request.locale_name)
271 272
272 273
273 274 def attach_context_attributes(context, request, user_id):
274 275 """
275 276 Attach variables into template context called `c`.
276 277 """
277 278 config = request.registry.settings
278 279
279 280
280 281 rc_config = SettingsModel().get_all_settings(cache=True)
281 282
282 283 context.rhodecode_version = rhodecode.__version__
283 284 context.rhodecode_edition = config.get('rhodecode.edition')
284 285 # unique secret + version does not leak the version but keep consistency
285 286 context.rhodecode_version_hash = calculate_version_hash(config)
286 287
287 288 # Default language set for the incoming request
288 289 context.language = get_current_lang(request)
289 290
290 291 # Visual options
291 292 context.visual = AttributeDict({})
292 293
293 294 # DB stored Visual Items
294 295 context.visual.show_public_icon = str2bool(
295 296 rc_config.get('rhodecode_show_public_icon'))
296 297 context.visual.show_private_icon = str2bool(
297 298 rc_config.get('rhodecode_show_private_icon'))
298 299 context.visual.stylify_metatags = str2bool(
299 300 rc_config.get('rhodecode_stylify_metatags'))
300 301 context.visual.dashboard_items = safe_int(
301 302 rc_config.get('rhodecode_dashboard_items', 100))
302 303 context.visual.admin_grid_items = safe_int(
303 304 rc_config.get('rhodecode_admin_grid_items', 100))
304 305 context.visual.repository_fields = str2bool(
305 306 rc_config.get('rhodecode_repository_fields'))
306 307 context.visual.show_version = str2bool(
307 308 rc_config.get('rhodecode_show_version'))
308 309 context.visual.use_gravatar = str2bool(
309 310 rc_config.get('rhodecode_use_gravatar'))
310 311 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
311 312 context.visual.default_renderer = rc_config.get(
312 313 'rhodecode_markup_renderer', 'rst')
313 314 context.visual.comment_types = ChangesetComment.COMMENT_TYPES
314 315 context.visual.rhodecode_support_url = \
315 316 rc_config.get('rhodecode_support_url') or h.route_url('rhodecode_support')
316 317
317 318 context.visual.affected_files_cut_off = 60
318 319
319 320 context.pre_code = rc_config.get('rhodecode_pre_code')
320 321 context.post_code = rc_config.get('rhodecode_post_code')
321 322 context.rhodecode_name = rc_config.get('rhodecode_title')
322 323 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
323 324 # if we have specified default_encoding in the request, it has more
324 325 # priority
325 326 if request.GET.get('default_encoding'):
326 327 context.default_encodings.insert(0, request.GET.get('default_encoding'))
327 328 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
328 329
329 330 # INI stored
330 331 context.labs_active = str2bool(
331 332 config.get('labs_settings_active', 'false'))
332 333 context.visual.allow_repo_location_change = str2bool(
333 334 config.get('allow_repo_location_change', True))
334 335 context.visual.allow_custom_hooks_settings = str2bool(
335 336 config.get('allow_custom_hooks_settings', True))
336 337 context.debug_style = str2bool(config.get('debug_style', False))
337 338
338 339 context.rhodecode_instanceid = config.get('instance_id')
339 340
340 341 context.visual.cut_off_limit_diff = safe_int(
341 342 config.get('cut_off_limit_diff'))
342 343 context.visual.cut_off_limit_file = safe_int(
343 344 config.get('cut_off_limit_file'))
344 345
345 346 # AppEnlight
346 347 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
347 348 context.appenlight_api_public_key = config.get(
348 349 'appenlight.api_public_key', '')
349 350 context.appenlight_server_url = config.get('appenlight.server_url', '')
350 351
351 352 # JS template context
352 353 context.template_context = {
353 354 'repo_name': None,
354 355 'repo_type': None,
355 356 'repo_landing_commit': None,
356 357 'rhodecode_user': {
357 358 'username': None,
358 359 'email': None,
359 360 'notification_status': False
360 361 },
361 362 'visual': {
362 363 'default_renderer': None
363 364 },
364 365 'commit_data': {
365 366 'commit_id': None
366 367 },
367 368 'pull_request_data': {'pull_request_id': None},
368 369 'timeago': {
369 370 'refresh_time': 120 * 1000,
370 371 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
371 372 },
372 373 'pyramid_dispatch': {
373 374
374 375 },
375 376 'extra': {'plugins': {}}
376 377 }
377 378 # END CONFIG VARS
378 379
379 380 diffmode = 'sideside'
380 381 if request.GET.get('diffmode'):
381 382 if request.GET['diffmode'] == 'unified':
382 383 diffmode = 'unified'
383 384 elif request.session.get('diffmode'):
384 385 diffmode = request.session['diffmode']
385 386
386 387 context.diffmode = diffmode
387 388
388 389 if request.session.get('diffmode') != diffmode:
389 390 request.session['diffmode'] = diffmode
390 391
391 392 context.csrf_token = auth.get_csrf_token(session=request.session)
392 393 context.backends = rhodecode.BACKENDS.keys()
393 394 context.backends.sort()
394 395 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(user_id)
395 396
396 397 # web case
397 398 if hasattr(request, 'user'):
398 399 context.auth_user = request.user
399 400 context.rhodecode_user = request.user
400 401
401 402 # api case
402 403 if hasattr(request, 'rpc_user'):
403 404 context.auth_user = request.rpc_user
404 405 context.rhodecode_user = request.rpc_user
405 406
406 407 # attach the whole call context to the request
407 408 request.call_context = context
408 409
409 410
410 411 def get_auth_user(request):
411 412 environ = request.environ
412 413 session = request.session
413 414
414 415 ip_addr = get_ip_addr(environ)
415 416 # make sure that we update permissions each time we call controller
416 417 _auth_token = (request.GET.get('auth_token', '') or
417 418 request.GET.get('api_key', ''))
418 419
419 420 if _auth_token:
420 421 # when using API_KEY we assume user exists, and
421 422 # doesn't need auth based on cookies.
422 423 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
423 424 authenticated = False
424 425 else:
425 426 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
426 427 try:
427 428 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
428 429 ip_addr=ip_addr)
429 430 except UserCreationError as e:
430 431 h.flash(e, 'error')
431 432 # container auth or other auth functions that create users
432 433 # on the fly can throw this exception signaling that there's
433 434 # issue with user creation, explanation should be provided
434 435 # in Exception itself. We then create a simple blank
435 436 # AuthUser
436 437 auth_user = AuthUser(ip_addr=ip_addr)
437 438
438 439 # in case someone changes a password for user it triggers session
439 440 # flush and forces a re-login
440 441 if password_changed(auth_user, session):
441 442 session.invalidate()
442 443 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
443 444 auth_user = AuthUser(ip_addr=ip_addr)
444 445
445 446 authenticated = cookie_store.get('is_authenticated')
446 447
447 448 if not auth_user.is_authenticated and auth_user.is_user_object:
448 449 # user is not authenticated and not empty
449 450 auth_user.set_authenticated(authenticated)
450 451
451 452 return auth_user
452 453
453 454
454 455 def h_filter(s):
455 456 """
456 457 Custom filter for Mako templates. Mako by standard uses `markupsafe.escape`
457 458 we wrap this with additional functionality that converts None to empty
458 459 strings
459 460 """
460 461 if s is None:
461 462 return markupsafe.Markup()
462 463 return markupsafe.escape(s)
463 464
464 465
465 466 def add_events_routes(config):
466 467 """
467 468 Adds routing that can be used in events. Because some events are triggered
468 469 outside of pyramid context, we need to bootstrap request with some
469 470 routing registered
470 471 """
471 472
472 473 from rhodecode.apps._base import ADMIN_PREFIX
473 474
474 475 config.add_route(name='home', pattern='/')
475 476
476 477 config.add_route(name='login', pattern=ADMIN_PREFIX + '/login')
477 478 config.add_route(name='logout', pattern=ADMIN_PREFIX + '/logout')
478 479 config.add_route(name='repo_summary', pattern='/{repo_name}')
479 480 config.add_route(name='repo_summary_explicit', pattern='/{repo_name}/summary')
480 481 config.add_route(name='repo_group_home', pattern='/{repo_group_name}')
481 482
482 483 config.add_route(name='pullrequest_show',
483 484 pattern='/{repo_name}/pull-request/{pull_request_id}')
484 485 config.add_route(name='pull_requests_global',
485 486 pattern='/pull-request/{pull_request_id}')
486 487 config.add_route(name='repo_commit',
487 488 pattern='/{repo_name}/changeset/{commit_id}')
488 489
489 490 config.add_route(name='repo_files',
490 491 pattern='/{repo_name}/files/{commit_id}/{f_path}')
491 492
492 493
493 494 def bootstrap_config(request):
494 495 import pyramid.testing
495 496 registry = pyramid.testing.Registry('RcTestRegistry')
496 497
497 498 config = pyramid.testing.setUp(registry=registry, request=request)
498 499
499 500 # allow pyramid lookup in testing
500 501 config.include('pyramid_mako')
501 502 config.include('pyramid_beaker')
502 503 config.include('rhodecode.lib.caches')
503 504
504 505 add_events_routes(config)
505 506
506 507 return config
507 508
508 509
509 510 def bootstrap_request(**kwargs):
510 511 import pyramid.testing
511 512
512 513 class TestRequest(pyramid.testing.DummyRequest):
513 514 application_url = kwargs.pop('application_url', 'http://example.com')
514 515 host = kwargs.pop('host', 'example.com:80')
515 516 domain = kwargs.pop('domain', 'example.com')
516 517
517 518 def translate(self, msg):
518 519 return msg
519 520
520 521 def plularize(self, singular, plural, n):
521 522 return singular
522 523
523 524 def get_partial_renderer(self, tmpl_name):
524 525
525 526 from rhodecode.lib.partial_renderer import get_partial_renderer
526 527 return get_partial_renderer(request=self, tmpl_name=tmpl_name)
527 528
528 529 _call_context = {}
529 530 @property
530 531 def call_context(self):
531 532 return self._call_context
532 533
533 534 class TestDummySession(pyramid.testing.DummySession):
534 535 def save(*arg, **kw):
535 536 pass
536 537
537 538 request = TestRequest(**kwargs)
538 539 request.session = TestDummySession()
539 540
540 541 return request
541 542
@@ -1,120 +1,121 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22
23 23 from rhodecode.tests.events.conftest import EventCatcher
24 24
25 25 from rhodecode.lib import hooks_base, utils2
26 26 from rhodecode.model.repo import RepoModel
27 27 from rhodecode.events.repo import (
28 28 RepoPrePullEvent, RepoPullEvent,
29 29 RepoPrePushEvent, RepoPushEvent,
30 30 RepoPreCreateEvent, RepoCreateEvent,
31 31 RepoPreDeleteEvent, RepoDeleteEvent,
32 32 )
33 33
34 34
35 35 @pytest.fixture
36 36 def scm_extras(user_regular, repo_stub):
37 37 extras = utils2.AttributeDict({
38 38 'ip': '127.0.0.1',
39 39 'username': user_regular.username,
40 'user_id': user_regular.user_id,
40 41 'action': '',
41 42 'repository': repo_stub.repo_name,
42 43 'scm': repo_stub.scm_instance().alias,
43 44 'config': '',
44 45 'server_url': 'http://example.com',
45 46 'make_lock': None,
46 47 'user-agent': 'some-client',
47 48 'locked_by': [None],
48 49 'commit_ids': ['a' * 40] * 3,
49 50 'is_shadow_repo': False,
50 51 })
51 52 return extras
52 53
53 54
54 55 # TODO: dan: make the serialization tests complete json comparisons
55 56 @pytest.mark.parametrize('EventClass', [
56 57 RepoPreCreateEvent, RepoCreateEvent,
57 58 RepoPreDeleteEvent, RepoDeleteEvent,
58 59 ])
59 60 def test_repo_events_serialized(config_stub, repo_stub, EventClass):
60 61 event = EventClass(repo_stub)
61 62 data = event.as_dict()
62 63 assert data['name'] == EventClass.name
63 64 assert data['repo']['repo_name'] == repo_stub.repo_name
64 65 assert data['repo']['url']
65 66 assert data['repo']['permalink_url']
66 67
67 68
68 69 @pytest.mark.parametrize('EventClass', [
69 70 RepoPrePullEvent, RepoPullEvent, RepoPrePushEvent
70 71 ])
71 72 def test_vcs_repo_events_serialize(config_stub, repo_stub, scm_extras, EventClass):
72 73 event = EventClass(repo_name=repo_stub.repo_name, extras=scm_extras)
73 74 data = event.as_dict()
74 75 assert data['name'] == EventClass.name
75 76 assert data['repo']['repo_name'] == repo_stub.repo_name
76 77 assert data['repo']['url']
77 78 assert data['repo']['permalink_url']
78 79
79 80
80 81 @pytest.mark.parametrize('EventClass', [RepoPushEvent])
81 82 def test_vcs_repo_push_event_serialize(config_stub, repo_stub, scm_extras, EventClass):
82 83 event = EventClass(repo_name=repo_stub.repo_name,
83 84 pushed_commit_ids=scm_extras['commit_ids'],
84 85 extras=scm_extras)
85 86 data = event.as_dict()
86 87 assert data['name'] == EventClass.name
87 88 assert data['repo']['repo_name'] == repo_stub.repo_name
88 89 assert data['repo']['url']
89 90 assert data['repo']['permalink_url']
90 91
91 92
92 93 def test_create_delete_repo_fires_events(backend):
93 94 with EventCatcher() as event_catcher:
94 95 repo = backend.create_repo()
95 96 assert event_catcher.events_types == [RepoPreCreateEvent, RepoCreateEvent]
96 97
97 98 with EventCatcher() as event_catcher:
98 99 RepoModel().delete(repo)
99 100 assert event_catcher.events_types == [RepoPreDeleteEvent, RepoDeleteEvent]
100 101
101 102
102 103 def test_pull_fires_events(scm_extras):
103 104 with EventCatcher() as event_catcher:
104 105 hooks_base.pre_push(scm_extras)
105 106 assert event_catcher.events_types == [RepoPrePushEvent]
106 107
107 108 with EventCatcher() as event_catcher:
108 109 hooks_base.post_push(scm_extras)
109 110 assert event_catcher.events_types == [RepoPushEvent]
110 111
111 112
112 113 def test_push_fires_events(scm_extras):
113 114 with EventCatcher() as event_catcher:
114 115 hooks_base.pre_pull(scm_extras)
115 116 assert event_catcher.events_types == [RepoPrePullEvent]
116 117
117 118 with EventCatcher() as event_catcher:
118 119 hooks_base.post_pull(scm_extras)
119 120 assert event_catcher.events_types == [RepoPullEvent]
120 121
@@ -1,52 +1,53 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 import pytest
23 23 from rhodecode import events
24 24
25 25
26 26 @pytest.fixture
27 27 def repo_push_event(backend, user_regular):
28 28 commits = [
29 29 {'message': 'ancestor commit fixes #15'},
30 30 {'message': 'quick fixes'},
31 31 {'message': 'change that fixes #41, #2'},
32 32 {'message': 'this is because 5b23c3532 broke stuff'},
33 33 {'message': 'last commit'},
34 34 ]
35 35 commit_ids = backend.create_master_repo(commits).values()
36 36 repo = backend.create_repo()
37 37 scm_extras = {
38 38 'ip': '127.0.0.1',
39 39 'username': user_regular.username,
40 'user_id': user_regular.user_id,
40 41 'action': '',
41 42 'repository': repo.repo_name,
42 43 'scm': repo.scm_instance().alias,
43 44 'config': '',
44 45 'server_url': 'http://example.com',
45 46 'make_lock': None,
46 47 'locked_by': [None],
47 48 'commit_ids': commit_ids,
48 49 }
49 50
50 51 return events.RepoPushEvent(repo_name=repo.repo_name,
51 52 pushed_commit_ids=commit_ids,
52 53 extras=scm_extras)
@@ -1,94 +1,96 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22 from mock import Mock, patch
23 23
24 24 from rhodecode.lib import base
25 25 from rhodecode.model import db
26 26
27 27
28 28 @pytest.mark.parametrize('result_key, expected_value', [
29 29 ('username', 'stub_username'),
30 30 ('action', 'stub_action'),
31 31 ('repository', 'stub_repo_name'),
32 32 ('scm', 'stub_scm'),
33 33 ('hooks', ['stub_hook']),
34 34 ('config', 'stub_ini_filename'),
35 35 ('ip', '1.2.3.4'),
36 36 ('server_url', 'https://example.com'),
37 37 ('user_agent', 'client-text-v1.1'),
38 38 # TODO: johbo: Commpare locking parameters with `_get_rc_scm_extras`
39 39 # in hooks_utils.
40 40 ('make_lock', None),
41 41 ('locked_by', [None, None, None]),
42 42 ])
43 43 def test_vcs_operation_context_parameters(result_key, expected_value):
44 44 result = call_vcs_operation_context()
45 45 assert result[result_key] == expected_value
46 46
47 47
48 48 @patch('rhodecode.model.db.User.get_by_username', Mock())
49 49 @patch('rhodecode.model.db.Repository.get_by_repo_name')
50 50 def test_vcs_operation_context_checks_locking(mock_get_by_repo_name):
51 51 mock_get_locking_state = mock_get_by_repo_name().get_locking_state
52 52 mock_get_locking_state.return_value = (None, None, [None, None, None])
53 53 call_vcs_operation_context(check_locking=True)
54 54 assert mock_get_locking_state.called
55 55
56 56
57 57 @patch('rhodecode.model.db.Repository.get_locking_state')
58 58 def test_vcs_operation_context_skips_locking_checks_if_anonymouse(
59 59 mock_get_locking_state):
60 60 call_vcs_operation_context(
61 61 username=db.User.DEFAULT_USER, check_locking=True)
62 62 assert not mock_get_locking_state.called
63 63
64 64
65 65 @patch('rhodecode.model.db.Repository.get_locking_state')
66 66 def test_vcs_operation_context_can_skip_locking_check(mock_get_locking_state):
67 67 call_vcs_operation_context(check_locking=False)
68 68 assert not mock_get_locking_state.called
69 69
70 70
71 71 @patch.object(
72 72 base, 'get_enabled_hook_classes', Mock(return_value=['stub_hook']))
73 73 @patch('rhodecode.lib.utils2.get_server_url',
74 74 Mock(return_value='https://example.com'))
75 @patch.object(db.User, 'get_by_username',
76 Mock(return_value=Mock(return_value=1)))
75 77 def call_vcs_operation_context(**kwargs_override):
76 78 kwargs = {
77 79 'repo_name': 'stub_repo_name',
78 80 'username': 'stub_username',
79 81 'action': 'stub_action',
80 82 'scm': 'stub_scm',
81 83 'check_locking': False,
82 84 }
83 85 kwargs.update(kwargs_override)
84 86 config_file_patch = patch.dict(
85 87 'rhodecode.CONFIG', {'__file__': 'stub_ini_filename'})
86 88 settings_patch = patch.object(base, 'VcsSettingsModel')
87 89 with config_file_patch, settings_patch as settings_mock:
88 90 result = base.vcs_operation_context(
89 91 environ={'HTTP_USER_AGENT': 'client-text-v1.1',
90 92 'REMOTE_ADDR': '1.2.3.4'}, **kwargs)
91 93 settings_mock.assert_called_once_with(repo='stub_repo_name')
92 94 return result
93 95
94 96
@@ -1,141 +1,143 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23 from rhodecode.model.db import Session, UserLog
24 24 from rhodecode.lib import hooks_base, utils2
25 25
26 26
27 27 def test_post_push_truncates_commits(user_regular, repo_stub):
28 28 extras = {
29 29 'ip': '127.0.0.1',
30 30 'username': user_regular.username,
31 'user_id': user_regular.user_id,
31 32 'action': 'push_local',
32 33 'repository': repo_stub.repo_name,
33 34 'scm': 'git',
34 35 'config': '',
35 36 'server_url': 'http://example.com',
36 37 'make_lock': None,
37 38 'user_agent': 'some-client',
38 39 'locked_by': [None],
39 40 'commit_ids': ['abcde12345' * 4] * 30000,
40 41 'is_shadow_repo': False,
41 42 }
42 43 extras = utils2.AttributeDict(extras)
43 44
44 45 hooks_base.post_push(extras)
45 46
46 47 # Calculate appropriate action string here
47 48 commit_ids = extras.commit_ids[:400]
48 49
49 50 entry = UserLog.query().order_by('-user_log_id').first()
50 51 assert entry.action == 'user.push'
51 52 assert entry.action_data['commit_ids'] == commit_ids
52 53 Session().delete(entry)
53 54 Session().commit()
54 55
55 56
56 57 def assert_called_with_mock(callable_, expected_mock_name):
57 58 mock_obj = callable_.call_args[0][0]
58 59 mock_name = mock_obj._mock_new_parent._mock_new_name
59 60 assert mock_name == expected_mock_name
60 61
61 62
62 63 @pytest.fixture
63 64 def hook_extras(user_regular, repo_stub):
64 65 extras = utils2.AttributeDict({
65 66 'ip': '127.0.0.1',
66 67 'username': user_regular.username,
68 'user_id': user_regular.user_id,
67 69 'action': 'push',
68 70 'repository': repo_stub.repo_name,
69 71 'scm': '',
70 72 'config': '',
71 73 'server_url': 'http://example.com',
72 74 'make_lock': None,
73 75 'user_agent': 'some-client',
74 76 'locked_by': [None],
75 77 'commit_ids': [],
76 78 'is_shadow_repo': False,
77 79 })
78 80 return extras
79 81
80 82
81 83 @pytest.mark.parametrize('func, extension, event', [
82 84 (hooks_base.pre_push, 'pre_push_extension', 'RepoPrePushEvent'),
83 85 (hooks_base.post_push, 'post_pull_extension', 'RepoPushEvent'),
84 86 (hooks_base.pre_pull, 'pre_pull_extension', 'RepoPrePullEvent'),
85 87 (hooks_base.post_pull, 'post_push_extension', 'RepoPullEvent'),
86 88 ])
87 89 def test_hooks_propagate(func, extension, event, hook_extras):
88 90 """
89 91 Tests that our hook code propagates to rhodecode extensions and triggers
90 92 the appropriate event.
91 93 """
92 94 extension_mock = mock.Mock()
93 95 events_mock = mock.Mock()
94 96 patches = {
95 97 'Repository': mock.Mock(),
96 98 'events': events_mock,
97 99 extension: extension_mock,
98 100 }
99 101
100 102 # Clear shadow repo flag.
101 103 hook_extras.is_shadow_repo = False
102 104
103 105 # Execute hook function.
104 106 with mock.patch.multiple(hooks_base, **patches):
105 107 func(hook_extras)
106 108
107 109 # Assert that extensions are called and event was fired.
108 110 extension_mock.called_once()
109 111 assert_called_with_mock(events_mock.trigger, event)
110 112
111 113
112 114 @pytest.mark.parametrize('func, extension, event', [
113 115 (hooks_base.pre_push, 'pre_push_extension', 'RepoPrePushEvent'),
114 116 (hooks_base.post_push, 'post_pull_extension', 'RepoPushEvent'),
115 117 (hooks_base.pre_pull, 'pre_pull_extension', 'RepoPrePullEvent'),
116 118 (hooks_base.post_pull, 'post_push_extension', 'RepoPullEvent'),
117 119 ])
118 120 def test_hooks_propagates_not_on_shadow(func, extension, event, hook_extras):
119 121 """
120 122 If hooks are called by a request to a shadow repo we only want to run our
121 123 internal hooks code but not external ones like rhodecode extensions or
122 124 trigger an event.
123 125 """
124 126 extension_mock = mock.Mock()
125 127 events_mock = mock.Mock()
126 128 patches = {
127 129 'Repository': mock.Mock(),
128 130 'events': events_mock,
129 131 extension: extension_mock,
130 132 }
131 133
132 134 # Set shadow repo flag.
133 135 hook_extras.is_shadow_repo = True
134 136
135 137 # Execute hook function.
136 138 with mock.patch.multiple(hooks_base, **patches):
137 139 func(hook_extras)
138 140
139 141 # Assert that extensions are *not* called and event was *not* fired.
140 142 assert not extension_mock.called
141 143 assert not events_mock.trigger.called
@@ -1,859 +1,860 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import mock
22 22 import pytest
23 23 import textwrap
24 24
25 25 import rhodecode
26 26 from rhodecode.lib.utils2 import safe_unicode
27 27 from rhodecode.lib.vcs.backends import get_backend
28 28 from rhodecode.lib.vcs.backends.base import (
29 29 MergeResponse, MergeFailureReason, Reference)
30 30 from rhodecode.lib.vcs.exceptions import RepositoryError
31 31 from rhodecode.lib.vcs.nodes import FileNode
32 32 from rhodecode.model.comment import CommentsModel
33 33 from rhodecode.model.db import PullRequest, Session
34 34 from rhodecode.model.pull_request import PullRequestModel
35 35 from rhodecode.model.user import UserModel
36 36 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
37 37
38 38
39 39 pytestmark = [
40 40 pytest.mark.backends("git", "hg"),
41 41 ]
42 42
43 43
44 44 @pytest.mark.usefixtures('config_stub')
45 45 class TestPullRequestModel(object):
46 46
47 47 @pytest.fixture
48 48 def pull_request(self, request, backend, pr_util):
49 49 """
50 50 A pull request combined with multiples patches.
51 51 """
52 52 BackendClass = get_backend(backend.alias)
53 53 self.merge_patcher = mock.patch.object(
54 54 BackendClass, 'merge', return_value=MergeResponse(
55 55 False, False, None, MergeFailureReason.UNKNOWN))
56 56 self.workspace_remove_patcher = mock.patch.object(
57 57 BackendClass, 'cleanup_merge_workspace')
58 58
59 59 self.workspace_remove_mock = self.workspace_remove_patcher.start()
60 60 self.merge_mock = self.merge_patcher.start()
61 61 self.comment_patcher = mock.patch(
62 62 'rhodecode.model.changeset_status.ChangesetStatusModel.set_status')
63 63 self.comment_patcher.start()
64 64 self.notification_patcher = mock.patch(
65 65 'rhodecode.model.notification.NotificationModel.create')
66 66 self.notification_patcher.start()
67 67 self.helper_patcher = mock.patch(
68 68 'rhodecode.lib.helpers.route_path')
69 69 self.helper_patcher.start()
70 70
71 71 self.hook_patcher = mock.patch.object(PullRequestModel,
72 72 '_trigger_pull_request_hook')
73 73 self.hook_mock = self.hook_patcher.start()
74 74
75 75 self.invalidation_patcher = mock.patch(
76 76 'rhodecode.model.pull_request.ScmModel.mark_for_invalidation')
77 77 self.invalidation_mock = self.invalidation_patcher.start()
78 78
79 79 self.pull_request = pr_util.create_pull_request(
80 80 mergeable=True, name_suffix=u'Δ…Δ‡')
81 81 self.source_commit = self.pull_request.source_ref_parts.commit_id
82 82 self.target_commit = self.pull_request.target_ref_parts.commit_id
83 83 self.workspace_id = 'pr-%s' % self.pull_request.pull_request_id
84 84
85 85 @request.addfinalizer
86 86 def cleanup_pull_request():
87 87 calls = [mock.call(
88 88 self.pull_request, self.pull_request.author, 'create')]
89 89 self.hook_mock.assert_has_calls(calls)
90 90
91 91 self.workspace_remove_patcher.stop()
92 92 self.merge_patcher.stop()
93 93 self.comment_patcher.stop()
94 94 self.notification_patcher.stop()
95 95 self.helper_patcher.stop()
96 96 self.hook_patcher.stop()
97 97 self.invalidation_patcher.stop()
98 98
99 99 return self.pull_request
100 100
101 101 def test_get_all(self, pull_request):
102 102 prs = PullRequestModel().get_all(pull_request.target_repo)
103 103 assert isinstance(prs, list)
104 104 assert len(prs) == 1
105 105
106 106 def test_count_all(self, pull_request):
107 107 pr_count = PullRequestModel().count_all(pull_request.target_repo)
108 108 assert pr_count == 1
109 109
110 110 def test_get_awaiting_review(self, pull_request):
111 111 prs = PullRequestModel().get_awaiting_review(pull_request.target_repo)
112 112 assert isinstance(prs, list)
113 113 assert len(prs) == 1
114 114
115 115 def test_count_awaiting_review(self, pull_request):
116 116 pr_count = PullRequestModel().count_awaiting_review(
117 117 pull_request.target_repo)
118 118 assert pr_count == 1
119 119
120 120 def test_get_awaiting_my_review(self, pull_request):
121 121 PullRequestModel().update_reviewers(
122 122 pull_request, [(pull_request.author, ['author'], False)],
123 123 pull_request.author)
124 124 prs = PullRequestModel().get_awaiting_my_review(
125 125 pull_request.target_repo, user_id=pull_request.author.user_id)
126 126 assert isinstance(prs, list)
127 127 assert len(prs) == 1
128 128
129 129 def test_count_awaiting_my_review(self, pull_request):
130 130 PullRequestModel().update_reviewers(
131 131 pull_request, [(pull_request.author, ['author'], False)],
132 132 pull_request.author)
133 133 pr_count = PullRequestModel().count_awaiting_my_review(
134 134 pull_request.target_repo, user_id=pull_request.author.user_id)
135 135 assert pr_count == 1
136 136
137 137 def test_delete_calls_cleanup_merge(self, pull_request):
138 138 PullRequestModel().delete(pull_request, pull_request.author)
139 139
140 140 self.workspace_remove_mock.assert_called_once_with(
141 141 self.workspace_id)
142 142
143 143 def test_close_calls_cleanup_and_hook(self, pull_request):
144 144 PullRequestModel().close_pull_request(
145 145 pull_request, pull_request.author)
146 146
147 147 self.workspace_remove_mock.assert_called_once_with(
148 148 self.workspace_id)
149 149 self.hook_mock.assert_called_with(
150 150 self.pull_request, self.pull_request.author, 'close')
151 151
152 152 def test_merge_status(self, pull_request):
153 153 self.merge_mock.return_value = MergeResponse(
154 154 True, False, None, MergeFailureReason.NONE)
155 155
156 156 assert pull_request._last_merge_source_rev is None
157 157 assert pull_request._last_merge_target_rev is None
158 158 assert pull_request.last_merge_status is None
159 159
160 160 status, msg = PullRequestModel().merge_status(pull_request)
161 161 assert status is True
162 162 assert msg.eval() == 'This pull request can be automatically merged.'
163 163 self.merge_mock.assert_called_with(
164 164 pull_request.target_ref_parts,
165 165 pull_request.source_repo.scm_instance(),
166 166 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
167 167 use_rebase=False, close_branch=False)
168 168
169 169 assert pull_request._last_merge_source_rev == self.source_commit
170 170 assert pull_request._last_merge_target_rev == self.target_commit
171 171 assert pull_request.last_merge_status is MergeFailureReason.NONE
172 172
173 173 self.merge_mock.reset_mock()
174 174 status, msg = PullRequestModel().merge_status(pull_request)
175 175 assert status is True
176 176 assert msg.eval() == 'This pull request can be automatically merged.'
177 177 assert self.merge_mock.called is False
178 178
179 179 def test_merge_status_known_failure(self, pull_request):
180 180 self.merge_mock.return_value = MergeResponse(
181 181 False, False, None, MergeFailureReason.MERGE_FAILED)
182 182
183 183 assert pull_request._last_merge_source_rev is None
184 184 assert pull_request._last_merge_target_rev is None
185 185 assert pull_request.last_merge_status is None
186 186
187 187 status, msg = PullRequestModel().merge_status(pull_request)
188 188 assert status is False
189 189 assert (
190 190 msg.eval() ==
191 191 'This pull request cannot be merged because of merge conflicts.')
192 192 self.merge_mock.assert_called_with(
193 193 pull_request.target_ref_parts,
194 194 pull_request.source_repo.scm_instance(),
195 195 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
196 196 use_rebase=False, close_branch=False)
197 197
198 198 assert pull_request._last_merge_source_rev == self.source_commit
199 199 assert pull_request._last_merge_target_rev == self.target_commit
200 200 assert (
201 201 pull_request.last_merge_status is MergeFailureReason.MERGE_FAILED)
202 202
203 203 self.merge_mock.reset_mock()
204 204 status, msg = PullRequestModel().merge_status(pull_request)
205 205 assert status is False
206 206 assert (
207 207 msg.eval() ==
208 208 'This pull request cannot be merged because of merge conflicts.')
209 209 assert self.merge_mock.called is False
210 210
211 211 def test_merge_status_unknown_failure(self, pull_request):
212 212 self.merge_mock.return_value = MergeResponse(
213 213 False, False, None, MergeFailureReason.UNKNOWN)
214 214
215 215 assert pull_request._last_merge_source_rev is None
216 216 assert pull_request._last_merge_target_rev is None
217 217 assert pull_request.last_merge_status is None
218 218
219 219 status, msg = PullRequestModel().merge_status(pull_request)
220 220 assert status is False
221 221 assert msg.eval() == (
222 222 'This pull request cannot be merged because of an unhandled'
223 223 ' exception.')
224 224 self.merge_mock.assert_called_with(
225 225 pull_request.target_ref_parts,
226 226 pull_request.source_repo.scm_instance(),
227 227 pull_request.source_ref_parts, self.workspace_id, dry_run=True,
228 228 use_rebase=False, close_branch=False)
229 229
230 230 assert pull_request._last_merge_source_rev is None
231 231 assert pull_request._last_merge_target_rev is None
232 232 assert pull_request.last_merge_status is None
233 233
234 234 self.merge_mock.reset_mock()
235 235 status, msg = PullRequestModel().merge_status(pull_request)
236 236 assert status is False
237 237 assert msg.eval() == (
238 238 'This pull request cannot be merged because of an unhandled'
239 239 ' exception.')
240 240 assert self.merge_mock.called is True
241 241
242 242 def test_merge_status_when_target_is_locked(self, pull_request):
243 243 pull_request.target_repo.locked = [1, u'12345.50', 'lock_web']
244 244 status, msg = PullRequestModel().merge_status(pull_request)
245 245 assert status is False
246 246 assert msg.eval() == (
247 247 'This pull request cannot be merged because the target repository'
248 248 ' is locked.')
249 249
250 250 def test_merge_status_requirements_check_target(self, pull_request):
251 251
252 252 def has_largefiles(self, repo):
253 253 return repo == pull_request.source_repo
254 254
255 255 patcher = mock.patch.object(
256 256 PullRequestModel, '_has_largefiles', has_largefiles)
257 257 with patcher:
258 258 status, msg = PullRequestModel().merge_status(pull_request)
259 259
260 260 assert status is False
261 261 assert msg == 'Target repository large files support is disabled.'
262 262
263 263 def test_merge_status_requirements_check_source(self, pull_request):
264 264
265 265 def has_largefiles(self, repo):
266 266 return repo == pull_request.target_repo
267 267
268 268 patcher = mock.patch.object(
269 269 PullRequestModel, '_has_largefiles', has_largefiles)
270 270 with patcher:
271 271 status, msg = PullRequestModel().merge_status(pull_request)
272 272
273 273 assert status is False
274 274 assert msg == 'Source repository large files support is disabled.'
275 275
276 276 def test_merge(self, pull_request, merge_extras):
277 277 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
278 278 merge_ref = Reference(
279 279 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
280 280 self.merge_mock.return_value = MergeResponse(
281 281 True, True, merge_ref, MergeFailureReason.NONE)
282 282
283 283 merge_extras['repository'] = pull_request.target_repo.repo_name
284 284 PullRequestModel().merge(
285 285 pull_request, pull_request.author, extras=merge_extras)
286 286
287 287 message = (
288 288 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
289 289 u'\n\n {pr_title}'.format(
290 290 pr_id=pull_request.pull_request_id,
291 291 source_repo=safe_unicode(
292 292 pull_request.source_repo.scm_instance().name),
293 293 source_ref_name=pull_request.source_ref_parts.name,
294 294 pr_title=safe_unicode(pull_request.title)
295 295 )
296 296 )
297 297 self.merge_mock.assert_called_with(
298 298 pull_request.target_ref_parts,
299 299 pull_request.source_repo.scm_instance(),
300 300 pull_request.source_ref_parts, self.workspace_id,
301 301 user_name=user.username, user_email=user.email, message=message,
302 302 use_rebase=False, close_branch=False
303 303 )
304 304 self.invalidation_mock.assert_called_once_with(
305 305 pull_request.target_repo.repo_name)
306 306
307 307 self.hook_mock.assert_called_with(
308 308 self.pull_request, self.pull_request.author, 'merge')
309 309
310 310 pull_request = PullRequest.get(pull_request.pull_request_id)
311 311 assert (
312 312 pull_request.merge_rev ==
313 313 '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
314 314
315 315 def test_merge_failed(self, pull_request, merge_extras):
316 316 user = UserModel().get_by_username(TEST_USER_ADMIN_LOGIN)
317 317 merge_ref = Reference(
318 318 'type', 'name', '6126b7bfcc82ad2d3deaee22af926b082ce54cc6')
319 319 self.merge_mock.return_value = MergeResponse(
320 320 False, False, merge_ref, MergeFailureReason.MERGE_FAILED)
321 321
322 322 merge_extras['repository'] = pull_request.target_repo.repo_name
323 323 PullRequestModel().merge(
324 324 pull_request, pull_request.author, extras=merge_extras)
325 325
326 326 message = (
327 327 u'Merge pull request #{pr_id} from {source_repo} {source_ref_name}'
328 328 u'\n\n {pr_title}'.format(
329 329 pr_id=pull_request.pull_request_id,
330 330 source_repo=safe_unicode(
331 331 pull_request.source_repo.scm_instance().name),
332 332 source_ref_name=pull_request.source_ref_parts.name,
333 333 pr_title=safe_unicode(pull_request.title)
334 334 )
335 335 )
336 336 self.merge_mock.assert_called_with(
337 337 pull_request.target_ref_parts,
338 338 pull_request.source_repo.scm_instance(),
339 339 pull_request.source_ref_parts, self.workspace_id,
340 340 user_name=user.username, user_email=user.email, message=message,
341 341 use_rebase=False, close_branch=False
342 342 )
343 343
344 344 pull_request = PullRequest.get(pull_request.pull_request_id)
345 345 assert self.invalidation_mock.called is False
346 346 assert pull_request.merge_rev is None
347 347
348 348 def test_get_commit_ids(self, pull_request):
349 349 # The PR has been not merget yet, so expect an exception
350 350 with pytest.raises(ValueError):
351 351 PullRequestModel()._get_commit_ids(pull_request)
352 352
353 353 # Merge revision is in the revisions list
354 354 pull_request.merge_rev = pull_request.revisions[0]
355 355 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
356 356 assert commit_ids == pull_request.revisions
357 357
358 358 # Merge revision is not in the revisions list
359 359 pull_request.merge_rev = 'f000' * 10
360 360 commit_ids = PullRequestModel()._get_commit_ids(pull_request)
361 361 assert commit_ids == pull_request.revisions + [pull_request.merge_rev]
362 362
363 363 def test_get_diff_from_pr_version(self, pull_request):
364 364 source_repo = pull_request.source_repo
365 365 source_ref_id = pull_request.source_ref_parts.commit_id
366 366 target_ref_id = pull_request.target_ref_parts.commit_id
367 367 diff = PullRequestModel()._get_diff_from_pr_or_version(
368 368 source_repo, source_ref_id, target_ref_id, context=6)
369 369 assert 'file_1' in diff.raw
370 370
371 371 def test_generate_title_returns_unicode(self):
372 372 title = PullRequestModel().generate_pullrequest_title(
373 373 source='source-dummy',
374 374 source_ref='source-ref-dummy',
375 375 target='target-dummy',
376 376 )
377 377 assert type(title) == unicode
378 378
379 379
380 380 @pytest.mark.usefixtures('config_stub')
381 381 class TestIntegrationMerge(object):
382 382 @pytest.mark.parametrize('extra_config', (
383 383 {'vcs.hooks.protocol': 'http', 'vcs.hooks.direct_calls': False},
384 384 ))
385 385 def test_merge_triggers_push_hooks(
386 386 self, pr_util, user_admin, capture_rcextensions, merge_extras,
387 387 extra_config):
388 388 pull_request = pr_util.create_pull_request(
389 389 approved=True, mergeable=True)
390 390 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
391 391 merge_extras['repository'] = pull_request.target_repo.repo_name
392 392 Session().commit()
393 393
394 394 with mock.patch.dict(rhodecode.CONFIG, extra_config, clear=False):
395 395 merge_state = PullRequestModel().merge(
396 396 pull_request, user_admin, extras=merge_extras)
397 397
398 398 assert merge_state.executed
399 399 assert 'pre_push' in capture_rcextensions
400 400 assert 'post_push' in capture_rcextensions
401 401
402 402 def test_merge_can_be_rejected_by_pre_push_hook(
403 403 self, pr_util, user_admin, capture_rcextensions, merge_extras):
404 404 pull_request = pr_util.create_pull_request(
405 405 approved=True, mergeable=True)
406 406 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
407 407 merge_extras['repository'] = pull_request.target_repo.repo_name
408 408 Session().commit()
409 409
410 410 with mock.patch('rhodecode.EXTENSIONS.PRE_PUSH_HOOK') as pre_pull:
411 411 pre_pull.side_effect = RepositoryError("Disallow push!")
412 412 merge_status = PullRequestModel().merge(
413 413 pull_request, user_admin, extras=merge_extras)
414 414
415 415 assert not merge_status.executed
416 416 assert 'pre_push' not in capture_rcextensions
417 417 assert 'post_push' not in capture_rcextensions
418 418
419 419 def test_merge_fails_if_target_is_locked(
420 420 self, pr_util, user_regular, merge_extras):
421 421 pull_request = pr_util.create_pull_request(
422 422 approved=True, mergeable=True)
423 423 locked_by = [user_regular.user_id + 1, 12345.50, 'lock_web']
424 424 pull_request.target_repo.locked = locked_by
425 425 # TODO: johbo: Check if this can work based on the database, currently
426 426 # all data is pre-computed, that's why just updating the DB is not
427 427 # enough.
428 428 merge_extras['locked_by'] = locked_by
429 429 merge_extras['repository'] = pull_request.target_repo.repo_name
430 430 # TODO: johbo: Needed for sqlite, try to find an automatic way for it
431 431 Session().commit()
432 432 merge_status = PullRequestModel().merge(
433 433 pull_request, user_regular, extras=merge_extras)
434 434 assert not merge_status.executed
435 435
436 436
437 437 @pytest.mark.parametrize('use_outdated, inlines_count, outdated_count', [
438 438 (False, 1, 0),
439 439 (True, 0, 1),
440 440 ])
441 441 def test_outdated_comments(
442 442 pr_util, use_outdated, inlines_count, outdated_count, config_stub):
443 443 pull_request = pr_util.create_pull_request()
444 444 pr_util.create_inline_comment(file_path='not_in_updated_diff')
445 445
446 446 with outdated_comments_patcher(use_outdated) as outdated_comment_mock:
447 447 pr_util.add_one_commit()
448 448 assert_inline_comments(
449 449 pull_request, visible=inlines_count, outdated=outdated_count)
450 450 outdated_comment_mock.assert_called_with(pull_request)
451 451
452 452
453 453 @pytest.fixture
454 454 def merge_extras(user_regular):
455 455 """
456 456 Context for the vcs operation when running a merge.
457 457 """
458 458 extras = {
459 459 'ip': '127.0.0.1',
460 460 'username': user_regular.username,
461 'user_id': user_regular.user_id,
461 462 'action': 'push',
462 463 'repository': 'fake_target_repo_name',
463 464 'scm': 'git',
464 465 'config': 'fake_config_ini_path',
465 466 'make_lock': None,
466 467 'locked_by': [None, None, None],
467 468 'server_url': 'http://test.example.com:5000',
468 469 'hooks': ['push', 'pull'],
469 470 'is_shadow_repo': False,
470 471 }
471 472 return extras
472 473
473 474
474 475 @pytest.mark.usefixtures('config_stub')
475 476 class TestUpdateCommentHandling(object):
476 477
477 478 @pytest.fixture(autouse=True, scope='class')
478 479 def enable_outdated_comments(self, request, baseapp):
479 480 config_patch = mock.patch.dict(
480 481 'rhodecode.CONFIG', {'rhodecode_use_outdated_comments': True})
481 482 config_patch.start()
482 483
483 484 @request.addfinalizer
484 485 def cleanup():
485 486 config_patch.stop()
486 487
487 488 def test_comment_stays_unflagged_on_unchanged_diff(self, pr_util):
488 489 commits = [
489 490 {'message': 'a'},
490 491 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
491 492 {'message': 'c', 'added': [FileNode('file_c', 'test_content\n')]},
492 493 ]
493 494 pull_request = pr_util.create_pull_request(
494 495 commits=commits, target_head='a', source_head='b', revisions=['b'])
495 496 pr_util.create_inline_comment(file_path='file_b')
496 497 pr_util.add_one_commit(head='c')
497 498
498 499 assert_inline_comments(pull_request, visible=1, outdated=0)
499 500
500 501 def test_comment_stays_unflagged_on_change_above(self, pr_util):
501 502 original_content = ''.join(
502 503 ['line {}\n'.format(x) for x in range(1, 11)])
503 504 updated_content = 'new_line_at_top\n' + original_content
504 505 commits = [
505 506 {'message': 'a'},
506 507 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
507 508 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
508 509 ]
509 510 pull_request = pr_util.create_pull_request(
510 511 commits=commits, target_head='a', source_head='b', revisions=['b'])
511 512
512 513 with outdated_comments_patcher():
513 514 comment = pr_util.create_inline_comment(
514 515 line_no=u'n8', file_path='file_b')
515 516 pr_util.add_one_commit(head='c')
516 517
517 518 assert_inline_comments(pull_request, visible=1, outdated=0)
518 519 assert comment.line_no == u'n9'
519 520
520 521 def test_comment_stays_unflagged_on_change_below(self, pr_util):
521 522 original_content = ''.join(['line {}\n'.format(x) for x in range(10)])
522 523 updated_content = original_content + 'new_line_at_end\n'
523 524 commits = [
524 525 {'message': 'a'},
525 526 {'message': 'b', 'added': [FileNode('file_b', original_content)]},
526 527 {'message': 'c', 'changed': [FileNode('file_b', updated_content)]},
527 528 ]
528 529 pull_request = pr_util.create_pull_request(
529 530 commits=commits, target_head='a', source_head='b', revisions=['b'])
530 531 pr_util.create_inline_comment(file_path='file_b')
531 532 pr_util.add_one_commit(head='c')
532 533
533 534 assert_inline_comments(pull_request, visible=1, outdated=0)
534 535
535 536 @pytest.mark.parametrize('line_no', ['n4', 'o4', 'n10', 'o9'])
536 537 def test_comment_flagged_on_change_around_context(self, pr_util, line_no):
537 538 base_lines = ['line {}\n'.format(x) for x in range(1, 13)]
538 539 change_lines = list(base_lines)
539 540 change_lines.insert(6, 'line 6a added\n')
540 541
541 542 # Changes on the last line of sight
542 543 update_lines = list(change_lines)
543 544 update_lines[0] = 'line 1 changed\n'
544 545 update_lines[-1] = 'line 12 changed\n'
545 546
546 547 def file_b(lines):
547 548 return FileNode('file_b', ''.join(lines))
548 549
549 550 commits = [
550 551 {'message': 'a', 'added': [file_b(base_lines)]},
551 552 {'message': 'b', 'changed': [file_b(change_lines)]},
552 553 {'message': 'c', 'changed': [file_b(update_lines)]},
553 554 ]
554 555
555 556 pull_request = pr_util.create_pull_request(
556 557 commits=commits, target_head='a', source_head='b', revisions=['b'])
557 558 pr_util.create_inline_comment(line_no=line_no, file_path='file_b')
558 559
559 560 with outdated_comments_patcher():
560 561 pr_util.add_one_commit(head='c')
561 562 assert_inline_comments(pull_request, visible=0, outdated=1)
562 563
563 564 @pytest.mark.parametrize("change, content", [
564 565 ('changed', 'changed\n'),
565 566 ('removed', ''),
566 567 ], ids=['changed', 'removed'])
567 568 def test_comment_flagged_on_change(self, pr_util, change, content):
568 569 commits = [
569 570 {'message': 'a'},
570 571 {'message': 'b', 'added': [FileNode('file_b', 'test_content\n')]},
571 572 {'message': 'c', change: [FileNode('file_b', content)]},
572 573 ]
573 574 pull_request = pr_util.create_pull_request(
574 575 commits=commits, target_head='a', source_head='b', revisions=['b'])
575 576 pr_util.create_inline_comment(file_path='file_b')
576 577
577 578 with outdated_comments_patcher():
578 579 pr_util.add_one_commit(head='c')
579 580 assert_inline_comments(pull_request, visible=0, outdated=1)
580 581
581 582
582 583 @pytest.mark.usefixtures('config_stub')
583 584 class TestUpdateChangedFiles(object):
584 585
585 586 def test_no_changes_on_unchanged_diff(self, pr_util):
586 587 commits = [
587 588 {'message': 'a'},
588 589 {'message': 'b',
589 590 'added': [FileNode('file_b', 'test_content b\n')]},
590 591 {'message': 'c',
591 592 'added': [FileNode('file_c', 'test_content c\n')]},
592 593 ]
593 594 # open a PR from a to b, adding file_b
594 595 pull_request = pr_util.create_pull_request(
595 596 commits=commits, target_head='a', source_head='b', revisions=['b'],
596 597 name_suffix='per-file-review')
597 598
598 599 # modify PR adding new file file_c
599 600 pr_util.add_one_commit(head='c')
600 601
601 602 assert_pr_file_changes(
602 603 pull_request,
603 604 added=['file_c'],
604 605 modified=[],
605 606 removed=[])
606 607
607 608 def test_modify_and_undo_modification_diff(self, pr_util):
608 609 commits = [
609 610 {'message': 'a'},
610 611 {'message': 'b',
611 612 'added': [FileNode('file_b', 'test_content b\n')]},
612 613 {'message': 'c',
613 614 'changed': [FileNode('file_b', 'test_content b modified\n')]},
614 615 {'message': 'd',
615 616 'changed': [FileNode('file_b', 'test_content b\n')]},
616 617 ]
617 618 # open a PR from a to b, adding file_b
618 619 pull_request = pr_util.create_pull_request(
619 620 commits=commits, target_head='a', source_head='b', revisions=['b'],
620 621 name_suffix='per-file-review')
621 622
622 623 # modify PR modifying file file_b
623 624 pr_util.add_one_commit(head='c')
624 625
625 626 assert_pr_file_changes(
626 627 pull_request,
627 628 added=[],
628 629 modified=['file_b'],
629 630 removed=[])
630 631
631 632 # move the head again to d, which rollbacks change,
632 633 # meaning we should indicate no changes
633 634 pr_util.add_one_commit(head='d')
634 635
635 636 assert_pr_file_changes(
636 637 pull_request,
637 638 added=[],
638 639 modified=[],
639 640 removed=[])
640 641
641 642 def test_updated_all_files_in_pr(self, pr_util):
642 643 commits = [
643 644 {'message': 'a'},
644 645 {'message': 'b', 'added': [
645 646 FileNode('file_a', 'test_content a\n'),
646 647 FileNode('file_b', 'test_content b\n'),
647 648 FileNode('file_c', 'test_content c\n')]},
648 649 {'message': 'c', 'changed': [
649 650 FileNode('file_a', 'test_content a changed\n'),
650 651 FileNode('file_b', 'test_content b changed\n'),
651 652 FileNode('file_c', 'test_content c changed\n')]},
652 653 ]
653 654 # open a PR from a to b, changing 3 files
654 655 pull_request = pr_util.create_pull_request(
655 656 commits=commits, target_head='a', source_head='b', revisions=['b'],
656 657 name_suffix='per-file-review')
657 658
658 659 pr_util.add_one_commit(head='c')
659 660
660 661 assert_pr_file_changes(
661 662 pull_request,
662 663 added=[],
663 664 modified=['file_a', 'file_b', 'file_c'],
664 665 removed=[])
665 666
666 667 def test_updated_and_removed_all_files_in_pr(self, pr_util):
667 668 commits = [
668 669 {'message': 'a'},
669 670 {'message': 'b', 'added': [
670 671 FileNode('file_a', 'test_content a\n'),
671 672 FileNode('file_b', 'test_content b\n'),
672 673 FileNode('file_c', 'test_content c\n')]},
673 674 {'message': 'c', 'removed': [
674 675 FileNode('file_a', 'test_content a changed\n'),
675 676 FileNode('file_b', 'test_content b changed\n'),
676 677 FileNode('file_c', 'test_content c changed\n')]},
677 678 ]
678 679 # open a PR from a to b, removing 3 files
679 680 pull_request = pr_util.create_pull_request(
680 681 commits=commits, target_head='a', source_head='b', revisions=['b'],
681 682 name_suffix='per-file-review')
682 683
683 684 pr_util.add_one_commit(head='c')
684 685
685 686 assert_pr_file_changes(
686 687 pull_request,
687 688 added=[],
688 689 modified=[],
689 690 removed=['file_a', 'file_b', 'file_c'])
690 691
691 692
692 693 def test_update_writes_snapshot_into_pull_request_version(pr_util, config_stub):
693 694 model = PullRequestModel()
694 695 pull_request = pr_util.create_pull_request()
695 696 pr_util.update_source_repository()
696 697
697 698 model.update_commits(pull_request)
698 699
699 700 # Expect that it has a version entry now
700 701 assert len(model.get_versions(pull_request)) == 1
701 702
702 703
703 704 def test_update_skips_new_version_if_unchanged(pr_util, config_stub):
704 705 pull_request = pr_util.create_pull_request()
705 706 model = PullRequestModel()
706 707 model.update_commits(pull_request)
707 708
708 709 # Expect that it still has no versions
709 710 assert len(model.get_versions(pull_request)) == 0
710 711
711 712
712 713 def test_update_assigns_comments_to_the_new_version(pr_util, config_stub):
713 714 model = PullRequestModel()
714 715 pull_request = pr_util.create_pull_request()
715 716 comment = pr_util.create_comment()
716 717 pr_util.update_source_repository()
717 718
718 719 model.update_commits(pull_request)
719 720
720 721 # Expect that the comment is linked to the pr version now
721 722 assert comment.pull_request_version == model.get_versions(pull_request)[0]
722 723
723 724
724 725 def test_update_adds_a_comment_to_the_pull_request_about_the_change(pr_util, config_stub):
725 726 model = PullRequestModel()
726 727 pull_request = pr_util.create_pull_request()
727 728 pr_util.update_source_repository()
728 729 pr_util.update_source_repository()
729 730
730 731 model.update_commits(pull_request)
731 732
732 733 # Expect to find a new comment about the change
733 734 expected_message = textwrap.dedent(
734 735 """\
735 736 Pull request updated. Auto status change to |under_review|
736 737
737 738 .. role:: added
738 739 .. role:: removed
739 740 .. parsed-literal::
740 741
741 742 Changed commits:
742 743 * :added:`1 added`
743 744 * :removed:`0 removed`
744 745
745 746 Changed files:
746 747 * `A file_2 <#a_c--92ed3b5f07b4>`_
747 748
748 749 .. |under_review| replace:: *"Under Review"*"""
749 750 )
750 751 pull_request_comments = sorted(
751 752 pull_request.comments, key=lambda c: c.modified_at)
752 753 update_comment = pull_request_comments[-1]
753 754 assert update_comment.text == expected_message
754 755
755 756
756 757 def test_create_version_from_snapshot_updates_attributes(pr_util, config_stub):
757 758 pull_request = pr_util.create_pull_request()
758 759
759 760 # Avoiding default values
760 761 pull_request.status = PullRequest.STATUS_CLOSED
761 762 pull_request._last_merge_source_rev = "0" * 40
762 763 pull_request._last_merge_target_rev = "1" * 40
763 764 pull_request.last_merge_status = 1
764 765 pull_request.merge_rev = "2" * 40
765 766
766 767 # Remember automatic values
767 768 created_on = pull_request.created_on
768 769 updated_on = pull_request.updated_on
769 770
770 771 # Create a new version of the pull request
771 772 version = PullRequestModel()._create_version_from_snapshot(pull_request)
772 773
773 774 # Check attributes
774 775 assert version.title == pr_util.create_parameters['title']
775 776 assert version.description == pr_util.create_parameters['description']
776 777 assert version.status == PullRequest.STATUS_CLOSED
777 778
778 779 # versions get updated created_on
779 780 assert version.created_on != created_on
780 781
781 782 assert version.updated_on == updated_on
782 783 assert version.user_id == pull_request.user_id
783 784 assert version.revisions == pr_util.create_parameters['revisions']
784 785 assert version.source_repo == pr_util.source_repository
785 786 assert version.source_ref == pr_util.create_parameters['source_ref']
786 787 assert version.target_repo == pr_util.target_repository
787 788 assert version.target_ref == pr_util.create_parameters['target_ref']
788 789 assert version._last_merge_source_rev == pull_request._last_merge_source_rev
789 790 assert version._last_merge_target_rev == pull_request._last_merge_target_rev
790 791 assert version.last_merge_status == pull_request.last_merge_status
791 792 assert version.merge_rev == pull_request.merge_rev
792 793 assert version.pull_request == pull_request
793 794
794 795
795 796 def test_link_comments_to_version_only_updates_unlinked_comments(pr_util, config_stub):
796 797 version1 = pr_util.create_version_of_pull_request()
797 798 comment_linked = pr_util.create_comment(linked_to=version1)
798 799 comment_unlinked = pr_util.create_comment()
799 800 version2 = pr_util.create_version_of_pull_request()
800 801
801 802 PullRequestModel()._link_comments_to_version(version2)
802 803
803 804 # Expect that only the new comment is linked to version2
804 805 assert (
805 806 comment_unlinked.pull_request_version_id ==
806 807 version2.pull_request_version_id)
807 808 assert (
808 809 comment_linked.pull_request_version_id ==
809 810 version1.pull_request_version_id)
810 811 assert (
811 812 comment_unlinked.pull_request_version_id !=
812 813 comment_linked.pull_request_version_id)
813 814
814 815
815 816 def test_calculate_commits():
816 817 old_ids = [1, 2, 3]
817 818 new_ids = [1, 3, 4, 5]
818 819 change = PullRequestModel()._calculate_commit_id_changes(old_ids, new_ids)
819 820 assert change.added == [4, 5]
820 821 assert change.common == [1, 3]
821 822 assert change.removed == [2]
822 823 assert change.total == [1, 3, 4, 5]
823 824
824 825
825 826 def assert_inline_comments(pull_request, visible=None, outdated=None):
826 827 if visible is not None:
827 828 inline_comments = CommentsModel().get_inline_comments(
828 829 pull_request.target_repo.repo_id, pull_request=pull_request)
829 830 inline_cnt = CommentsModel().get_inline_comments_count(
830 831 inline_comments)
831 832 assert inline_cnt == visible
832 833 if outdated is not None:
833 834 outdated_comments = CommentsModel().get_outdated_comments(
834 835 pull_request.target_repo.repo_id, pull_request)
835 836 assert len(outdated_comments) == outdated
836 837
837 838
838 839 def assert_pr_file_changes(
839 840 pull_request, added=None, modified=None, removed=None):
840 841 pr_versions = PullRequestModel().get_versions(pull_request)
841 842 # always use first version, ie original PR to calculate changes
842 843 pull_request_version = pr_versions[0]
843 844 old_diff_data, new_diff_data = PullRequestModel()._generate_update_diffs(
844 845 pull_request, pull_request_version)
845 846 file_changes = PullRequestModel()._calculate_file_changes(
846 847 old_diff_data, new_diff_data)
847 848
848 849 assert added == file_changes.added, \
849 850 'expected added:%s vs value:%s' % (added, file_changes.added)
850 851 assert modified == file_changes.modified, \
851 852 'expected modified:%s vs value:%s' % (modified, file_changes.modified)
852 853 assert removed == file_changes.removed, \
853 854 'expected removed:%s vs value:%s' % (removed, file_changes.removed)
854 855
855 856
856 857 def outdated_comments_patcher(use_outdated=True):
857 858 return mock.patch.object(
858 859 CommentsModel, 'use_outdated_comments',
859 860 return_value=use_outdated)
General Comments 0
You need to be logged in to leave comments. Login now