##// END OF EJS Templates
vcs: report 404 for shadow repos that are not existing anymore. Before we got 500 exception in this case.
marcink -
r2069:d39ea19b default
parent child Browse files
Show More
@@ -1,531 +1,540 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-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 SimpleVCS middleware for handling protocol request (push/clone etc.)
23 23 It's implemented with basic auth function
24 24 """
25 25
26 26 import os
27 27 import logging
28 28 import importlib
29 29 import re
30 30 from functools import wraps
31 31
32 32 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
33 33 from webob.exc import (
34 34 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
35 35
36 36 import rhodecode
37 37 from rhodecode.authentication.base import authenticate, VCS_TYPE
38 38 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
39 39 from rhodecode.lib.base import (
40 40 BasicAuth, get_ip_addr, get_user_agent, vcs_operation_context)
41 41 from rhodecode.lib.exceptions import (
42 42 HTTPLockedRC, HTTPRequirementError, UserCreationError,
43 43 NotAllowedToCreateUserError)
44 44 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
45 45 from rhodecode.lib.middleware import appenlight
46 46 from rhodecode.lib.middleware.utils import scm_app_http
47 47 from rhodecode.lib.utils import (
48 48 is_valid_repo, get_rhodecode_realm, get_rhodecode_base_path, SLUG_RE)
49 49 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool, safe_unicode
50 50 from rhodecode.lib.vcs.conf import settings as vcs_settings
51 51 from rhodecode.lib.vcs.backends import base
52 52 from rhodecode.model import meta
53 53 from rhodecode.model.db import User, Repository, PullRequest
54 54 from rhodecode.model.scm import ScmModel
55 55 from rhodecode.model.pull_request import PullRequestModel
56 56
57 57
58 58 log = logging.getLogger(__name__)
59 59
60 60
61 61 def initialize_generator(factory):
62 62 """
63 63 Initializes the returned generator by draining its first element.
64 64
65 65 This can be used to give a generator an initializer, which is the code
66 66 up to the first yield statement. This decorator enforces that the first
67 67 produced element has the value ``"__init__"`` to make its special
68 68 purpose very explicit in the using code.
69 69 """
70 70
71 71 @wraps(factory)
72 72 def wrapper(*args, **kwargs):
73 73 gen = factory(*args, **kwargs)
74 74 try:
75 75 init = gen.next()
76 76 except StopIteration:
77 77 raise ValueError('Generator must yield at least one element.')
78 78 if init != "__init__":
79 79 raise ValueError('First yielded element must be "__init__".')
80 80 return gen
81 81 return wrapper
82 82
83 83
84 84 class SimpleVCS(object):
85 85 """Common functionality for SCM HTTP handlers."""
86 86
87 87 SCM = 'unknown'
88 88
89 89 acl_repo_name = None
90 90 url_repo_name = None
91 91 vcs_repo_name = None
92 92
93 93 # We have to handle requests to shadow repositories different than requests
94 94 # to normal repositories. Therefore we have to distinguish them. To do this
95 95 # we use this regex which will match only on URLs pointing to shadow
96 96 # repositories.
97 97 shadow_repo_re = re.compile(
98 98 '(?P<groups>(?:{slug_pat}/)*)' # repo groups
99 99 '(?P<target>{slug_pat})/' # target repo
100 100 'pull-request/(?P<pr_id>\d+)/' # pull request
101 101 'repository$' # shadow repo
102 102 .format(slug_pat=SLUG_RE.pattern))
103 103
104 104 def __init__(self, application, config, registry):
105 105 self.registry = registry
106 106 self.application = application
107 107 self.config = config
108 108 # re-populated by specialized middleware
109 109 self.repo_vcs_config = base.Config()
110 110
111 111 # base path of repo locations
112 112 self.basepath = get_rhodecode_base_path()
113 113 # authenticate this VCS request using authfunc
114 114 auth_ret_code_detection = \
115 115 str2bool(self.config.get('auth_ret_code_detection', False))
116 116 self.authenticate = BasicAuth(
117 117 '', authenticate, registry, config.get('auth_ret_code'),
118 118 auth_ret_code_detection)
119 119 self.ip_addr = '0.0.0.0'
120 120
121 121 def set_repo_names(self, environ):
122 122 """
123 123 This will populate the attributes acl_repo_name, url_repo_name,
124 124 vcs_repo_name and is_shadow_repo. In case of requests to normal (non
125 125 shadow) repositories all names are equal. In case of requests to a
126 126 shadow repository the acl-name points to the target repo of the pull
127 127 request and the vcs-name points to the shadow repo file system path.
128 128 The url-name is always the URL used by the vcs client program.
129 129
130 130 Example in case of a shadow repo:
131 131 acl_repo_name = RepoGroup/MyRepo
132 132 url_repo_name = RepoGroup/MyRepo/pull-request/3/repository
133 133 vcs_repo_name = /repo/base/path/RepoGroup/.__shadow_MyRepo_pr-3'
134 134 """
135 135 # First we set the repo name from URL for all attributes. This is the
136 136 # default if handling normal (non shadow) repo requests.
137 137 self.url_repo_name = self._get_repository_name(environ)
138 138 self.acl_repo_name = self.vcs_repo_name = self.url_repo_name
139 139 self.is_shadow_repo = False
140 140
141 141 # Check if this is a request to a shadow repository.
142 142 match = self.shadow_repo_re.match(self.url_repo_name)
143 143 if match:
144 144 match_dict = match.groupdict()
145 145
146 146 # Build acl repo name from regex match.
147 147 acl_repo_name = safe_unicode('{groups}{target}'.format(
148 148 groups=match_dict['groups'] or '',
149 149 target=match_dict['target']))
150 150
151 151 # Retrieve pull request instance by ID from regex match.
152 152 pull_request = PullRequest.get(match_dict['pr_id'])
153 153
154 154 # Only proceed if we got a pull request and if acl repo name from
155 155 # URL equals the target repo name of the pull request.
156 156 if pull_request and (acl_repo_name ==
157 157 pull_request.target_repo.repo_name):
158 158 # Get file system path to shadow repository.
159 159 workspace_id = PullRequestModel()._workspace_id(pull_request)
160 160 target_vcs = pull_request.target_repo.scm_instance()
161 161 vcs_repo_name = target_vcs._get_shadow_repository_path(
162 162 workspace_id)
163 163
164 164 # Store names for later usage.
165 165 self.vcs_repo_name = vcs_repo_name
166 166 self.acl_repo_name = acl_repo_name
167 167 self.is_shadow_repo = True
168 168
169 169 log.debug('Setting all VCS repository names: %s', {
170 170 'acl_repo_name': self.acl_repo_name,
171 171 'url_repo_name': self.url_repo_name,
172 172 'vcs_repo_name': self.vcs_repo_name,
173 173 })
174 174
175 175 @property
176 176 def scm_app(self):
177 177 custom_implementation = self.config['vcs.scm_app_implementation']
178 178 if custom_implementation == 'http':
179 179 log.info('Using HTTP implementation of scm app.')
180 180 scm_app_impl = scm_app_http
181 181 else:
182 182 log.info('Using custom implementation of scm_app: "{}"'.format(
183 183 custom_implementation))
184 184 scm_app_impl = importlib.import_module(custom_implementation)
185 185 return scm_app_impl
186 186
187 187 def _get_by_id(self, repo_name):
188 188 """
189 189 Gets a special pattern _<ID> from clone url and tries to replace it
190 190 with a repository_name for support of _<ID> non changeable urls
191 191 """
192 192
193 193 data = repo_name.split('/')
194 194 if len(data) >= 2:
195 195 from rhodecode.model.repo import RepoModel
196 196 by_id_match = RepoModel().get_repo_by_id(repo_name)
197 197 if by_id_match:
198 198 data[1] = by_id_match.repo_name
199 199
200 200 return safe_str('/'.join(data))
201 201
202 202 def _invalidate_cache(self, repo_name):
203 203 """
204 204 Set's cache for this repository for invalidation on next access
205 205
206 206 :param repo_name: full repo name, also a cache key
207 207 """
208 208 ScmModel().mark_for_invalidation(repo_name)
209 209
210 210 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
211 211 db_repo = Repository.get_by_repo_name(repo_name)
212 212 if not db_repo:
213 213 log.debug('Repository `%s` not found inside the database.',
214 214 repo_name)
215 215 return False
216 216
217 217 if db_repo.repo_type != scm_type:
218 218 log.warning(
219 219 'Repository `%s` have incorrect scm_type, expected %s got %s',
220 220 repo_name, db_repo.repo_type, scm_type)
221 221 return False
222 222
223 223 return is_valid_repo(repo_name, base_path, explicit_scm=scm_type)
224 224
225 225 def valid_and_active_user(self, user):
226 226 """
227 227 Checks if that user is not empty, and if it's actually object it checks
228 228 if he's active.
229 229
230 230 :param user: user object or None
231 231 :return: boolean
232 232 """
233 233 if user is None:
234 234 return False
235 235
236 236 elif user.active:
237 237 return True
238 238
239 239 return False
240 240
241 @property
242 def is_shadow_repo_dir(self):
243 return os.path.isdir(self.vcs_repo_name)
244
241 245 def _check_permission(self, action, user, repo_name, ip_addr=None):
242 246 """
243 247 Checks permissions using action (push/pull) user and repository
244 248 name
245 249
246 250 :param action: push or pull action
247 251 :param user: user instance
248 252 :param repo_name: repository name
249 253 """
250 254 # check IP
251 255 inherit = user.inherit_default_permissions
252 256 ip_allowed = AuthUser.check_ip_allowed(user.user_id, ip_addr,
253 257 inherit_from_default=inherit)
254 258 if ip_allowed:
255 259 log.info('Access for IP:%s allowed', ip_addr)
256 260 else:
257 261 return False
258 262
259 263 if action == 'push':
260 264 if not HasPermissionAnyMiddleware('repository.write',
261 265 'repository.admin')(user,
262 266 repo_name):
263 267 return False
264 268
265 269 else:
266 270 # any other action need at least read permission
267 271 if not HasPermissionAnyMiddleware('repository.read',
268 272 'repository.write',
269 273 'repository.admin')(user,
270 274 repo_name):
271 275 return False
272 276
273 277 return True
274 278
275 279 def _check_ssl(self, environ, start_response):
276 280 """
277 281 Checks the SSL check flag and returns False if SSL is not present
278 282 and required True otherwise
279 283 """
280 284 org_proto = environ['wsgi._org_proto']
281 285 # check if we have SSL required ! if not it's a bad request !
282 286 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
283 287 if require_ssl and org_proto == 'http':
284 288 log.debug('proto is %s and SSL is required BAD REQUEST !',
285 289 org_proto)
286 290 return False
287 291 return True
288 292
289 293 def __call__(self, environ, start_response):
290 294 try:
291 295 return self._handle_request(environ, start_response)
292 296 except Exception:
293 297 log.exception("Exception while handling request")
294 298 appenlight.track_exception(environ)
295 299 return HTTPInternalServerError()(environ, start_response)
296 300 finally:
297 301 meta.Session.remove()
298 302
299 303 def _handle_request(self, environ, start_response):
300 304
301 305 if not self._check_ssl(environ, start_response):
302 306 reason = ('SSL required, while RhodeCode was unable '
303 307 'to detect this as SSL request')
304 308 log.debug('User not allowed to proceed, %s', reason)
305 309 return HTTPNotAcceptable(reason)(environ, start_response)
306 310
307 311 if not self.url_repo_name:
308 312 log.warning('Repository name is empty: %s', self.url_repo_name)
309 313 # failed to get repo name, we fail now
310 314 return HTTPNotFound()(environ, start_response)
311 315 log.debug('Extracted repo name is %s', self.url_repo_name)
312 316
313 317 ip_addr = get_ip_addr(environ)
314 318 user_agent = get_user_agent(environ)
315 319 username = None
316 320
317 321 # skip passing error to error controller
318 322 environ['pylons.status_code_redirect'] = True
319 323
320 324 # ======================================================================
321 325 # GET ACTION PULL or PUSH
322 326 # ======================================================================
323 327 action = self._get_action(environ)
324 328
325 329 # ======================================================================
326 330 # Check if this is a request to a shadow repository of a pull request.
327 331 # In this case only pull action is allowed.
328 332 # ======================================================================
329 333 if self.is_shadow_repo and action != 'pull':
330 334 reason = 'Only pull action is allowed for shadow repositories.'
331 335 log.debug('User not allowed to proceed, %s', reason)
332 336 return HTTPNotAcceptable(reason)(environ, start_response)
333 337
338 # Check if the shadow repo actually exists, in case someone refers
339 # to it, and it has been deleted because of successful merge.
340 if self.is_shadow_repo and not self.is_shadow_repo_dir:
341 return HTTPNotFound()(environ, start_response)
342
334 343 # ======================================================================
335 344 # CHECK ANONYMOUS PERMISSION
336 345 # ======================================================================
337 346 if action in ['pull', 'push']:
338 347 anonymous_user = User.get_default_user()
339 348 username = anonymous_user.username
340 349 if anonymous_user.active:
341 350 # ONLY check permissions if the user is activated
342 351 anonymous_perm = self._check_permission(
343 352 action, anonymous_user, self.acl_repo_name, ip_addr)
344 353 else:
345 354 anonymous_perm = False
346 355
347 356 if not anonymous_user.active or not anonymous_perm:
348 357 if not anonymous_user.active:
349 358 log.debug('Anonymous access is disabled, running '
350 359 'authentication')
351 360
352 361 if not anonymous_perm:
353 362 log.debug('Not enough credentials to access this '
354 363 'repository as anonymous user')
355 364
356 365 username = None
357 366 # ==============================================================
358 367 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
359 368 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
360 369 # ==============================================================
361 370
362 371 # try to auth based on environ, container auth methods
363 372 log.debug('Running PRE-AUTH for container based authentication')
364 373 pre_auth = authenticate(
365 374 '', '', environ, VCS_TYPE, registry=self.registry,
366 375 acl_repo_name=self.acl_repo_name)
367 376 if pre_auth and pre_auth.get('username'):
368 377 username = pre_auth['username']
369 378 log.debug('PRE-AUTH got %s as username', username)
370 379
371 380 # If not authenticated by the container, running basic auth
372 381 # before inject the calling repo_name for special scope checks
373 382 self.authenticate.acl_repo_name = self.acl_repo_name
374 383 if not username:
375 384 self.authenticate.realm = get_rhodecode_realm()
376 385
377 386 try:
378 387 result = self.authenticate(environ)
379 388 except (UserCreationError, NotAllowedToCreateUserError) as e:
380 389 log.error(e)
381 390 reason = safe_str(e)
382 391 return HTTPNotAcceptable(reason)(environ, start_response)
383 392
384 393 if isinstance(result, str):
385 394 AUTH_TYPE.update(environ, 'basic')
386 395 REMOTE_USER.update(environ, result)
387 396 username = result
388 397 else:
389 398 return result.wsgi_application(environ, start_response)
390 399
391 400 # ==============================================================
392 401 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
393 402 # ==============================================================
394 403 user = User.get_by_username(username)
395 404 if not self.valid_and_active_user(user):
396 405 return HTTPForbidden()(environ, start_response)
397 406 username = user.username
398 407 user.update_lastactivity()
399 408 meta.Session().commit()
400 409
401 410 # check user attributes for password change flag
402 411 user_obj = user
403 412 if user_obj and user_obj.username != User.DEFAULT_USER and \
404 413 user_obj.user_data.get('force_password_change'):
405 414 reason = 'password change required'
406 415 log.debug('User not allowed to authenticate, %s', reason)
407 416 return HTTPNotAcceptable(reason)(environ, start_response)
408 417
409 418 # check permissions for this repository
410 419 perm = self._check_permission(
411 420 action, user, self.acl_repo_name, ip_addr)
412 421 if not perm:
413 422 return HTTPForbidden()(environ, start_response)
414 423
415 424 # extras are injected into UI object and later available
416 425 # in hooks executed by rhodecode
417 426 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
418 427 extras = vcs_operation_context(
419 428 environ, repo_name=self.acl_repo_name, username=username,
420 429 action=action, scm=self.SCM, check_locking=check_locking,
421 430 is_shadow_repo=self.is_shadow_repo
422 431 )
423 432
424 433 # ======================================================================
425 434 # REQUEST HANDLING
426 435 # ======================================================================
427 436 repo_path = os.path.join(
428 437 safe_str(self.basepath), safe_str(self.vcs_repo_name))
429 438 log.debug('Repository path is %s', repo_path)
430 439
431 440 fix_PATH()
432 441
433 442 log.info(
434 443 '%s action on %s repo "%s" by "%s" from %s %s',
435 444 action, self.SCM, safe_str(self.url_repo_name),
436 445 safe_str(username), ip_addr, user_agent)
437 446
438 447 return self._generate_vcs_response(
439 448 environ, start_response, repo_path, extras, action)
440 449
441 450 @initialize_generator
442 451 def _generate_vcs_response(
443 452 self, environ, start_response, repo_path, extras, action):
444 453 """
445 454 Returns a generator for the response content.
446 455
447 456 This method is implemented as a generator, so that it can trigger
448 457 the cache validation after all content sent back to the client. It
449 458 also handles the locking exceptions which will be triggered when
450 459 the first chunk is produced by the underlying WSGI application.
451 460 """
452 461 callback_daemon, extras = self._prepare_callback_daemon(extras)
453 462 config = self._create_config(extras, self.acl_repo_name)
454 463 log.debug('HOOKS extras is %s', extras)
455 464 app = self._create_wsgi_app(repo_path, self.url_repo_name, config)
456 465
457 466 try:
458 467 with callback_daemon:
459 468 try:
460 469 response = app(environ, start_response)
461 470 finally:
462 471 # This statement works together with the decorator
463 472 # "initialize_generator" above. The decorator ensures that
464 473 # we hit the first yield statement before the generator is
465 474 # returned back to the WSGI server. This is needed to
466 475 # ensure that the call to "app" above triggers the
467 476 # needed callback to "start_response" before the
468 477 # generator is actually used.
469 478 yield "__init__"
470 479
471 480 for chunk in response:
472 481 yield chunk
473 482 except Exception as exc:
474 483 # TODO: martinb: Exceptions are only raised in case of the Pyro4
475 484 # backend. Refactor this except block after dropping Pyro4 support.
476 485 # TODO: johbo: Improve "translating" back the exception.
477 486 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
478 487 exc = HTTPLockedRC(*exc.args)
479 488 _code = rhodecode.CONFIG.get('lock_ret_code')
480 489 log.debug('Repository LOCKED ret code %s!', (_code,))
481 490 elif getattr(exc, '_vcs_kind', None) == 'requirement':
482 491 log.debug(
483 492 'Repository requires features unknown to this Mercurial')
484 493 exc = HTTPRequirementError(*exc.args)
485 494 else:
486 495 raise
487 496
488 497 for chunk in exc(environ, start_response):
489 498 yield chunk
490 499 finally:
491 500 # invalidate cache on push
492 501 try:
493 502 if action == 'push':
494 503 self._invalidate_cache(self.url_repo_name)
495 504 finally:
496 505 meta.Session.remove()
497 506
498 507 def _get_repository_name(self, environ):
499 508 """Get repository name out of the environmnent
500 509
501 510 :param environ: WSGI environment
502 511 """
503 512 raise NotImplementedError()
504 513
505 514 def _get_action(self, environ):
506 515 """Map request commands into a pull or push command.
507 516
508 517 :param environ: WSGI environment
509 518 """
510 519 raise NotImplementedError()
511 520
512 521 def _create_wsgi_app(self, repo_path, repo_name, config):
513 522 """Return the WSGI app that will finally handle the request."""
514 523 raise NotImplementedError()
515 524
516 525 def _create_config(self, extras, repo_name):
517 526 """Create a safe config representation."""
518 527 raise NotImplementedError()
519 528
520 529 def _prepare_callback_daemon(self, extras):
521 530 return prepare_callback_daemon(
522 531 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
523 532 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
524 533
525 534
526 535 def _should_check_locking(query_string):
527 536 # this is kind of hacky, but due to how mercurial handles client-server
528 537 # server see all operation on commit; bookmarks, phases and
529 538 # obsolescence marker in different transaction, we don't want to check
530 539 # locking on those
531 540 return query_string not in ['cmd=listkeys']
@@ -1,455 +1,485 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 base64
22 22
23 23 import mock
24 24 import pytest
25 25
26 26 from rhodecode.tests.utils import CustomTestApp
27 27
28 28 from rhodecode.lib.caching_query import FromCache
29 29 from rhodecode.lib.hooks_daemon import DummyHooksCallbackDaemon
30 30 from rhodecode.lib.middleware import simplevcs
31 31 from rhodecode.lib.middleware.https_fixup import HttpsFixup
32 32 from rhodecode.lib.middleware.utils import scm_app_http
33 33 from rhodecode.model.db import User, _hash_key
34 34 from rhodecode.model.meta import Session
35 35 from rhodecode.tests import (
36 36 HG_REPO, TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
37 37 from rhodecode.tests.lib.middleware import mock_scm_app
38 38
39 39
40 40 class StubVCSController(simplevcs.SimpleVCS):
41 41
42 42 SCM = 'hg'
43 43 stub_response_body = tuple()
44 44
45 45 def __init__(self, *args, **kwargs):
46 46 super(StubVCSController, self).__init__(*args, **kwargs)
47 47 self._action = 'pull'
48 self._is_shadow_repo_dir = True
48 49 self._name = HG_REPO
49 50 self.set_repo_names(None)
50 51
52 @property
53 def is_shadow_repo_dir(self):
54 return self._is_shadow_repo_dir
55
51 56 def _get_repository_name(self, environ):
52 57 return self._name
53 58
54 59 def _get_action(self, environ):
55 60 return self._action
56 61
57 62 def _create_wsgi_app(self, repo_path, repo_name, config):
58 63 def fake_app(environ, start_response):
59 64 headers = [
60 65 ('Http-Accept', 'application/mercurial')
61 66 ]
62 67 start_response('200 OK', headers)
63 68 return self.stub_response_body
64 69 return fake_app
65 70
66 71 def _create_config(self, extras, repo_name):
67 72 return None
68 73
69 74
70 75 @pytest.fixture
71 76 def vcscontroller(pylonsapp, config_stub):
72 77 config_stub.testing_securitypolicy()
73 78 config_stub.include('rhodecode.authentication')
74 79
75 80 #set_anonymous_access(True)
76 81 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
77 82 app = HttpsFixup(controller, pylonsapp.config)
78 83 app = CustomTestApp(app)
79 84
80 85 _remove_default_user_from_query_cache()
81 86
82 87 # Sanity checks that things are set up correctly
83 88 app.get('/' + HG_REPO, status=200)
84 89
85 90 app.controller = controller
86 91 return app
87 92
88 93
89 94 def _remove_default_user_from_query_cache():
90 95 user = User.get_default_user(cache=True)
91 96 query = Session().query(User).filter(User.username == user.username)
92 97 query = query.options(
93 98 FromCache("sql_cache_short", "get_user_%s" % _hash_key(user.username)))
94 99 query.invalidate()
95 100 Session().expire(user)
96 101
97 102
98 103 def test_handles_exceptions_during_permissions_checks(
99 104 vcscontroller, disable_anonymous_user):
100 105 user_and_pass = '%s:%s' % (TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
101 106 auth_password = base64.encodestring(user_and_pass).strip()
102 107 extra_environ = {
103 108 'AUTH_TYPE': 'Basic',
104 109 'HTTP_AUTHORIZATION': 'Basic %s' % auth_password,
105 110 'REMOTE_USER': TEST_USER_ADMIN_LOGIN,
106 111 }
107 112
108 113 # Verify that things are hooked up correctly
109 114 vcscontroller.get('/', status=200, extra_environ=extra_environ)
110 115
111 116 # Simulate trouble during permission checks
112 117 with mock.patch('rhodecode.model.db.User.get_by_username',
113 118 side_effect=Exception) as get_user:
114 119 # Verify that a correct 500 is returned and check that the expected
115 120 # code path was hit.
116 121 vcscontroller.get('/', status=500, extra_environ=extra_environ)
117 122 assert get_user.called
118 123
119 124
120 125 def test_returns_forbidden_if_no_anonymous_access(
121 126 vcscontroller, disable_anonymous_user):
122 127 vcscontroller.get('/', status=401)
123 128
124 129
125 130 class StubFailVCSController(simplevcs.SimpleVCS):
126 131 def _handle_request(self, environ, start_response):
127 132 raise Exception("BOOM")
128 133
129 134
130 135 @pytest.fixture(scope='module')
131 136 def fail_controller(pylonsapp):
132 137 controller = StubFailVCSController(pylonsapp, pylonsapp.config, None)
133 138 controller = HttpsFixup(controller, pylonsapp.config)
134 139 controller = CustomTestApp(controller)
135 140 return controller
136 141
137 142
138 143 def test_handles_exceptions_as_internal_server_error(fail_controller):
139 144 fail_controller.get('/', status=500)
140 145
141 146
142 147 def test_provides_traceback_for_appenlight(fail_controller):
143 148 response = fail_controller.get(
144 149 '/', status=500, extra_environ={'appenlight.client': 'fake'})
145 150 assert 'appenlight.__traceback' in response.request.environ
146 151
147 152
148 153 def test_provides_utils_scm_app_as_scm_app_by_default(pylonsapp):
149 154 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
150 155 assert controller.scm_app is scm_app_http
151 156
152 157
153 158 def test_allows_to_override_scm_app_via_config(pylonsapp):
154 159 config = pylonsapp.config.copy()
155 160 config['vcs.scm_app_implementation'] = (
156 161 'rhodecode.tests.lib.middleware.mock_scm_app')
157 162 controller = StubVCSController(pylonsapp, config, None)
158 163 assert controller.scm_app is mock_scm_app
159 164
160 165
161 166 @pytest.mark.parametrize('query_string, expected', [
162 167 ('cmd=stub_command', True),
163 168 ('cmd=listkeys', False),
164 169 ])
165 170 def test_should_check_locking(query_string, expected):
166 171 result = simplevcs._should_check_locking(query_string)
167 172 assert result == expected
168 173
169 174
170 175 class TestShadowRepoRegularExpression(object):
171 176 pr_segment = 'pull-request'
172 177 shadow_segment = 'repository'
173 178
174 179 @pytest.mark.parametrize('url, expected', [
175 180 # repo with/without groups
176 181 ('My-Repo/{pr_segment}/1/{shadow_segment}', True),
177 182 ('Group/My-Repo/{pr_segment}/2/{shadow_segment}', True),
178 183 ('Group/Sub-Group/My-Repo/{pr_segment}/3/{shadow_segment}', True),
179 184 ('Group/Sub-Group1/Sub-Group2/My-Repo/{pr_segment}/3/{shadow_segment}', True),
180 185
181 186 # pull request ID
182 187 ('MyRepo/{pr_segment}/1/{shadow_segment}', True),
183 188 ('MyRepo/{pr_segment}/1234567890/{shadow_segment}', True),
184 189 ('MyRepo/{pr_segment}/-1/{shadow_segment}', False),
185 190 ('MyRepo/{pr_segment}/invalid/{shadow_segment}', False),
186 191
187 192 # unicode
188 193 (u'Sp€çîál-Repö/{pr_segment}/1/{shadow_segment}', True),
189 194 (u'Sp€çîál-Gröüp/Sp€çîál-Repö/{pr_segment}/1/{shadow_segment}', True),
190 195
191 196 # trailing/leading slash
192 197 ('/My-Repo/{pr_segment}/1/{shadow_segment}', False),
193 198 ('My-Repo/{pr_segment}/1/{shadow_segment}/', False),
194 199 ('/My-Repo/{pr_segment}/1/{shadow_segment}/', False),
195 200
196 201 # misc
197 202 ('My-Repo/{pr_segment}/1/{shadow_segment}/extra', False),
198 203 ('My-Repo/{pr_segment}/1/{shadow_segment}extra', False),
199 204 ])
200 205 def test_shadow_repo_regular_expression(self, url, expected):
201 206 from rhodecode.lib.middleware.simplevcs import SimpleVCS
202 207 url = url.format(
203 208 pr_segment=self.pr_segment,
204 209 shadow_segment=self.shadow_segment)
205 210 match_obj = SimpleVCS.shadow_repo_re.match(url)
206 211 assert (match_obj is not None) == expected
207 212
208 213
209 214 @pytest.mark.backends('git', 'hg')
210 215 class TestShadowRepoExposure(object):
211 216
212 217 def test_pull_on_shadow_repo_propagates_to_wsgi_app(self, pylonsapp):
213 218 """
214 219 Check that a pull action to a shadow repo is propagated to the
215 220 underlying wsgi app.
216 221 """
217 222 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
218 223 controller._check_ssl = mock.Mock()
219 224 controller.is_shadow_repo = True
220 225 controller._action = 'pull'
226 controller._is_shadow_repo_dir = True
221 227 controller.stub_response_body = 'dummy body value'
222 228 environ_stub = {
223 229 'HTTP_HOST': 'test.example.com',
224 230 'HTTP_ACCEPT': 'application/mercurial',
225 231 'REQUEST_METHOD': 'GET',
226 232 'wsgi.url_scheme': 'http',
227 233 }
228 234
229 235 response = controller(environ_stub, mock.Mock())
230 236 response_body = ''.join(response)
231 237
232 238 # Assert that we got the response from the wsgi app.
233 239 assert response_body == controller.stub_response_body
234 240
241 def test_pull_on_shadow_repo_that_is_missing(self, pylonsapp):
242 """
243 Check that a pull action to a shadow repo is propagated to the
244 underlying wsgi app.
245 """
246 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
247 controller._check_ssl = mock.Mock()
248 controller.is_shadow_repo = True
249 controller._action = 'pull'
250 controller._is_shadow_repo_dir = False
251 controller.stub_response_body = 'dummy body value'
252 environ_stub = {
253 'HTTP_HOST': 'test.example.com',
254 'HTTP_ACCEPT': 'application/mercurial',
255 'REQUEST_METHOD': 'GET',
256 'wsgi.url_scheme': 'http',
257 }
258
259 response = controller(environ_stub, mock.Mock())
260 response_body = ''.join(response)
261
262 # Assert that we got the response from the wsgi app.
263 assert '404 Not Found' in response_body
264
235 265 def test_push_on_shadow_repo_raises(self, pylonsapp):
236 266 """
237 267 Check that a push action to a shadow repo is aborted.
238 268 """
239 269 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
240 270 controller._check_ssl = mock.Mock()
241 271 controller.is_shadow_repo = True
242 272 controller._action = 'push'
243 273 controller.stub_response_body = 'dummy body value'
244 274 environ_stub = {
245 275 'HTTP_HOST': 'test.example.com',
246 276 'HTTP_ACCEPT': 'application/mercurial',
247 277 'REQUEST_METHOD': 'GET',
248 278 'wsgi.url_scheme': 'http',
249 279 }
250 280
251 281 response = controller(environ_stub, mock.Mock())
252 282 response_body = ''.join(response)
253 283
254 284 assert response_body != controller.stub_response_body
255 285 # Assert that a 406 error is returned.
256 286 assert '406 Not Acceptable' in response_body
257 287
258 288 def test_set_repo_names_no_shadow(self, pylonsapp):
259 289 """
260 290 Check that the set_repo_names method sets all names to the one returned
261 291 by the _get_repository_name method on a request to a non shadow repo.
262 292 """
263 293 environ_stub = {}
264 294 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
265 295 controller._name = 'RepoGroup/MyRepo'
266 296 controller.set_repo_names(environ_stub)
267 297 assert not controller.is_shadow_repo
268 298 assert (controller.url_repo_name ==
269 299 controller.acl_repo_name ==
270 300 controller.vcs_repo_name ==
271 301 controller._get_repository_name(environ_stub))
272 302
273 303 def test_set_repo_names_with_shadow(self, pylonsapp, pr_util, config_stub):
274 304 """
275 305 Check that the set_repo_names method sets correct names on a request
276 306 to a shadow repo.
277 307 """
278 308 from rhodecode.model.pull_request import PullRequestModel
279 309
280 310 pull_request = pr_util.create_pull_request()
281 311 shadow_url = '{target}/{pr_segment}/{pr_id}/{shadow_segment}'.format(
282 312 target=pull_request.target_repo.repo_name,
283 313 pr_id=pull_request.pull_request_id,
284 314 pr_segment=TestShadowRepoRegularExpression.pr_segment,
285 315 shadow_segment=TestShadowRepoRegularExpression.shadow_segment)
286 316 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
287 317 controller._name = shadow_url
288 318 controller.set_repo_names({})
289 319
290 320 # Get file system path to shadow repo for assertions.
291 321 workspace_id = PullRequestModel()._workspace_id(pull_request)
292 322 target_vcs = pull_request.target_repo.scm_instance()
293 323 vcs_repo_name = target_vcs._get_shadow_repository_path(
294 324 workspace_id)
295 325
296 326 assert controller.vcs_repo_name == vcs_repo_name
297 327 assert controller.url_repo_name == shadow_url
298 328 assert controller.acl_repo_name == pull_request.target_repo.repo_name
299 329 assert controller.is_shadow_repo
300 330
301 331 def test_set_repo_names_with_shadow_but_missing_pr(
302 332 self, pylonsapp, pr_util, config_stub):
303 333 """
304 334 Checks that the set_repo_names method enforces matching target repos
305 335 and pull request IDs.
306 336 """
307 337 pull_request = pr_util.create_pull_request()
308 338 shadow_url = '{target}/{pr_segment}/{pr_id}/{shadow_segment}'.format(
309 339 target=pull_request.target_repo.repo_name,
310 340 pr_id=999999999,
311 341 pr_segment=TestShadowRepoRegularExpression.pr_segment,
312 342 shadow_segment=TestShadowRepoRegularExpression.shadow_segment)
313 343 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
314 344 controller._name = shadow_url
315 345 controller.set_repo_names({})
316 346
317 347 assert not controller.is_shadow_repo
318 348 assert (controller.url_repo_name ==
319 349 controller.acl_repo_name ==
320 350 controller.vcs_repo_name)
321 351
322 352
323 353 @pytest.mark.usefixtures('db')
324 354 class TestGenerateVcsResponse(object):
325 355
326 356 def test_ensures_that_start_response_is_called_early_enough(self):
327 357 self.call_controller_with_response_body(iter(['a', 'b']))
328 358 assert self.start_response.called
329 359
330 360 def test_invalidates_cache_after_body_is_consumed(self):
331 361 result = self.call_controller_with_response_body(iter(['a', 'b']))
332 362 assert not self.was_cache_invalidated()
333 363 # Consume the result
334 364 list(result)
335 365 assert self.was_cache_invalidated()
336 366
337 367 @mock.patch('rhodecode.lib.middleware.simplevcs.HTTPLockedRC')
338 368 def test_handles_locking_exception(self, http_locked_rc):
339 369 result = self.call_controller_with_response_body(
340 370 self.raise_result_iter(vcs_kind='repo_locked'))
341 371 assert not http_locked_rc.called
342 372 # Consume the result
343 373 list(result)
344 374 assert http_locked_rc.called
345 375
346 376 @mock.patch('rhodecode.lib.middleware.simplevcs.HTTPRequirementError')
347 377 def test_handles_requirement_exception(self, http_requirement):
348 378 result = self.call_controller_with_response_body(
349 379 self.raise_result_iter(vcs_kind='requirement'))
350 380 assert not http_requirement.called
351 381 # Consume the result
352 382 list(result)
353 383 assert http_requirement.called
354 384
355 385 @mock.patch('rhodecode.lib.middleware.simplevcs.HTTPLockedRC')
356 386 def test_handles_locking_exception_in_app_call(self, http_locked_rc):
357 387 app_factory_patcher = mock.patch.object(
358 388 StubVCSController, '_create_wsgi_app')
359 389 with app_factory_patcher as app_factory:
360 390 app_factory().side_effect = self.vcs_exception()
361 391 result = self.call_controller_with_response_body(['a'])
362 392 list(result)
363 393 assert http_locked_rc.called
364 394
365 395 def test_raises_unknown_exceptions(self):
366 396 result = self.call_controller_with_response_body(
367 397 self.raise_result_iter(vcs_kind='unknown'))
368 398 with pytest.raises(Exception):
369 399 list(result)
370 400
371 401 def test_prepare_callback_daemon_is_called(self):
372 402 def side_effect(extras):
373 403 return DummyHooksCallbackDaemon(), extras
374 404
375 405 prepare_patcher = mock.patch.object(
376 406 StubVCSController, '_prepare_callback_daemon')
377 407 with prepare_patcher as prepare_mock:
378 408 prepare_mock.side_effect = side_effect
379 409 self.call_controller_with_response_body(iter(['a', 'b']))
380 410 assert prepare_mock.called
381 411 assert prepare_mock.call_count == 1
382 412
383 413 def call_controller_with_response_body(self, response_body):
384 414 settings = {
385 415 'base_path': 'fake_base_path',
386 416 'vcs.hooks.protocol': 'http',
387 417 'vcs.hooks.direct_calls': False,
388 418 }
389 419 controller = StubVCSController(None, settings, None)
390 420 controller._invalidate_cache = mock.Mock()
391 421 controller.stub_response_body = response_body
392 422 self.start_response = mock.Mock()
393 423 result = controller._generate_vcs_response(
394 424 environ={}, start_response=self.start_response,
395 425 repo_path='fake_repo_path',
396 426 extras={}, action='push')
397 427 self.controller = controller
398 428 return result
399 429
400 430 def raise_result_iter(self, vcs_kind='repo_locked'):
401 431 """
402 432 Simulates an exception due to a vcs raised exception if kind vcs_kind
403 433 """
404 434 raise self.vcs_exception(vcs_kind=vcs_kind)
405 435 yield "never_reached"
406 436
407 437 def vcs_exception(self, vcs_kind='repo_locked'):
408 438 locked_exception = Exception('TEST_MESSAGE')
409 439 locked_exception._vcs_kind = vcs_kind
410 440 return locked_exception
411 441
412 442 def was_cache_invalidated(self):
413 443 return self.controller._invalidate_cache.called
414 444
415 445
416 446 class TestInitializeGenerator(object):
417 447
418 448 def test_drains_first_element(self):
419 449 gen = self.factory(['__init__', 1, 2])
420 450 result = list(gen)
421 451 assert result == [1, 2]
422 452
423 453 @pytest.mark.parametrize('values', [
424 454 [],
425 455 [1, 2],
426 456 ])
427 457 def test_raises_value_error(self, values):
428 458 with pytest.raises(ValueError):
429 459 self.factory(values)
430 460
431 461 @simplevcs.initialize_generator
432 462 def factory(self, iterable):
433 463 for elem in iterable:
434 464 yield elem
435 465
436 466
437 467 class TestPrepareHooksDaemon(object):
438 468 def test_calls_imported_prepare_callback_daemon(self, app_settings):
439 469 expected_extras = {'extra1': 'value1'}
440 470 daemon = DummyHooksCallbackDaemon()
441 471
442 472 controller = StubVCSController(None, app_settings, None)
443 473 prepare_patcher = mock.patch.object(
444 474 simplevcs, 'prepare_callback_daemon',
445 475 return_value=(daemon, expected_extras))
446 476 with prepare_patcher as prepare_mock:
447 477 callback_daemon, extras = controller._prepare_callback_daemon(
448 478 expected_extras.copy())
449 479 prepare_mock.assert_called_once_with(
450 480 expected_extras,
451 481 protocol=app_settings['vcs.hooks.protocol'],
452 482 use_direct_calls=app_settings['vcs.hooks.direct_calls'])
453 483
454 484 assert callback_daemon == daemon
455 485 assert extras == extras
General Comments 0
You need to be logged in to leave comments. Login now