##// END OF EJS Templates
hooks: expose user agent in the variables submitted to pull/push hooks.
marcink -
r1710:7173cb6f default
parent child Browse files
Show More
@@ -1,589 +1,594 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 ipaddress
31 31 import pyramid.threadlocal
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 from pylons import config, tmpl_context as c, request, session, url
37 37 from pylons.controllers import WSGIController
38 38 from pylons.controllers.util import redirect
39 39 from pylons.i18n import translation
40 40 # marcink: don't remove this import
41 41 from pylons.templating import render_mako as render # noqa
42 42 from pylons.i18n.translation import _
43 43 from webob.exc import HTTPFound
44 44
45 45
46 46 import rhodecode
47 47 from rhodecode.authentication.base import VCS_TYPE
48 48 from rhodecode.lib import auth, utils2
49 49 from rhodecode.lib import helpers as h
50 50 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
51 51 from rhodecode.lib.exceptions import UserCreationError
52 52 from rhodecode.lib.utils import (
53 53 get_repo_slug, set_rhodecode_config, password_changed,
54 54 get_enabled_hook_classes)
55 55 from rhodecode.lib.utils2 import (
56 56 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist)
57 57 from rhodecode.lib.vcs.exceptions import RepositoryRequirementError
58 58 from rhodecode.model import meta
59 59 from rhodecode.model.db import Repository, User, ChangesetComment
60 60 from rhodecode.model.notification import NotificationModel
61 61 from rhodecode.model.scm import ScmModel
62 62 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
63 63
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 def _filter_proxy(ip):
69 69 """
70 70 Passed in IP addresses in HEADERS can be in a special format of multiple
71 71 ips. Those comma separated IPs are passed from various proxies in the
72 72 chain of request processing. The left-most being the original client.
73 73 We only care about the first IP which came from the org. client.
74 74
75 75 :param ip: ip string from headers
76 76 """
77 77 if ',' in ip:
78 78 _ips = ip.split(',')
79 79 _first_ip = _ips[0].strip()
80 80 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
81 81 return _first_ip
82 82 return ip
83 83
84 84
85 85 def _filter_port(ip):
86 86 """
87 87 Removes a port from ip, there are 4 main cases to handle here.
88 88 - ipv4 eg. 127.0.0.1
89 89 - ipv6 eg. ::1
90 90 - ipv4+port eg. 127.0.0.1:8080
91 91 - ipv6+port eg. [::1]:8080
92 92
93 93 :param ip:
94 94 """
95 95 def is_ipv6(ip_addr):
96 96 if hasattr(socket, 'inet_pton'):
97 97 try:
98 98 socket.inet_pton(socket.AF_INET6, ip_addr)
99 99 except socket.error:
100 100 return False
101 101 else:
102 102 # fallback to ipaddress
103 103 try:
104 104 ipaddress.IPv6Address(ip_addr)
105 105 except Exception:
106 106 return False
107 107 return True
108 108
109 109 if ':' not in ip: # must be ipv4 pure ip
110 110 return ip
111 111
112 112 if '[' in ip and ']' in ip: # ipv6 with port
113 113 return ip.split(']')[0][1:].lower()
114 114
115 115 # must be ipv6 or ipv4 with port
116 116 if is_ipv6(ip):
117 117 return ip
118 118 else:
119 119 ip, _port = ip.split(':')[:2] # means ipv4+port
120 120 return ip
121 121
122 122
123 123 def get_ip_addr(environ):
124 124 proxy_key = 'HTTP_X_REAL_IP'
125 125 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
126 126 def_key = 'REMOTE_ADDR'
127 127 _filters = lambda x: _filter_port(_filter_proxy(x))
128 128
129 129 ip = environ.get(proxy_key)
130 130 if ip:
131 131 return _filters(ip)
132 132
133 133 ip = environ.get(proxy_key2)
134 134 if ip:
135 135 return _filters(ip)
136 136
137 137 ip = environ.get(def_key, '0.0.0.0')
138 138 return _filters(ip)
139 139
140 140
141 141 def get_server_ip_addr(environ, log_errors=True):
142 142 hostname = environ.get('SERVER_NAME')
143 143 try:
144 144 return socket.gethostbyname(hostname)
145 145 except Exception as e:
146 146 if log_errors:
147 147 # in some cases this lookup is not possible, and we don't want to
148 148 # make it an exception in logs
149 149 log.exception('Could not retrieve server ip address: %s', e)
150 150 return hostname
151 151
152 152
153 153 def get_server_port(environ):
154 154 return environ.get('SERVER_PORT')
155 155
156 156
157 157 def get_access_path(environ):
158 158 path = environ.get('PATH_INFO')
159 159 org_req = environ.get('pylons.original_request')
160 160 if org_req:
161 161 path = org_req.environ.get('PATH_INFO')
162 162 return path
163 163
164 164
165 def get_user_agent(environ):
166 return environ.get('HTTP_USER_AGENT')
167
168
165 169 def vcs_operation_context(
166 170 environ, repo_name, username, action, scm, check_locking=True,
167 171 is_shadow_repo=False):
168 172 """
169 173 Generate the context for a vcs operation, e.g. push or pull.
170 174
171 175 This context is passed over the layers so that hooks triggered by the
172 176 vcs operation know details like the user, the user's IP address etc.
173 177
174 178 :param check_locking: Allows to switch of the computation of the locking
175 179 data. This serves mainly the need of the simplevcs middleware to be
176 180 able to disable this for certain operations.
177 181
178 182 """
179 183 # Tri-state value: False: unlock, None: nothing, True: lock
180 184 make_lock = None
181 185 locked_by = [None, None, None]
182 186 is_anonymous = username == User.DEFAULT_USER
183 187 if not is_anonymous and check_locking:
184 188 log.debug('Checking locking on repository "%s"', repo_name)
185 189 user = User.get_by_username(username)
186 190 repo = Repository.get_by_repo_name(repo_name)
187 191 make_lock, __, locked_by = repo.get_locking_state(
188 192 action, user.user_id)
189 193
190 194 settings_model = VcsSettingsModel(repo=repo_name)
191 195 ui_settings = settings_model.get_ui_settings()
192 196
193 197 extras = {
194 198 'ip': get_ip_addr(environ),
195 199 'username': username,
196 200 'action': action,
197 201 'repository': repo_name,
198 202 'scm': scm,
199 203 'config': rhodecode.CONFIG['__file__'],
200 204 'make_lock': make_lock,
201 205 'locked_by': locked_by,
202 206 'server_url': utils2.get_server_url(environ),
207 'user_agent': get_user_agent(environ),
203 208 'hooks': get_enabled_hook_classes(ui_settings),
204 209 'is_shadow_repo': is_shadow_repo,
205 210 }
206 211 return extras
207 212
208 213
209 214 class BasicAuth(AuthBasicAuthenticator):
210 215
211 216 def __init__(self, realm, authfunc, registry, auth_http_code=None,
212 217 initial_call_detection=False, acl_repo_name=None):
213 218 self.realm = realm
214 219 self.initial_call = initial_call_detection
215 220 self.authfunc = authfunc
216 221 self.registry = registry
217 222 self.acl_repo_name = acl_repo_name
218 223 self._rc_auth_http_code = auth_http_code
219 224
220 225 def _get_response_from_code(self, http_code):
221 226 try:
222 227 return get_exception(safe_int(http_code))
223 228 except Exception:
224 229 log.exception('Failed to fetch response for code %s' % http_code)
225 230 return HTTPForbidden
226 231
227 232 def build_authentication(self):
228 233 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
229 234 if self._rc_auth_http_code and not self.initial_call:
230 235 # return alternative HTTP code if alternative http return code
231 236 # is specified in RhodeCode config, but ONLY if it's not the
232 237 # FIRST call
233 238 custom_response_klass = self._get_response_from_code(
234 239 self._rc_auth_http_code)
235 240 return custom_response_klass(headers=head)
236 241 return HTTPUnauthorized(headers=head)
237 242
238 243 def authenticate(self, environ):
239 244 authorization = AUTHORIZATION(environ)
240 245 if not authorization:
241 246 return self.build_authentication()
242 247 (authmeth, auth) = authorization.split(' ', 1)
243 248 if 'basic' != authmeth.lower():
244 249 return self.build_authentication()
245 250 auth = auth.strip().decode('base64')
246 251 _parts = auth.split(':', 1)
247 252 if len(_parts) == 2:
248 253 username, password = _parts
249 254 if self.authfunc(
250 255 username, password, environ, VCS_TYPE,
251 256 registry=self.registry, acl_repo_name=self.acl_repo_name):
252 257 return username
253 258 if username and password:
254 259 # we mark that we actually executed authentication once, at
255 260 # that point we can use the alternative auth code
256 261 self.initial_call = False
257 262
258 263 return self.build_authentication()
259 264
260 265 __call__ = authenticate
261 266
262 267
263 268 def attach_context_attributes(context, request):
264 269 """
265 270 Attach variables into template context called `c`, please note that
266 271 request could be pylons or pyramid request in here.
267 272 """
268 273 rc_config = SettingsModel().get_all_settings(cache=True)
269 274
270 275 context.rhodecode_version = rhodecode.__version__
271 276 context.rhodecode_edition = config.get('rhodecode.edition')
272 277 # unique secret + version does not leak the version but keep consistency
273 278 context.rhodecode_version_hash = md5(
274 279 config.get('beaker.session.secret', '') +
275 280 rhodecode.__version__)[:8]
276 281
277 282 # Default language set for the incoming request
278 283 context.language = translation.get_lang()[0]
279 284
280 285 # Visual options
281 286 context.visual = AttributeDict({})
282 287
283 288 # DB stored Visual Items
284 289 context.visual.show_public_icon = str2bool(
285 290 rc_config.get('rhodecode_show_public_icon'))
286 291 context.visual.show_private_icon = str2bool(
287 292 rc_config.get('rhodecode_show_private_icon'))
288 293 context.visual.stylify_metatags = str2bool(
289 294 rc_config.get('rhodecode_stylify_metatags'))
290 295 context.visual.dashboard_items = safe_int(
291 296 rc_config.get('rhodecode_dashboard_items', 100))
292 297 context.visual.admin_grid_items = safe_int(
293 298 rc_config.get('rhodecode_admin_grid_items', 100))
294 299 context.visual.repository_fields = str2bool(
295 300 rc_config.get('rhodecode_repository_fields'))
296 301 context.visual.show_version = str2bool(
297 302 rc_config.get('rhodecode_show_version'))
298 303 context.visual.use_gravatar = str2bool(
299 304 rc_config.get('rhodecode_use_gravatar'))
300 305 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
301 306 context.visual.default_renderer = rc_config.get(
302 307 'rhodecode_markup_renderer', 'rst')
303 308 context.visual.comment_types = ChangesetComment.COMMENT_TYPES
304 309 context.visual.rhodecode_support_url = \
305 310 rc_config.get('rhodecode_support_url') or h.route_url('rhodecode_support')
306 311
307 312 context.pre_code = rc_config.get('rhodecode_pre_code')
308 313 context.post_code = rc_config.get('rhodecode_post_code')
309 314 context.rhodecode_name = rc_config.get('rhodecode_title')
310 315 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
311 316 # if we have specified default_encoding in the request, it has more
312 317 # priority
313 318 if request.GET.get('default_encoding'):
314 319 context.default_encodings.insert(0, request.GET.get('default_encoding'))
315 320 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
316 321
317 322 # INI stored
318 323 context.labs_active = str2bool(
319 324 config.get('labs_settings_active', 'false'))
320 325 context.visual.allow_repo_location_change = str2bool(
321 326 config.get('allow_repo_location_change', True))
322 327 context.visual.allow_custom_hooks_settings = str2bool(
323 328 config.get('allow_custom_hooks_settings', True))
324 329 context.debug_style = str2bool(config.get('debug_style', False))
325 330
326 331 context.rhodecode_instanceid = config.get('instance_id')
327 332
328 333 # AppEnlight
329 334 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
330 335 context.appenlight_api_public_key = config.get(
331 336 'appenlight.api_public_key', '')
332 337 context.appenlight_server_url = config.get('appenlight.server_url', '')
333 338
334 339 # JS template context
335 340 context.template_context = {
336 341 'repo_name': None,
337 342 'repo_type': None,
338 343 'repo_landing_commit': None,
339 344 'rhodecode_user': {
340 345 'username': None,
341 346 'email': None,
342 347 'notification_status': False
343 348 },
344 349 'visual': {
345 350 'default_renderer': None
346 351 },
347 352 'commit_data': {
348 353 'commit_id': None
349 354 },
350 355 'pull_request_data': {'pull_request_id': None},
351 356 'timeago': {
352 357 'refresh_time': 120 * 1000,
353 358 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
354 359 },
355 360 'pylons_dispatch': {
356 361 # 'controller': request.environ['pylons.routes_dict']['controller'],
357 362 # 'action': request.environ['pylons.routes_dict']['action'],
358 363 },
359 364 'pyramid_dispatch': {
360 365
361 366 },
362 367 'extra': {'plugins': {}}
363 368 }
364 369 # END CONFIG VARS
365 370
366 371 # TODO: This dosn't work when called from pylons compatibility tween.
367 372 # Fix this and remove it from base controller.
368 373 # context.repo_name = get_repo_slug(request) # can be empty
369 374
370 375 diffmode = 'sideside'
371 376 if request.GET.get('diffmode'):
372 377 if request.GET['diffmode'] == 'unified':
373 378 diffmode = 'unified'
374 379 elif request.session.get('diffmode'):
375 380 diffmode = request.session['diffmode']
376 381
377 382 context.diffmode = diffmode
378 383
379 384 if request.session.get('diffmode') != diffmode:
380 385 request.session['diffmode'] = diffmode
381 386
382 387 context.csrf_token = auth.get_csrf_token()
383 388 context.backends = rhodecode.BACKENDS.keys()
384 389 context.backends.sort()
385 390 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(
386 391 context.rhodecode_user.user_id)
387 392
388 393 context.pyramid_request = pyramid.threadlocal.get_current_request()
389 394
390 395
391 396 def get_auth_user(environ):
392 397 ip_addr = get_ip_addr(environ)
393 398 # make sure that we update permissions each time we call controller
394 399 _auth_token = (request.GET.get('auth_token', '') or
395 400 request.GET.get('api_key', ''))
396 401
397 402 if _auth_token:
398 403 # when using API_KEY we assume user exists, and
399 404 # doesn't need auth based on cookies.
400 405 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
401 406 authenticated = False
402 407 else:
403 408 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
404 409 try:
405 410 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
406 411 ip_addr=ip_addr)
407 412 except UserCreationError as e:
408 413 h.flash(e, 'error')
409 414 # container auth or other auth functions that create users
410 415 # on the fly can throw this exception signaling that there's
411 416 # issue with user creation, explanation should be provided
412 417 # in Exception itself. We then create a simple blank
413 418 # AuthUser
414 419 auth_user = AuthUser(ip_addr=ip_addr)
415 420
416 421 if password_changed(auth_user, session):
417 422 session.invalidate()
418 423 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
419 424 auth_user = AuthUser(ip_addr=ip_addr)
420 425
421 426 authenticated = cookie_store.get('is_authenticated')
422 427
423 428 if not auth_user.is_authenticated and auth_user.is_user_object:
424 429 # user is not authenticated and not empty
425 430 auth_user.set_authenticated(authenticated)
426 431
427 432 return auth_user
428 433
429 434
430 435 class BaseController(WSGIController):
431 436
432 437 def __before__(self):
433 438 """
434 439 __before__ is called before controller methods and after __call__
435 440 """
436 441 # on each call propagate settings calls into global settings.
437 442 set_rhodecode_config(config)
438 443 attach_context_attributes(c, request)
439 444
440 445 # TODO: Remove this when fixed in attach_context_attributes()
441 446 c.repo_name = get_repo_slug(request) # can be empty
442 447
443 448 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
444 449 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
445 450 self.sa = meta.Session
446 451 self.scm_model = ScmModel(self.sa)
447 452
448 453 # set user language
449 454 user_lang = getattr(c.pyramid_request, '_LOCALE_', None)
450 455 if user_lang:
451 456 translation.set_lang(user_lang)
452 457 log.debug('set language to %s for user %s',
453 458 user_lang, self._rhodecode_user)
454 459
455 460 def _dispatch_redirect(self, with_url, environ, start_response):
456 461 resp = HTTPFound(with_url)
457 462 environ['SCRIPT_NAME'] = '' # handle prefix middleware
458 463 environ['PATH_INFO'] = with_url
459 464 return resp(environ, start_response)
460 465
461 466 def __call__(self, environ, start_response):
462 467 """Invoke the Controller"""
463 468 # WSGIController.__call__ dispatches to the Controller method
464 469 # the request is routed to. This routing information is
465 470 # available in environ['pylons.routes_dict']
466 471 from rhodecode.lib import helpers as h
467 472
468 473 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
469 474 if environ.get('debugtoolbar.wants_pylons_context', False):
470 475 environ['debugtoolbar.pylons_context'] = c._current_obj()
471 476
472 477 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
473 478 environ['pylons.routes_dict']['action']])
474 479
475 480 self.rc_config = SettingsModel().get_all_settings(cache=True)
476 481 self.ip_addr = get_ip_addr(environ)
477 482
478 483 # The rhodecode auth user is looked up and passed through the
479 484 # environ by the pylons compatibility tween in pyramid.
480 485 # So we can just grab it from there.
481 486 auth_user = environ['rc_auth_user']
482 487
483 488 # set globals for auth user
484 489 request.user = auth_user
485 490 c.rhodecode_user = self._rhodecode_user = auth_user
486 491
487 492 log.info('IP: %s User: %s accessed %s [%s]' % (
488 493 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
489 494 _route_name)
490 495 )
491 496
492 497 user_obj = auth_user.get_instance()
493 498 if user_obj and user_obj.user_data.get('force_password_change'):
494 499 h.flash('You are required to change your password', 'warning',
495 500 ignore_duplicate=True)
496 501 return self._dispatch_redirect(
497 502 url('my_account_password'), environ, start_response)
498 503
499 504 return WSGIController.__call__(self, environ, start_response)
500 505
501 506
502 507 class BaseRepoController(BaseController):
503 508 """
504 509 Base class for controllers responsible for loading all needed data for
505 510 repository loaded items are
506 511
507 512 c.rhodecode_repo: instance of scm repository
508 513 c.rhodecode_db_repo: instance of db
509 514 c.repository_requirements_missing: shows that repository specific data
510 515 could not be displayed due to the missing requirements
511 516 c.repository_pull_requests: show number of open pull requests
512 517 """
513 518
514 519 def __before__(self):
515 520 super(BaseRepoController, self).__before__()
516 521 if c.repo_name: # extracted from routes
517 522 db_repo = Repository.get_by_repo_name(c.repo_name)
518 523 if not db_repo:
519 524 return
520 525
521 526 log.debug(
522 527 'Found repository in database %s with state `%s`',
523 528 safe_unicode(db_repo), safe_unicode(db_repo.repo_state))
524 529 route = getattr(request.environ.get('routes.route'), 'name', '')
525 530
526 531 # allow to delete repos that are somehow damages in filesystem
527 532 if route in ['delete_repo']:
528 533 return
529 534
530 535 if db_repo.repo_state in [Repository.STATE_PENDING]:
531 536 if route in ['repo_creating_home']:
532 537 return
533 538 check_url = url('repo_creating_home', repo_name=c.repo_name)
534 539 return redirect(check_url)
535 540
536 541 self.rhodecode_db_repo = db_repo
537 542
538 543 missing_requirements = False
539 544 try:
540 545 self.rhodecode_repo = self.rhodecode_db_repo.scm_instance()
541 546 except RepositoryRequirementError as e:
542 547 missing_requirements = True
543 548 self._handle_missing_requirements(e)
544 549
545 550 if self.rhodecode_repo is None and not missing_requirements:
546 551 log.error('%s this repository is present in database but it '
547 552 'cannot be created as an scm instance', c.repo_name)
548 553
549 554 h.flash(_(
550 555 "The repository at %(repo_name)s cannot be located.") %
551 556 {'repo_name': c.repo_name},
552 557 category='error', ignore_duplicate=True)
553 558 redirect(url('home'))
554 559
555 560 # update last change according to VCS data
556 561 if not missing_requirements:
557 562 commit = db_repo.get_commit(
558 563 pre_load=["author", "date", "message", "parents"])
559 564 db_repo.update_commit_cache(commit)
560 565
561 566 # Prepare context
562 567 c.rhodecode_db_repo = db_repo
563 568 c.rhodecode_repo = self.rhodecode_repo
564 569 c.repository_requirements_missing = missing_requirements
565 570
566 571 self._update_global_counters(self.scm_model, db_repo)
567 572
568 573 def _update_global_counters(self, scm_model, db_repo):
569 574 """
570 575 Base variables that are exposed to every page of repository
571 576 """
572 577 c.repository_pull_requests = scm_model.get_pull_requests(db_repo)
573 578
574 579 def _handle_missing_requirements(self, error):
575 580 self.rhodecode_repo = None
576 581 log.error(
577 582 'Requirements are missing for repository %s: %s',
578 583 c.repo_name, error.message)
579 584
580 585 summary_url = url('summary_home', repo_name=c.repo_name)
581 586 statistics_url = url('edit_repo_statistics', repo_name=c.repo_name)
582 587 settings_update_url = url('repo', repo_name=c.repo_name)
583 588 path = request.path
584 589 should_redirect = (
585 590 path not in (summary_url, settings_update_url)
586 591 and '/settings' not in path or path == statistics_url
587 592 )
588 593 if should_redirect:
589 594 redirect(summary_url)
@@ -1,117 +1,118 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 40 'action': '',
41 41 'repository': repo_stub.repo_name,
42 42 'scm': repo_stub.scm_instance().alias,
43 43 'config': '',
44 44 'server_url': 'http://example.com',
45 45 'make_lock': None,
46 'user-agent': 'some-client',
46 47 'locked_by': [None],
47 48 'commit_ids': ['a' * 40] * 3,
48 49 'is_shadow_repo': False,
49 50 })
50 51 return extras
51 52
52 53
53 54 # TODO: dan: make the serialization tests complete json comparisons
54 55 @pytest.mark.parametrize('EventClass', [
55 56 RepoPreCreateEvent, RepoCreateEvent,
56 57 RepoPreDeleteEvent, RepoDeleteEvent,
57 58 ])
58 59 def test_repo_events_serialized(repo_stub, EventClass):
59 60 event = EventClass(repo_stub)
60 61 data = event.as_dict()
61 62 assert data['name'] == EventClass.name
62 63 assert data['repo']['repo_name'] == repo_stub.repo_name
63 64 assert data['repo']['url']
64 65
65 66
66 67 @pytest.mark.parametrize('EventClass', [
67 68 RepoPrePullEvent, RepoPullEvent, RepoPrePushEvent
68 69 ])
69 70 def test_vcs_repo_events_serialize(repo_stub, scm_extras, EventClass):
70 71 event = EventClass(repo_name=repo_stub.repo_name, extras=scm_extras)
71 72 data = event.as_dict()
72 73 assert data['name'] == EventClass.name
73 74 assert data['repo']['repo_name'] == repo_stub.repo_name
74 75 assert data['repo']['url']
75 76
76 77
77 78
78 79 @pytest.mark.parametrize('EventClass', [RepoPushEvent])
79 80 def test_vcs_repo_push_event_serialize(repo_stub, scm_extras, EventClass):
80 81 event = EventClass(repo_name=repo_stub.repo_name,
81 82 pushed_commit_ids=scm_extras['commit_ids'],
82 83 extras=scm_extras)
83 84 data = event.as_dict()
84 85 assert data['name'] == EventClass.name
85 86 assert data['repo']['repo_name'] == repo_stub.repo_name
86 87 assert data['repo']['url']
87 88
88 89
89 90 def test_create_delete_repo_fires_events(backend):
90 91 with EventCatcher() as event_catcher:
91 92 repo = backend.create_repo()
92 93 assert event_catcher.events_types == [RepoPreCreateEvent, RepoCreateEvent]
93 94
94 95 with EventCatcher() as event_catcher:
95 96 RepoModel().delete(repo)
96 97 assert event_catcher.events_types == [RepoPreDeleteEvent, RepoDeleteEvent]
97 98
98 99
99 100 def test_pull_fires_events(scm_extras):
100 101 with EventCatcher() as event_catcher:
101 102 hooks_base.pre_push(scm_extras)
102 103 assert event_catcher.events_types == [RepoPrePushEvent]
103 104
104 105 with EventCatcher() as event_catcher:
105 106 hooks_base.post_push(scm_extras)
106 107 assert event_catcher.events_types == [RepoPushEvent]
107 108
108 109
109 110 def test_push_fires_events(scm_extras):
110 111 with EventCatcher() as event_catcher:
111 112 hooks_base.pre_pull(scm_extras)
112 113 assert event_catcher.events_types == [RepoPrePullEvent]
113 114
114 115 with EventCatcher() as event_catcher:
115 116 hooks_base.post_pull(scm_extras)
116 117 assert event_catcher.events_types == [RepoPullEvent]
117 118
@@ -1,253 +1,255 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 from pylons import url
24 24
25 25 from rhodecode.lib import base
26 26 from rhodecode.lib.vcs.exceptions import RepositoryRequirementError
27 27 from rhodecode.model import db
28 28
29 29
30 30 @pytest.mark.parametrize('result_key, expected_value', [
31 31 ('username', 'stub_username'),
32 32 ('action', 'stub_action'),
33 33 ('repository', 'stub_repo_name'),
34 34 ('scm', 'stub_scm'),
35 35 ('hooks', ['stub_hook']),
36 36 ('config', 'stub_ini_filename'),
37 ('ip', 'fake_ip'),
37 ('ip', '1.2.3.4'),
38 38 ('server_url', 'https://example.com'),
39 ('user_agent', 'client-text-v1.1'),
39 40 # TODO: johbo: Commpare locking parameters with `_get_rc_scm_extras`
40 41 # in hooks_utils.
41 42 ('make_lock', None),
42 43 ('locked_by', [None, None, None]),
43 44 ])
44 45 def test_vcs_operation_context_parameters(result_key, expected_value):
45 46 result = call_vcs_operation_context()
46 47 assert result[result_key] == expected_value
47 48
48 49
49 50 @patch('rhodecode.model.db.User.get_by_username', Mock())
50 51 @patch('rhodecode.model.db.Repository.get_by_repo_name')
51 52 def test_vcs_operation_context_checks_locking(mock_get_by_repo_name):
52 53 mock_get_locking_state = mock_get_by_repo_name().get_locking_state
53 54 mock_get_locking_state.return_value = (None, None, [None, None, None])
54 55 call_vcs_operation_context(check_locking=True)
55 56 assert mock_get_locking_state.called
56 57
57 58
58 59 @patch('rhodecode.model.db.Repository.get_locking_state')
59 60 def test_vcs_operation_context_skips_locking_checks_if_anonymouse(
60 61 mock_get_locking_state):
61 62 call_vcs_operation_context(
62 63 username=db.User.DEFAULT_USER, check_locking=True)
63 64 assert not mock_get_locking_state.called
64 65
65 66
66 67 @patch('rhodecode.model.db.Repository.get_locking_state')
67 68 def test_vcs_operation_context_can_skip_locking_check(mock_get_locking_state):
68 69 call_vcs_operation_context(check_locking=False)
69 70 assert not mock_get_locking_state.called
70 71
71 72
72 73 @patch.object(
73 74 base, 'get_enabled_hook_classes', Mock(return_value=['stub_hook']))
74 @patch.object(base, 'get_ip_addr', Mock(return_value="fake_ip"))
75 75 @patch('rhodecode.lib.utils2.get_server_url',
76 76 Mock(return_value='https://example.com'))
77 77 def call_vcs_operation_context(**kwargs_override):
78 78 kwargs = {
79 79 'repo_name': 'stub_repo_name',
80 80 'username': 'stub_username',
81 81 'action': 'stub_action',
82 82 'scm': 'stub_scm',
83 83 'check_locking': False,
84 84 }
85 85 kwargs.update(kwargs_override)
86 86 config_file_patch = patch.dict(
87 87 'rhodecode.CONFIG', {'__file__': 'stub_ini_filename'})
88 88 settings_patch = patch.object(base, 'VcsSettingsModel')
89 89 with config_file_patch, settings_patch as settings_mock:
90 result = base.vcs_operation_context(environ={}, **kwargs)
90 result = base.vcs_operation_context(
91 environ={'HTTP_USER_AGENT': 'client-text-v1.1',
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
95 97 class TestBaseRepoController(object):
96 98 def test_context_is_updated_when_update_global_counters_is_called(self):
97 99 followers = 1
98 100 forks = 2
99 101 pull_requests = 3
100 102 is_following = True
101 103 scm_model = Mock(name="scm_model")
102 104 db_repo = Mock(name="db_repo")
103 105 scm_model.get_followers.return_value = followers
104 106 scm_model.get_forks.return_value = forks
105 107 scm_model.get_pull_requests.return_value = pull_requests
106 108 scm_model.is_following_repo.return_value = is_following
107 109
108 110 controller = base.BaseRepoController()
109 111 with patch.object(base, 'c') as context_mock:
110 112 controller._update_global_counters(scm_model, db_repo)
111 113
112 114 scm_model.get_pull_requests.assert_called_once_with(db_repo)
113 115
114 116 assert context_mock.repository_pull_requests == pull_requests
115 117
116 118
117 119 class TestBaseRepoControllerHandleMissingRequirements(object):
118 120 def test_logs_error_and_sets_repo_to_none(self, app):
119 121 controller = base.BaseRepoController()
120 122 error_message = 'Some message'
121 123 error = RepositoryRequirementError(error_message)
122 124 context_patcher = patch.object(base, 'c')
123 125 log_patcher = patch.object(base, 'log')
124 126 request_patcher = patch.object(base, 'request')
125 127 redirect_patcher = patch.object(base, 'redirect')
126 128 controller.rhodecode_repo = 'something'
127 129
128 130 with context_patcher as context_mock, log_patcher as log_mock, \
129 131 request_patcher, redirect_patcher:
130 132 context_mock.repo_name = 'abcde'
131 133 controller._handle_missing_requirements(error)
132 134
133 135 expected_log_message = (
134 136 'Requirements are missing for repository %s: %s', 'abcde',
135 137 error_message)
136 138 log_mock.error.assert_called_once_with(*expected_log_message)
137 139
138 140 assert controller.rhodecode_repo is None
139 141
140 142 @pytest.mark.parametrize('path, should_redirect', [
141 143 ('/abcde', False),
142 144 ('/abcde/settings', False),
143 145 ('/abcde/settings/vcs', False),
144 146 ('/_admin/repos/abcde', False), # Settings update
145 147 ('/abcde/changelog', True),
146 148 ('/abcde/files/tip', True),
147 149 ('/abcde/settings/statistics', True),
148 150 ])
149 151 def test_redirects_if_not_summary_or_settings_page(
150 152 self, app, path, should_redirect):
151 153 repo_name = 'abcde'
152 154 controller = base.BaseRepoController()
153 155 error = RepositoryRequirementError('Some message')
154 156 context_patcher = patch.object(base, 'c')
155 157 controller.rhodecode_repo = repo_name
156 158 request_patcher = patch.object(base, 'request')
157 159 redirect_patcher = patch.object(base, 'redirect')
158 160
159 161 with context_patcher as context_mock, \
160 162 request_patcher as request_mock, \
161 163 redirect_patcher as redirect_mock:
162 164 request_mock.path = path
163 165 context_mock.repo_name = repo_name
164 166 controller._handle_missing_requirements(error)
165 167
166 168 expected_url = url('summary_home', repo_name=repo_name)
167 169 if should_redirect:
168 170 redirect_mock.assert_called_once_with(expected_url)
169 171 else:
170 172 redirect_mock.call_count == 0
171 173
172 174
173 175 class TestBaseRepoControllerBefore(object):
174 176 def test_flag_is_true_when_requirements_are_missing(self, before_mocks):
175 177 controller = self._get_controller()
176 178
177 179 handle_patcher = patch.object(
178 180 controller, '_handle_missing_requirements')
179 181
180 182 error = RepositoryRequirementError()
181 183 before_mocks.repository.scm_instance.side_effect = error
182 184
183 185 with handle_patcher as handle_mock:
184 186 controller.__before__()
185 187
186 188 handle_mock.assert_called_once_with(error)
187 189 assert before_mocks['context'].repository_requirements_missing is True
188 190
189 191 def test_flag_is_false_when_no_requirements_are_missing(
190 192 self, before_mocks):
191 193 controller = self._get_controller()
192 194
193 195 handle_patcher = patch.object(
194 196 controller, '_handle_missing_requirements')
195 197 with handle_patcher as handle_mock:
196 198 controller.__before__()
197 199 handle_mock.call_count == 0
198 200 assert before_mocks['context'].repository_requirements_missing is False
199 201
200 202 def test_update_global_counters_is_called(self, before_mocks):
201 203 controller = self._get_controller()
202 204
203 205 update_counters_patcher = patch.object(
204 206 controller, '_update_global_counters')
205 207
206 208 with update_counters_patcher as update_counters_mock:
207 209 controller.__before__()
208 210 update_counters_mock.assert_called_once_with(
209 211 controller.scm_model, before_mocks.repository)
210 212
211 213 def _get_controller(self):
212 214 controller = base.BaseRepoController()
213 215 controller.scm_model = Mock()
214 216 controller.rhodecode_repo = Mock()
215 217 return controller
216 218
217 219
218 220 @pytest.fixture
219 221 def before_mocks(request):
220 222 patcher = BeforePatcher()
221 223 patcher.start()
222 224 request.addfinalizer(patcher.stop)
223 225 return patcher
224 226
225 227
226 228 class BeforePatcher(object):
227 229 patchers = {}
228 230 mocks = {}
229 231 repository = None
230 232
231 233 def __init__(self):
232 234 self.repository = Mock()
233 235
234 236 def start(self):
235 237 self.patchers = {
236 238 'request': patch.object(base, 'request'),
237 239 'before': patch.object(base.BaseController, '__before__'),
238 240 'context': patch.object(base, 'c'),
239 241 'repo': patch.object(
240 242 base.Repository, 'get_by_repo_name',
241 243 return_value=self.repository)
242 244
243 245 }
244 246 self.mocks = {
245 247 p: self.patchers[p].start() for p in self.patchers
246 248 }
247 249
248 250 def stop(self):
249 251 for patcher in self.patchers.values():
250 252 patcher.stop()
251 253
252 254 def __getitem__(self, key):
253 255 return self.mocks[key]
@@ -1,142 +1,144 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
24 24 from rhodecode.lib import hooks_base, utils2
25 25
26 26
27 27 @mock.patch.multiple(
28 28 hooks_base,
29 29 action_logger=mock.Mock(),
30 30 post_push_extension=mock.Mock(),
31 31 Repository=mock.Mock())
32 32 def test_post_push_truncates_commits(user_regular, repo_stub):
33 33 extras = {
34 34 'ip': '127.0.0.1',
35 35 'username': user_regular.username,
36 36 'action': 'push_local',
37 37 'repository': repo_stub.repo_name,
38 38 'scm': 'git',
39 39 'config': '',
40 40 'server_url': 'http://example.com',
41 41 'make_lock': None,
42 'user_agent': 'some-client',
42 43 'locked_by': [None],
43 44 'commit_ids': ['abcde12345' * 4] * 30000,
44 45 'is_shadow_repo': False,
45 46 }
46 47 extras = utils2.AttributeDict(extras)
47 48
48 49 hooks_base.post_push(extras)
49 50
50 51 # Calculate appropriate action string here
51 52 expected_action = 'push_local:%s' % ','.join(extras.commit_ids[:29000])
52 53
53 54 hooks_base.action_logger.assert_called_with(
54 55 extras.username, expected_action, extras.repository, extras.ip,
55 56 commit=True)
56 57
57 58
58 59 def assert_called_with_mock(callable_, expected_mock_name):
59 60 mock_obj = callable_.call_args[0][0]
60 61 mock_name = mock_obj._mock_new_parent._mock_new_name
61 62 assert mock_name == expected_mock_name
62 63
63 64
64 65 @pytest.fixture
65 66 def hook_extras(user_regular, repo_stub):
66 67 extras = utils2.AttributeDict({
67 68 'ip': '127.0.0.1',
68 69 'username': user_regular.username,
69 70 'action': 'push',
70 71 'repository': repo_stub.repo_name,
71 72 'scm': '',
72 73 'config': '',
73 74 'server_url': 'http://example.com',
74 75 'make_lock': None,
76 'user_agent': 'some-client',
75 77 'locked_by': [None],
76 78 'commit_ids': [],
77 79 'is_shadow_repo': False,
78 80 })
79 81 return extras
80 82
81 83
82 84 @pytest.mark.parametrize('func, extension, event', [
83 85 (hooks_base.pre_push, 'pre_push_extension', 'RepoPrePushEvent'),
84 86 (hooks_base.post_push, 'post_pull_extension', 'RepoPushEvent'),
85 87 (hooks_base.pre_pull, 'pre_pull_extension', 'RepoPrePullEvent'),
86 88 (hooks_base.post_pull, 'post_push_extension', 'RepoPullEvent'),
87 89 ])
88 90 def test_hooks_propagate(func, extension, event, hook_extras):
89 91 """
90 92 Tests that our hook code propagates to rhodecode extensions and triggers
91 93 the appropriate event.
92 94 """
93 95 extension_mock = mock.Mock()
94 96 events_mock = mock.Mock()
95 97 patches = {
96 98 'Repository': mock.Mock(),
97 99 'events': events_mock,
98 100 extension: extension_mock,
99 101 }
100 102
101 103 # Clear shadow repo flag.
102 104 hook_extras.is_shadow_repo = False
103 105
104 106 # Execute hook function.
105 107 with mock.patch.multiple(hooks_base, **patches):
106 108 func(hook_extras)
107 109
108 110 # Assert that extensions are called and event was fired.
109 111 extension_mock.called_once()
110 112 assert_called_with_mock(events_mock.trigger, event)
111 113
112 114
113 115 @pytest.mark.parametrize('func, extension, event', [
114 116 (hooks_base.pre_push, 'pre_push_extension', 'RepoPrePushEvent'),
115 117 (hooks_base.post_push, 'post_pull_extension', 'RepoPushEvent'),
116 118 (hooks_base.pre_pull, 'pre_pull_extension', 'RepoPrePullEvent'),
117 119 (hooks_base.post_pull, 'post_push_extension', 'RepoPullEvent'),
118 120 ])
119 121 def test_hooks_propagates_not_on_shadow(func, extension, event, hook_extras):
120 122 """
121 123 If hooks are called by a request to a shadow repo we only want to run our
122 124 internal hooks code but not external ones like rhodecode extensions or
123 125 trigger an event.
124 126 """
125 127 extension_mock = mock.Mock()
126 128 events_mock = mock.Mock()
127 129 patches = {
128 130 'Repository': mock.Mock(),
129 131 'events': events_mock,
130 132 extension: extension_mock,
131 133 }
132 134
133 135 # Set shadow repo flag.
134 136 hook_extras.is_shadow_repo = True
135 137
136 138 # Execute hook function.
137 139 with mock.patch.multiple(hooks_base, **patches):
138 140 func(hook_extras)
139 141
140 142 # Assert that extensions are *not* called and event was *not* fired.
141 143 assert not extension_mock.called
142 144 assert not events_mock.trigger.called
General Comments 0
You need to be logged in to leave comments. Login now