##// END OF EJS Templates
vcs: reduce sql queries used during pull/push operations.
marcink -
r2140:61a36530 default
parent child Browse files
Show More
@@ -1,697 +1,704 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 Authentication modules
23 23 """
24 24
25 25 import colander
26 26 import copy
27 27 import logging
28 28 import time
29 29 import traceback
30 30 import warnings
31 31 import functools
32 32
33 33 from pyramid.threadlocal import get_current_registry
34 from zope.cachedescriptors.property import Lazy as LazyProperty
34 35
35 36 from rhodecode.authentication.interface import IAuthnPluginRegistry
36 37 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
37 38 from rhodecode.lib import caches
38 39 from rhodecode.lib.auth import PasswordGenerator, _RhodeCodeCryptoBCrypt
39 40 from rhodecode.lib.utils2 import md5_safe, safe_int
40 41 from rhodecode.lib.utils2 import safe_str
41 42 from rhodecode.model.db import User
42 43 from rhodecode.model.meta import Session
43 44 from rhodecode.model.settings import SettingsModel
44 45 from rhodecode.model.user import UserModel
45 46 from rhodecode.model.user_group import UserGroupModel
46 47
47 48
48 49 log = logging.getLogger(__name__)
49 50
50 51 # auth types that authenticate() function can receive
51 52 VCS_TYPE = 'vcs'
52 53 HTTP_TYPE = 'http'
53 54
54 55
55 56 class hybrid_property(object):
56 57 """
57 58 a property decorator that works both for instance and class
58 59 """
59 60 def __init__(self, fget, fset=None, fdel=None, expr=None):
60 61 self.fget = fget
61 62 self.fset = fset
62 63 self.fdel = fdel
63 64 self.expr = expr or fget
64 65 functools.update_wrapper(self, fget)
65 66
66 67 def __get__(self, instance, owner):
67 68 if instance is None:
68 69 return self.expr(owner)
69 70 else:
70 71 return self.fget(instance)
71 72
72 73 def __set__(self, instance, value):
73 74 self.fset(instance, value)
74 75
75 76 def __delete__(self, instance):
76 77 self.fdel(instance)
77 78
78 79
79 80
80 81 class LazyFormencode(object):
81 82 def __init__(self, formencode_obj, *args, **kwargs):
82 83 self.formencode_obj = formencode_obj
83 84 self.args = args
84 85 self.kwargs = kwargs
85 86
86 87 def __call__(self, *args, **kwargs):
87 88 from inspect import isfunction
88 89 formencode_obj = self.formencode_obj
89 90 if isfunction(formencode_obj):
90 91 # case we wrap validators into functions
91 92 formencode_obj = self.formencode_obj(*args, **kwargs)
92 93 return formencode_obj(*self.args, **self.kwargs)
93 94
94 95
95 96 class RhodeCodeAuthPluginBase(object):
96 97 # cache the authentication request for N amount of seconds. Some kind
97 98 # of authentication methods are very heavy and it's very efficient to cache
98 99 # the result of a call. If it's set to None (default) cache is off
99 100 AUTH_CACHE_TTL = None
100 101 AUTH_CACHE = {}
101 102
102 103 auth_func_attrs = {
103 104 "username": "unique username",
104 105 "firstname": "first name",
105 106 "lastname": "last name",
106 107 "email": "email address",
107 108 "groups": '["list", "of", "groups"]',
108 109 "extern_name": "name in external source of record",
109 110 "extern_type": "type of external source of record",
110 111 "admin": 'True|False defines if user should be RhodeCode super admin',
111 112 "active":
112 113 'True|False defines active state of user internally for RhodeCode',
113 114 "active_from_extern":
114 115 "True|False\None, active state from the external auth, "
115 116 "None means use definition from RhodeCode extern_type active value"
116 117 }
117 118 # set on authenticate() method and via set_auth_type func.
118 119 auth_type = None
119 120
120 121 # set on authenticate() method and via set_calling_scope_repo, this is a
121 122 # calling scope repository when doing authentication most likely on VCS
122 123 # operations
123 124 acl_repo_name = None
124 125
125 126 # List of setting names to store encrypted. Plugins may override this list
126 127 # to store settings encrypted.
127 128 _settings_encrypted = []
128 129
129 130 # Mapping of python to DB settings model types. Plugins may override or
130 131 # extend this mapping.
131 132 _settings_type_map = {
132 133 colander.String: 'unicode',
133 134 colander.Integer: 'int',
134 135 colander.Boolean: 'bool',
135 136 colander.List: 'list',
136 137 }
137 138
138 139 # list of keys in settings that are unsafe to be logged, should be passwords
139 140 # or other crucial credentials
140 141 _settings_unsafe_keys = []
141 142
142 143 def __init__(self, plugin_id):
143 144 self._plugin_id = plugin_id
144 145
145 146 def __str__(self):
146 147 return self.get_id()
147 148
148 149 def _get_setting_full_name(self, name):
149 150 """
150 151 Return the full setting name used for storing values in the database.
151 152 """
152 153 # TODO: johbo: Using the name here is problematic. It would be good to
153 154 # introduce either new models in the database to hold Plugin and
154 155 # PluginSetting or to use the plugin id here.
155 156 return 'auth_{}_{}'.format(self.name, name)
156 157
157 158 def _get_setting_type(self, name):
158 159 """
159 160 Return the type of a setting. This type is defined by the SettingsModel
160 161 and determines how the setting is stored in DB. Optionally the suffix
161 162 `.encrypted` is appended to instruct SettingsModel to store it
162 163 encrypted.
163 164 """
164 165 schema_node = self.get_settings_schema().get(name)
165 166 db_type = self._settings_type_map.get(
166 167 type(schema_node.typ), 'unicode')
167 168 if name in self._settings_encrypted:
168 169 db_type = '{}.encrypted'.format(db_type)
169 170 return db_type
170 171
172 @LazyProperty
173 def plugin_settings(self):
174 settings = SettingsModel().get_all_settings()
175 return settings
176
171 177 def is_enabled(self):
172 178 """
173 179 Returns true if this plugin is enabled. An enabled plugin can be
174 180 configured in the admin interface but it is not consulted during
175 181 authentication.
176 182 """
177 183 auth_plugins = SettingsModel().get_auth_plugins()
178 184 return self.get_id() in auth_plugins
179 185
180 186 def is_active(self):
181 187 """
182 188 Returns true if the plugin is activated. An activated plugin is
183 189 consulted during authentication, assumed it is also enabled.
184 190 """
185 191 return self.get_setting_by_name('enabled')
186 192
187 193 def get_id(self):
188 194 """
189 195 Returns the plugin id.
190 196 """
191 197 return self._plugin_id
192 198
193 199 def get_display_name(self):
194 200 """
195 201 Returns a translation string for displaying purposes.
196 202 """
197 203 raise NotImplementedError('Not implemented in base class')
198 204
199 205 def get_settings_schema(self):
200 206 """
201 207 Returns a colander schema, representing the plugin settings.
202 208 """
203 209 return AuthnPluginSettingsSchemaBase()
204 210
205 211 def get_setting_by_name(self, name, default=None):
206 212 """
207 213 Returns a plugin setting by name.
208 214 """
209 full_name = self._get_setting_full_name(name)
210 db_setting = SettingsModel().get_setting_by_name(full_name)
211 return db_setting.app_settings_value if db_setting else default
215 full_name = 'rhodecode_{}'.format(self._get_setting_full_name(name))
216 plugin_settings = self.plugin_settings
217
218 return plugin_settings.get(full_name) or default
212 219
213 220 def create_or_update_setting(self, name, value):
214 221 """
215 222 Create or update a setting for this plugin in the persistent storage.
216 223 """
217 224 full_name = self._get_setting_full_name(name)
218 225 type_ = self._get_setting_type(name)
219 226 db_setting = SettingsModel().create_or_update_setting(
220 227 full_name, value, type_)
221 228 return db_setting.app_settings_value
222 229
223 230 def get_settings(self):
224 231 """
225 232 Returns the plugin settings as dictionary.
226 233 """
227 234 settings = {}
228 235 for node in self.get_settings_schema():
229 236 settings[node.name] = self.get_setting_by_name(node.name)
230 237 return settings
231 238
232 239 def log_safe_settings(self, settings):
233 240 """
234 241 returns a log safe representation of settings, without any secrets
235 242 """
236 243 settings_copy = copy.deepcopy(settings)
237 244 for k in self._settings_unsafe_keys:
238 245 if k in settings_copy:
239 246 del settings_copy[k]
240 247 return settings_copy
241 248
242 249 @property
243 250 def validators(self):
244 251 """
245 252 Exposes RhodeCode validators modules
246 253 """
247 254 # this is a hack to overcome issues with pylons threadlocals and
248 255 # translator object _() not being registered properly.
249 256 class LazyCaller(object):
250 257 def __init__(self, name):
251 258 self.validator_name = name
252 259
253 260 def __call__(self, *args, **kwargs):
254 261 from rhodecode.model import validators as v
255 262 obj = getattr(v, self.validator_name)
256 263 # log.debug('Initializing lazy formencode object: %s', obj)
257 264 return LazyFormencode(obj, *args, **kwargs)
258 265
259 266 class ProxyGet(object):
260 267 def __getattribute__(self, name):
261 268 return LazyCaller(name)
262 269
263 270 return ProxyGet()
264 271
265 272 @hybrid_property
266 273 def name(self):
267 274 """
268 275 Returns the name of this authentication plugin.
269 276
270 277 :returns: string
271 278 """
272 279 raise NotImplementedError("Not implemented in base class")
273 280
274 281 def get_url_slug(self):
275 282 """
276 283 Returns a slug which should be used when constructing URLs which refer
277 284 to this plugin. By default it returns the plugin name. If the name is
278 285 not suitable for using it in an URL the plugin should override this
279 286 method.
280 287 """
281 288 return self.name
282 289
283 290 @property
284 291 def is_headers_auth(self):
285 292 """
286 293 Returns True if this authentication plugin uses HTTP headers as
287 294 authentication method.
288 295 """
289 296 return False
290 297
291 298 @hybrid_property
292 299 def is_container_auth(self):
293 300 """
294 301 Deprecated method that indicates if this authentication plugin uses
295 302 HTTP headers as authentication method.
296 303 """
297 304 warnings.warn(
298 305 'Use is_headers_auth instead.', category=DeprecationWarning)
299 306 return self.is_headers_auth
300 307
301 308 @hybrid_property
302 309 def allows_creating_users(self):
303 310 """
304 311 Defines if Plugin allows users to be created on-the-fly when
305 312 authentication is called. Controls how external plugins should behave
306 313 in terms if they are allowed to create new users, or not. Base plugins
307 314 should not be allowed to, but External ones should be !
308 315
309 316 :return: bool
310 317 """
311 318 return False
312 319
313 320 def set_auth_type(self, auth_type):
314 321 self.auth_type = auth_type
315 322
316 323 def set_calling_scope_repo(self, acl_repo_name):
317 324 self.acl_repo_name = acl_repo_name
318 325
319 326 def allows_authentication_from(
320 327 self, user, allows_non_existing_user=True,
321 328 allowed_auth_plugins=None, allowed_auth_sources=None):
322 329 """
323 330 Checks if this authentication module should accept a request for
324 331 the current user.
325 332
326 333 :param user: user object fetched using plugin's get_user() method.
327 334 :param allows_non_existing_user: if True, don't allow the
328 335 user to be empty, meaning not existing in our database
329 336 :param allowed_auth_plugins: if provided, users extern_type will be
330 337 checked against a list of provided extern types, which are plugin
331 338 auth_names in the end
332 339 :param allowed_auth_sources: authentication type allowed,
333 340 `http` or `vcs` default is both.
334 341 defines if plugin will accept only http authentication vcs
335 342 authentication(git/hg) or both
336 343 :returns: boolean
337 344 """
338 345 if not user and not allows_non_existing_user:
339 346 log.debug('User is empty but plugin does not allow empty users,'
340 347 'not allowed to authenticate')
341 348 return False
342 349
343 350 expected_auth_plugins = allowed_auth_plugins or [self.name]
344 351 if user and (user.extern_type and
345 352 user.extern_type not in expected_auth_plugins):
346 353 log.debug(
347 354 'User `%s` is bound to `%s` auth type. Plugin allows only '
348 355 '%s, skipping', user, user.extern_type, expected_auth_plugins)
349 356
350 357 return False
351 358
352 359 # by default accept both
353 360 expected_auth_from = allowed_auth_sources or [HTTP_TYPE, VCS_TYPE]
354 361 if self.auth_type not in expected_auth_from:
355 362 log.debug('Current auth source is %s but plugin only allows %s',
356 363 self.auth_type, expected_auth_from)
357 364 return False
358 365
359 366 return True
360 367
361 368 def get_user(self, username=None, **kwargs):
362 369 """
363 370 Helper method for user fetching in plugins, by default it's using
364 371 simple fetch by username, but this method can be custimized in plugins
365 372 eg. headers auth plugin to fetch user by environ params
366 373
367 374 :param username: username if given to fetch from database
368 375 :param kwargs: extra arguments needed for user fetching.
369 376 """
370 377 user = None
371 378 log.debug(
372 379 'Trying to fetch user `%s` from RhodeCode database', username)
373 380 if username:
374 381 user = User.get_by_username(username)
375 382 if not user:
376 383 log.debug('User not found, fallback to fetch user in '
377 384 'case insensitive mode')
378 385 user = User.get_by_username(username, case_insensitive=True)
379 386 else:
380 387 log.debug('provided username:`%s` is empty skipping...', username)
381 388 if not user:
382 389 log.debug('User `%s` not found in database', username)
383 390 else:
384 391 log.debug('Got DB user:%s', user)
385 392 return user
386 393
387 394 def user_activation_state(self):
388 395 """
389 396 Defines user activation state when creating new users
390 397
391 398 :returns: boolean
392 399 """
393 400 raise NotImplementedError("Not implemented in base class")
394 401
395 402 def auth(self, userobj, username, passwd, settings, **kwargs):
396 403 """
397 404 Given a user object (which may be null), username, a plaintext
398 405 password, and a settings object (containing all the keys needed as
399 406 listed in settings()), authenticate this user's login attempt.
400 407
401 408 Return None on failure. On success, return a dictionary of the form:
402 409
403 410 see: RhodeCodeAuthPluginBase.auth_func_attrs
404 411 This is later validated for correctness
405 412 """
406 413 raise NotImplementedError("not implemented in base class")
407 414
408 415 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
409 416 """
410 417 Wrapper to call self.auth() that validates call on it
411 418
412 419 :param userobj: userobj
413 420 :param username: username
414 421 :param passwd: plaintext password
415 422 :param settings: plugin settings
416 423 """
417 424 auth = self.auth(userobj, username, passwd, settings, **kwargs)
418 425 if auth:
419 426 # check if hash should be migrated ?
420 427 new_hash = auth.get('_hash_migrate')
421 428 if new_hash:
422 429 self._migrate_hash_to_bcrypt(username, passwd, new_hash)
423 430 return self._validate_auth_return(auth)
424 431 return auth
425 432
426 433 def _migrate_hash_to_bcrypt(self, username, password, new_hash):
427 434 new_hash_cypher = _RhodeCodeCryptoBCrypt()
428 435 # extra checks, so make sure new hash is correct.
429 436 password_encoded = safe_str(password)
430 437 if new_hash and new_hash_cypher.hash_check(
431 438 password_encoded, new_hash):
432 439 cur_user = User.get_by_username(username)
433 440 cur_user.password = new_hash
434 441 Session().add(cur_user)
435 442 Session().flush()
436 443 log.info('Migrated user %s hash to bcrypt', cur_user)
437 444
438 445 def _validate_auth_return(self, ret):
439 446 if not isinstance(ret, dict):
440 447 raise Exception('returned value from auth must be a dict')
441 448 for k in self.auth_func_attrs:
442 449 if k not in ret:
443 450 raise Exception('Missing %s attribute from returned data' % k)
444 451 return ret
445 452
446 453
447 454 class RhodeCodeExternalAuthPlugin(RhodeCodeAuthPluginBase):
448 455
449 456 @hybrid_property
450 457 def allows_creating_users(self):
451 458 return True
452 459
453 460 def use_fake_password(self):
454 461 """
455 462 Return a boolean that indicates whether or not we should set the user's
456 463 password to a random value when it is authenticated by this plugin.
457 464 If your plugin provides authentication, then you will generally
458 465 want this.
459 466
460 467 :returns: boolean
461 468 """
462 469 raise NotImplementedError("Not implemented in base class")
463 470
464 471 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
465 472 # at this point _authenticate calls plugin's `auth()` function
466 473 auth = super(RhodeCodeExternalAuthPlugin, self)._authenticate(
467 474 userobj, username, passwd, settings, **kwargs)
468 475 if auth:
469 476 # maybe plugin will clean the username ?
470 477 # we should use the return value
471 478 username = auth['username']
472 479
473 480 # if external source tells us that user is not active, we should
474 481 # skip rest of the process. This can prevent from creating users in
475 482 # RhodeCode when using external authentication, but if it's
476 483 # inactive user we shouldn't create that user anyway
477 484 if auth['active_from_extern'] is False:
478 485 log.warning(
479 486 "User %s authenticated against %s, but is inactive",
480 487 username, self.__module__)
481 488 return None
482 489
483 490 cur_user = User.get_by_username(username, case_insensitive=True)
484 491 is_user_existing = cur_user is not None
485 492
486 493 if is_user_existing:
487 494 log.debug('Syncing user `%s` from '
488 495 '`%s` plugin', username, self.name)
489 496 else:
490 497 log.debug('Creating non existing user `%s` from '
491 498 '`%s` plugin', username, self.name)
492 499
493 500 if self.allows_creating_users:
494 501 log.debug('Plugin `%s` allows to '
495 502 'create new users', self.name)
496 503 else:
497 504 log.debug('Plugin `%s` does not allow to '
498 505 'create new users', self.name)
499 506
500 507 user_parameters = {
501 508 'username': username,
502 509 'email': auth["email"],
503 510 'firstname': auth["firstname"],
504 511 'lastname': auth["lastname"],
505 512 'active': auth["active"],
506 513 'admin': auth["admin"],
507 514 'extern_name': auth["extern_name"],
508 515 'extern_type': self.name,
509 516 'plugin': self,
510 517 'allow_to_create_user': self.allows_creating_users,
511 518 }
512 519
513 520 if not is_user_existing:
514 521 if self.use_fake_password():
515 522 # Randomize the PW because we don't need it, but don't want
516 523 # them blank either
517 524 passwd = PasswordGenerator().gen_password(length=16)
518 525 user_parameters['password'] = passwd
519 526 else:
520 527 # Since the password is required by create_or_update method of
521 528 # UserModel, we need to set it explicitly.
522 529 # The create_or_update method is smart and recognises the
523 530 # password hashes as well.
524 531 user_parameters['password'] = cur_user.password
525 532
526 533 # we either create or update users, we also pass the flag
527 534 # that controls if this method can actually do that.
528 535 # raises NotAllowedToCreateUserError if it cannot, and we try to.
529 536 user = UserModel().create_or_update(**user_parameters)
530 537 Session().flush()
531 538 # enforce user is just in given groups, all of them has to be ones
532 539 # created from plugins. We store this info in _group_data JSON
533 540 # field
534 541 try:
535 542 groups = auth['groups'] or []
536 543 UserGroupModel().enforce_groups(user, groups, self.name)
537 544 except Exception:
538 545 # for any reason group syncing fails, we should
539 546 # proceed with login
540 547 log.error(traceback.format_exc())
541 548 Session().commit()
542 549 return auth
543 550
544 551
545 552 def loadplugin(plugin_id):
546 553 """
547 554 Loads and returns an instantiated authentication plugin.
548 555 Returns the RhodeCodeAuthPluginBase subclass on success,
549 556 or None on failure.
550 557 """
551 558 # TODO: Disusing pyramids thread locals to retrieve the registry.
552 559 authn_registry = get_authn_registry()
553 560 plugin = authn_registry.get_plugin(plugin_id)
554 561 if plugin is None:
555 562 log.error('Authentication plugin not found: "%s"', plugin_id)
556 563 return plugin
557 564
558 565
559 566 def get_authn_registry(registry=None):
560 567 registry = registry or get_current_registry()
561 568 authn_registry = registry.getUtility(IAuthnPluginRegistry)
562 569 return authn_registry
563 570
564 571
565 572 def get_auth_cache_manager(custom_ttl=None):
566 573 return caches.get_cache_manager(
567 574 'auth_plugins', 'rhodecode.authentication', custom_ttl)
568 575
569 576
570 577 def authenticate(username, password, environ=None, auth_type=None,
571 578 skip_missing=False, registry=None, acl_repo_name=None):
572 579 """
573 580 Authentication function used for access control,
574 581 It tries to authenticate based on enabled authentication modules.
575 582
576 583 :param username: username can be empty for headers auth
577 584 :param password: password can be empty for headers auth
578 585 :param environ: environ headers passed for headers auth
579 586 :param auth_type: type of authentication, either `HTTP_TYPE` or `VCS_TYPE`
580 587 :param skip_missing: ignores plugins that are in db but not in environment
581 588 :returns: None if auth failed, plugin_user dict if auth is correct
582 589 """
583 590 if not auth_type or auth_type not in [HTTP_TYPE, VCS_TYPE]:
584 591 raise ValueError('auth type must be on of http, vcs got "%s" instead'
585 592 % auth_type)
586 593 headers_only = environ and not (username and password)
587 594
588 595 authn_registry = get_authn_registry(registry)
589 596 for plugin in authn_registry.get_plugins_for_authentication():
590 597 plugin.set_auth_type(auth_type)
591 598 plugin.set_calling_scope_repo(acl_repo_name)
592 599
593 600 if headers_only and not plugin.is_headers_auth:
594 601 log.debug('Auth type is for headers only and plugin `%s` is not '
595 602 'headers plugin, skipping...', plugin.get_id())
596 603 continue
597 604
598 605 # load plugin settings from RhodeCode database
599 606 plugin_settings = plugin.get_settings()
600 607 plugin_sanitized_settings = plugin.log_safe_settings(plugin_settings)
601 608 log.debug('Plugin settings:%s', plugin_sanitized_settings)
602 609
603 610 log.debug('Trying authentication using ** %s **', plugin.get_id())
604 611 # use plugin's method of user extraction.
605 612 user = plugin.get_user(username, environ=environ,
606 613 settings=plugin_settings)
607 614 display_user = user.username if user else username
608 615 log.debug(
609 616 'Plugin %s extracted user is `%s`', plugin.get_id(), display_user)
610 617
611 618 if not plugin.allows_authentication_from(user):
612 619 log.debug('Plugin %s does not accept user `%s` for authentication',
613 620 plugin.get_id(), display_user)
614 621 continue
615 622 else:
616 623 log.debug('Plugin %s accepted user `%s` for authentication',
617 624 plugin.get_id(), display_user)
618 625
619 626 log.info('Authenticating user `%s` using %s plugin',
620 627 display_user, plugin.get_id())
621 628
622 629 _cache_ttl = 0
623 630
624 631 if isinstance(plugin.AUTH_CACHE_TTL, (int, long)):
625 632 # plugin cache set inside is more important than the settings value
626 633 _cache_ttl = plugin.AUTH_CACHE_TTL
627 634 elif plugin_settings.get('cache_ttl'):
628 635 _cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0)
629 636
630 637 plugin_cache_active = bool(_cache_ttl and _cache_ttl > 0)
631 638
632 639 # get instance of cache manager configured for a namespace
633 640 cache_manager = get_auth_cache_manager(custom_ttl=_cache_ttl)
634 641
635 642 log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)',
636 643 plugin.get_id(), plugin_cache_active, _cache_ttl)
637 644
638 645 # for environ based password can be empty, but then the validation is
639 646 # on the server that fills in the env data needed for authentication
640 647 _password_hash = md5_safe(plugin.name + username + (password or ''))
641 648
642 649 # _authenticate is a wrapper for .auth() method of plugin.
643 650 # it checks if .auth() sends proper data.
644 651 # For RhodeCodeExternalAuthPlugin it also maps users to
645 652 # Database and maps the attributes returned from .auth()
646 653 # to RhodeCode database. If this function returns data
647 654 # then auth is correct.
648 655 start = time.time()
649 656 log.debug('Running plugin `%s` _authenticate method', plugin.get_id())
650 657
651 658 def auth_func():
652 659 """
653 660 This function is used internally in Cache of Beaker to calculate
654 661 Results
655 662 """
656 663 return plugin._authenticate(
657 664 user, username, password, plugin_settings,
658 665 environ=environ or {})
659 666
660 667 if plugin_cache_active:
661 668 plugin_user = cache_manager.get(
662 669 _password_hash, createfunc=auth_func)
663 670 else:
664 671 plugin_user = auth_func()
665 672
666 673 auth_time = time.time() - start
667 674 log.debug('Authentication for plugin `%s` completed in %.3fs, '
668 675 'expiration time of fetched cache %.1fs.',
669 676 plugin.get_id(), auth_time, _cache_ttl)
670 677
671 678 log.debug('PLUGIN USER DATA: %s', plugin_user)
672 679
673 680 if plugin_user:
674 681 log.debug('Plugin returned proper authentication data')
675 682 return plugin_user
676 683 # we failed to Auth because .auth() method didn't return proper user
677 684 log.debug("User `%s` failed to authenticate against %s",
678 685 display_user, plugin.get_id())
679 686 return None
680 687
681 688
682 689 def chop_at(s, sub, inclusive=False):
683 690 """Truncate string ``s`` at the first occurrence of ``sub``.
684 691
685 692 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
686 693
687 694 >>> chop_at("plutocratic brats", "rat")
688 695 'plutoc'
689 696 >>> chop_at("plutocratic brats", "rat", True)
690 697 'plutocrat'
691 698 """
692 699 pos = s.find(sub)
693 700 if pos == -1:
694 701 return s
695 702 if inclusive:
696 703 return s[:pos+len(sub)]
697 704 return s[:pos]
@@ -1,87 +1,87 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-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 logging
22 22
23 23 from pyramid.exceptions import ConfigurationError
24 24 from zope.interface import implementer
25 25
26 26 from rhodecode.authentication.interface import IAuthnPluginRegistry
27 27 from rhodecode.lib.utils2 import safe_str
28 28 from rhodecode.model.settings import SettingsModel
29 29
30 30 log = logging.getLogger(__name__)
31 31
32 32
33 33 @implementer(IAuthnPluginRegistry)
34 34 class AuthenticationPluginRegistry(object):
35 35
36 36 # INI settings key to set a fallback authentication plugin.
37 37 fallback_plugin_key = 'rhodecode.auth_plugin_fallback'
38 38
39 39 def __init__(self, settings):
40 40 self._plugins = {}
41 41 self._fallback_plugin = settings.get(self.fallback_plugin_key, None)
42 42
43 43 def add_authn_plugin(self, config, plugin):
44 44 plugin_id = plugin.get_id()
45 45 if plugin_id in self._plugins.keys():
46 46 raise ConfigurationError(
47 47 'Cannot register authentication plugin twice: "%s"', plugin_id)
48 48 else:
49 49 log.debug('Register authentication plugin: "%s"', plugin_id)
50 50 self._plugins[plugin_id] = plugin
51 51
52 52 def get_plugins(self):
53 53 def sort_key(plugin):
54 54 return str.lower(safe_str(plugin.get_display_name()))
55 55
56 56 return sorted(self._plugins.values(), key=sort_key)
57 57
58 58 def get_plugin(self, plugin_id):
59 59 return self._plugins.get(plugin_id, None)
60 60
61 61 def get_plugins_for_authentication(self):
62 62 """
63 63 Returns a list of plugins which should be consulted when authenticating
64 64 a user. It only returns plugins which are enabled and active.
65 65 Additionally it includes the fallback plugin from the INI file, if
66 66 `rhodecode.auth_plugin_fallback` is set to a plugin ID.
67 67 """
68 68 plugins = []
69 69
70 70 # Add all enabled and active plugins to the list. We iterate over the
71 # auth_plugins setting from DB beacuse it also represents the ordering.
71 # auth_plugins setting from DB because it also represents the ordering.
72 72 enabled_plugins = SettingsModel().get_auth_plugins()
73 73 for plugin_id in enabled_plugins:
74 74 plugin = self.get_plugin(plugin_id)
75 75 if plugin is not None and plugin.is_active():
76 76 plugins.append(plugin)
77 77
78 78 # Add the fallback plugin from ini file.
79 79 if self._fallback_plugin:
80 80 log.warn(
81 81 'Using fallback authentication plugin from INI file: "%s"',
82 82 self._fallback_plugin)
83 83 plugin = self.get_plugin(self._fallback_plugin)
84 84 if plugin is not None and plugin not in plugins:
85 85 plugins.append(plugin)
86 86
87 87 return plugins
@@ -1,627 +1,630 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 import pyramid.threadlocal
33 33
34 34 from paste.auth.basic import AuthBasicAuthenticator
35 35 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
36 36 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
37 37
38 38 import rhodecode
39 39 from rhodecode.authentication.base import VCS_TYPE
40 40 from rhodecode.lib import auth, utils2
41 41 from rhodecode.lib import helpers as h
42 42 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
43 43 from rhodecode.lib.exceptions import UserCreationError
44 44 from rhodecode.lib.utils import (
45 45 get_repo_slug, set_rhodecode_config, password_changed,
46 46 get_enabled_hook_classes)
47 47 from rhodecode.lib.utils2 import (
48 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist)
48 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist, safe_str)
49 49 from rhodecode.model import meta
50 50 from rhodecode.model.db import Repository, User, ChangesetComment
51 51 from rhodecode.model.notification import NotificationModel
52 52 from rhodecode.model.scm import ScmModel
53 53 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
54 54
55 55 # NOTE(marcink): remove after base controller is no longer required
56 56 from pylons.controllers import WSGIController
57 57 from pylons.i18n import translation
58 58
59 59 log = logging.getLogger(__name__)
60 60
61 61
62 62 # hack to make the migration to pyramid easier
63 63 def render(template_name, extra_vars=None, cache_key=None,
64 64 cache_type=None, cache_expire=None):
65 65 """Render a template with Mako
66 66
67 67 Accepts the cache options ``cache_key``, ``cache_type``, and
68 68 ``cache_expire``.
69 69
70 70 """
71 71 from pylons.templating import literal
72 72 from pylons.templating import cached_template, pylons_globals
73 73
74 74 # Create a render callable for the cache function
75 75 def render_template():
76 76 # Pull in extra vars if needed
77 77 globs = extra_vars or {}
78 78
79 79 # Second, get the globals
80 80 globs.update(pylons_globals())
81 81
82 82 globs['_ungettext'] = globs['ungettext']
83 83 # Grab a template reference
84 84 template = globs['app_globals'].mako_lookup.get_template(template_name)
85 85
86 86 return literal(template.render_unicode(**globs))
87 87
88 88 return cached_template(template_name, render_template, cache_key=cache_key,
89 89 cache_type=cache_type, cache_expire=cache_expire)
90 90
91 91 def _filter_proxy(ip):
92 92 """
93 93 Passed in IP addresses in HEADERS can be in a special format of multiple
94 94 ips. Those comma separated IPs are passed from various proxies in the
95 95 chain of request processing. The left-most being the original client.
96 96 We only care about the first IP which came from the org. client.
97 97
98 98 :param ip: ip string from headers
99 99 """
100 100 if ',' in ip:
101 101 _ips = ip.split(',')
102 102 _first_ip = _ips[0].strip()
103 103 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
104 104 return _first_ip
105 105 return ip
106 106
107 107
108 108 def _filter_port(ip):
109 109 """
110 110 Removes a port from ip, there are 4 main cases to handle here.
111 111 - ipv4 eg. 127.0.0.1
112 112 - ipv6 eg. ::1
113 113 - ipv4+port eg. 127.0.0.1:8080
114 114 - ipv6+port eg. [::1]:8080
115 115
116 116 :param ip:
117 117 """
118 118 def is_ipv6(ip_addr):
119 119 if hasattr(socket, 'inet_pton'):
120 120 try:
121 121 socket.inet_pton(socket.AF_INET6, ip_addr)
122 122 except socket.error:
123 123 return False
124 124 else:
125 125 # fallback to ipaddress
126 126 try:
127 127 ipaddress.IPv6Address(safe_unicode(ip_addr))
128 128 except Exception:
129 129 return False
130 130 return True
131 131
132 132 if ':' not in ip: # must be ipv4 pure ip
133 133 return ip
134 134
135 135 if '[' in ip and ']' in ip: # ipv6 with port
136 136 return ip.split(']')[0][1:].lower()
137 137
138 138 # must be ipv6 or ipv4 with port
139 139 if is_ipv6(ip):
140 140 return ip
141 141 else:
142 142 ip, _port = ip.split(':')[:2] # means ipv4+port
143 143 return ip
144 144
145 145
146 146 def get_ip_addr(environ):
147 147 proxy_key = 'HTTP_X_REAL_IP'
148 148 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
149 149 def_key = 'REMOTE_ADDR'
150 150 _filters = lambda x: _filter_port(_filter_proxy(x))
151 151
152 152 ip = environ.get(proxy_key)
153 153 if ip:
154 154 return _filters(ip)
155 155
156 156 ip = environ.get(proxy_key2)
157 157 if ip:
158 158 return _filters(ip)
159 159
160 160 ip = environ.get(def_key, '0.0.0.0')
161 161 return _filters(ip)
162 162
163 163
164 164 def get_server_ip_addr(environ, log_errors=True):
165 165 hostname = environ.get('SERVER_NAME')
166 166 try:
167 167 return socket.gethostbyname(hostname)
168 168 except Exception as e:
169 169 if log_errors:
170 170 # in some cases this lookup is not possible, and we don't want to
171 171 # make it an exception in logs
172 172 log.exception('Could not retrieve server ip address: %s', e)
173 173 return hostname
174 174
175 175
176 176 def get_server_port(environ):
177 177 return environ.get('SERVER_PORT')
178 178
179 179
180 180 def get_access_path(environ):
181 181 path = environ.get('PATH_INFO')
182 182 org_req = environ.get('pylons.original_request')
183 183 if org_req:
184 184 path = org_req.environ.get('PATH_INFO')
185 185 return path
186 186
187 187
188 188 def get_user_agent(environ):
189 189 return environ.get('HTTP_USER_AGENT')
190 190
191 191
192 192 def vcs_operation_context(
193 193 environ, repo_name, username, action, scm, check_locking=True,
194 194 is_shadow_repo=False):
195 195 """
196 196 Generate the context for a vcs operation, e.g. push or pull.
197 197
198 198 This context is passed over the layers so that hooks triggered by the
199 199 vcs operation know details like the user, the user's IP address etc.
200 200
201 201 :param check_locking: Allows to switch of the computation of the locking
202 202 data. This serves mainly the need of the simplevcs middleware to be
203 203 able to disable this for certain operations.
204 204
205 205 """
206 206 # Tri-state value: False: unlock, None: nothing, True: lock
207 207 make_lock = None
208 208 locked_by = [None, None, None]
209 209 is_anonymous = username == User.DEFAULT_USER
210 210 if not is_anonymous and check_locking:
211 211 log.debug('Checking locking on repository "%s"', repo_name)
212 212 user = User.get_by_username(username)
213 213 repo = Repository.get_by_repo_name(repo_name)
214 214 make_lock, __, locked_by = repo.get_locking_state(
215 215 action, user.user_id)
216 216
217 217 settings_model = VcsSettingsModel(repo=repo_name)
218 218 ui_settings = settings_model.get_ui_settings()
219 219
220 220 extras = {
221 221 'ip': get_ip_addr(environ),
222 222 'username': username,
223 223 'action': action,
224 224 'repository': repo_name,
225 225 'scm': scm,
226 226 'config': rhodecode.CONFIG['__file__'],
227 227 'make_lock': make_lock,
228 228 'locked_by': locked_by,
229 229 'server_url': utils2.get_server_url(environ),
230 230 'user_agent': get_user_agent(environ),
231 231 'hooks': get_enabled_hook_classes(ui_settings),
232 232 'is_shadow_repo': is_shadow_repo,
233 233 }
234 234 return extras
235 235
236 236
237 237 class BasicAuth(AuthBasicAuthenticator):
238 238
239 239 def __init__(self, realm, authfunc, registry, auth_http_code=None,
240 240 initial_call_detection=False, acl_repo_name=None):
241 241 self.realm = realm
242 242 self.initial_call = initial_call_detection
243 243 self.authfunc = authfunc
244 244 self.registry = registry
245 245 self.acl_repo_name = acl_repo_name
246 246 self._rc_auth_http_code = auth_http_code
247 247
248 248 def _get_response_from_code(self, http_code):
249 249 try:
250 250 return get_exception(safe_int(http_code))
251 251 except Exception:
252 252 log.exception('Failed to fetch response for code %s' % http_code)
253 253 return HTTPForbidden
254 254
255 def get_rc_realm(self):
256 return safe_str(self.registry.rhodecode_settings.get('rhodecode_realm'))
257
255 258 def build_authentication(self):
256 259 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
257 260 if self._rc_auth_http_code and not self.initial_call:
258 261 # return alternative HTTP code if alternative http return code
259 262 # is specified in RhodeCode config, but ONLY if it's not the
260 263 # FIRST call
261 264 custom_response_klass = self._get_response_from_code(
262 265 self._rc_auth_http_code)
263 266 return custom_response_klass(headers=head)
264 267 return HTTPUnauthorized(headers=head)
265 268
266 269 def authenticate(self, environ):
267 270 authorization = AUTHORIZATION(environ)
268 271 if not authorization:
269 272 return self.build_authentication()
270 273 (authmeth, auth) = authorization.split(' ', 1)
271 274 if 'basic' != authmeth.lower():
272 275 return self.build_authentication()
273 276 auth = auth.strip().decode('base64')
274 277 _parts = auth.split(':', 1)
275 278 if len(_parts) == 2:
276 279 username, password = _parts
277 280 if self.authfunc(
278 281 username, password, environ, VCS_TYPE,
279 282 registry=self.registry, acl_repo_name=self.acl_repo_name):
280 283 return username
281 284 if username and password:
282 285 # we mark that we actually executed authentication once, at
283 286 # that point we can use the alternative auth code
284 287 self.initial_call = False
285 288
286 289 return self.build_authentication()
287 290
288 291 __call__ = authenticate
289 292
290 293
291 294 def calculate_version_hash(config):
292 295 return md5(
293 296 config.get('beaker.session.secret', '') +
294 297 rhodecode.__version__)[:8]
295 298
296 299
297 300 def get_current_lang(request):
298 301 # NOTE(marcink): remove after pyramid move
299 302 try:
300 303 return translation.get_lang()[0]
301 304 except:
302 305 pass
303 306
304 307 return getattr(request, '_LOCALE_', request.locale_name)
305 308
306 309
307 310 def attach_context_attributes(context, request, user_id):
308 311 """
309 312 Attach variables into template context called `c`, please note that
310 313 request could be pylons or pyramid request in here.
311 314 """
312 315 # NOTE(marcink): remove check after pyramid migration
313 316 if hasattr(request, 'registry'):
314 317 config = request.registry.settings
315 318 else:
316 319 from pylons import config
317 320
318 321 rc_config = SettingsModel().get_all_settings(cache=True)
319 322
320 323 context.rhodecode_version = rhodecode.__version__
321 324 context.rhodecode_edition = config.get('rhodecode.edition')
322 325 # unique secret + version does not leak the version but keep consistency
323 326 context.rhodecode_version_hash = calculate_version_hash(config)
324 327
325 328 # Default language set for the incoming request
326 329 context.language = get_current_lang(request)
327 330
328 331 # Visual options
329 332 context.visual = AttributeDict({})
330 333
331 334 # DB stored Visual Items
332 335 context.visual.show_public_icon = str2bool(
333 336 rc_config.get('rhodecode_show_public_icon'))
334 337 context.visual.show_private_icon = str2bool(
335 338 rc_config.get('rhodecode_show_private_icon'))
336 339 context.visual.stylify_metatags = str2bool(
337 340 rc_config.get('rhodecode_stylify_metatags'))
338 341 context.visual.dashboard_items = safe_int(
339 342 rc_config.get('rhodecode_dashboard_items', 100))
340 343 context.visual.admin_grid_items = safe_int(
341 344 rc_config.get('rhodecode_admin_grid_items', 100))
342 345 context.visual.repository_fields = str2bool(
343 346 rc_config.get('rhodecode_repository_fields'))
344 347 context.visual.show_version = str2bool(
345 348 rc_config.get('rhodecode_show_version'))
346 349 context.visual.use_gravatar = str2bool(
347 350 rc_config.get('rhodecode_use_gravatar'))
348 351 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
349 352 context.visual.default_renderer = rc_config.get(
350 353 'rhodecode_markup_renderer', 'rst')
351 354 context.visual.comment_types = ChangesetComment.COMMENT_TYPES
352 355 context.visual.rhodecode_support_url = \
353 356 rc_config.get('rhodecode_support_url') or h.route_url('rhodecode_support')
354 357
355 358 context.visual.affected_files_cut_off = 60
356 359
357 360 context.pre_code = rc_config.get('rhodecode_pre_code')
358 361 context.post_code = rc_config.get('rhodecode_post_code')
359 362 context.rhodecode_name = rc_config.get('rhodecode_title')
360 363 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
361 364 # if we have specified default_encoding in the request, it has more
362 365 # priority
363 366 if request.GET.get('default_encoding'):
364 367 context.default_encodings.insert(0, request.GET.get('default_encoding'))
365 368 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
366 369
367 370 # INI stored
368 371 context.labs_active = str2bool(
369 372 config.get('labs_settings_active', 'false'))
370 373 context.visual.allow_repo_location_change = str2bool(
371 374 config.get('allow_repo_location_change', True))
372 375 context.visual.allow_custom_hooks_settings = str2bool(
373 376 config.get('allow_custom_hooks_settings', True))
374 377 context.debug_style = str2bool(config.get('debug_style', False))
375 378
376 379 context.rhodecode_instanceid = config.get('instance_id')
377 380
378 381 context.visual.cut_off_limit_diff = safe_int(
379 382 config.get('cut_off_limit_diff'))
380 383 context.visual.cut_off_limit_file = safe_int(
381 384 config.get('cut_off_limit_file'))
382 385
383 386 # AppEnlight
384 387 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
385 388 context.appenlight_api_public_key = config.get(
386 389 'appenlight.api_public_key', '')
387 390 context.appenlight_server_url = config.get('appenlight.server_url', '')
388 391
389 392 # JS template context
390 393 context.template_context = {
391 394 'repo_name': None,
392 395 'repo_type': None,
393 396 'repo_landing_commit': None,
394 397 'rhodecode_user': {
395 398 'username': None,
396 399 'email': None,
397 400 'notification_status': False
398 401 },
399 402 'visual': {
400 403 'default_renderer': None
401 404 },
402 405 'commit_data': {
403 406 'commit_id': None
404 407 },
405 408 'pull_request_data': {'pull_request_id': None},
406 409 'timeago': {
407 410 'refresh_time': 120 * 1000,
408 411 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
409 412 },
410 413 'pyramid_dispatch': {
411 414
412 415 },
413 416 'extra': {'plugins': {}}
414 417 }
415 418 # END CONFIG VARS
416 419
417 420 # TODO: This dosn't work when called from pylons compatibility tween.
418 421 # Fix this and remove it from base controller.
419 422 # context.repo_name = get_repo_slug(request) # can be empty
420 423
421 424 diffmode = 'sideside'
422 425 if request.GET.get('diffmode'):
423 426 if request.GET['diffmode'] == 'unified':
424 427 diffmode = 'unified'
425 428 elif request.session.get('diffmode'):
426 429 diffmode = request.session['diffmode']
427 430
428 431 context.diffmode = diffmode
429 432
430 433 if request.session.get('diffmode') != diffmode:
431 434 request.session['diffmode'] = diffmode
432 435
433 436 context.csrf_token = auth.get_csrf_token(session=request.session)
434 437 context.backends = rhodecode.BACKENDS.keys()
435 438 context.backends.sort()
436 439 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(user_id)
437 440
438 441 # NOTE(marcink): when migrated to pyramid we don't need to set this anymore,
439 442 # given request will ALWAYS be pyramid one
440 443 pyramid_request = pyramid.threadlocal.get_current_request()
441 444 context.pyramid_request = pyramid_request
442 445
443 446 # web case
444 447 if hasattr(pyramid_request, 'user'):
445 448 context.auth_user = pyramid_request.user
446 449 context.rhodecode_user = pyramid_request.user
447 450
448 451 # api case
449 452 if hasattr(pyramid_request, 'rpc_user'):
450 453 context.auth_user = pyramid_request.rpc_user
451 454 context.rhodecode_user = pyramid_request.rpc_user
452 455
453 456 # attach the whole call context to the request
454 457 request.call_context = context
455 458
456 459
457 460 def get_auth_user(request):
458 461 environ = request.environ
459 462 session = request.session
460 463
461 464 ip_addr = get_ip_addr(environ)
462 465 # make sure that we update permissions each time we call controller
463 466 _auth_token = (request.GET.get('auth_token', '') or
464 467 request.GET.get('api_key', ''))
465 468
466 469 if _auth_token:
467 470 # when using API_KEY we assume user exists, and
468 471 # doesn't need auth based on cookies.
469 472 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
470 473 authenticated = False
471 474 else:
472 475 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
473 476 try:
474 477 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
475 478 ip_addr=ip_addr)
476 479 except UserCreationError as e:
477 480 h.flash(e, 'error')
478 481 # container auth or other auth functions that create users
479 482 # on the fly can throw this exception signaling that there's
480 483 # issue with user creation, explanation should be provided
481 484 # in Exception itself. We then create a simple blank
482 485 # AuthUser
483 486 auth_user = AuthUser(ip_addr=ip_addr)
484 487
485 488 if password_changed(auth_user, session):
486 489 session.invalidate()
487 490 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
488 491 auth_user = AuthUser(ip_addr=ip_addr)
489 492
490 493 authenticated = cookie_store.get('is_authenticated')
491 494
492 495 if not auth_user.is_authenticated and auth_user.is_user_object:
493 496 # user is not authenticated and not empty
494 497 auth_user.set_authenticated(authenticated)
495 498
496 499 return auth_user
497 500
498 501
499 502 class BaseController(WSGIController):
500 503
501 504 def __before__(self):
502 505 """
503 506 __before__ is called before controller methods and after __call__
504 507 """
505 508 # on each call propagate settings calls into global settings.
506 509 from pylons import config
507 510 from pylons import tmpl_context as c, request, url
508 511 set_rhodecode_config(config)
509 512 attach_context_attributes(c, request, self._rhodecode_user.user_id)
510 513
511 514 # TODO: Remove this when fixed in attach_context_attributes()
512 515 c.repo_name = get_repo_slug(request) # can be empty
513 516
514 517 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
515 518 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
516 519 self.sa = meta.Session
517 520 self.scm_model = ScmModel(self.sa)
518 521
519 522 # set user language
520 523 user_lang = getattr(c.pyramid_request, '_LOCALE_', None)
521 524 if user_lang:
522 525 translation.set_lang(user_lang)
523 526 log.debug('set language to %s for user %s',
524 527 user_lang, self._rhodecode_user)
525 528
526 529 def _dispatch_redirect(self, with_url, environ, start_response):
527 530 from webob.exc import HTTPFound
528 531 resp = HTTPFound(with_url)
529 532 environ['SCRIPT_NAME'] = '' # handle prefix middleware
530 533 environ['PATH_INFO'] = with_url
531 534 return resp(environ, start_response)
532 535
533 536 def __call__(self, environ, start_response):
534 537 """Invoke the Controller"""
535 538 # WSGIController.__call__ dispatches to the Controller method
536 539 # the request is routed to. This routing information is
537 540 # available in environ['pylons.routes_dict']
538 541 from rhodecode.lib import helpers as h
539 542 from pylons import tmpl_context as c, request, url
540 543
541 544 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
542 545 if environ.get('debugtoolbar.wants_pylons_context', False):
543 546 environ['debugtoolbar.pylons_context'] = c._current_obj()
544 547
545 548 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
546 549 environ['pylons.routes_dict']['action']])
547 550
548 551 self.rc_config = SettingsModel().get_all_settings(cache=True)
549 552 self.ip_addr = get_ip_addr(environ)
550 553
551 554 # The rhodecode auth user is looked up and passed through the
552 555 # environ by the pylons compatibility tween in pyramid.
553 556 # So we can just grab it from there.
554 557 auth_user = environ['rc_auth_user']
555 558
556 559 # set globals for auth user
557 560 request.user = auth_user
558 561 self._rhodecode_user = auth_user
559 562
560 563 log.info('IP: %s User: %s accessed %s [%s]' % (
561 564 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
562 565 _route_name)
563 566 )
564 567
565 568 user_obj = auth_user.get_instance()
566 569 if user_obj and user_obj.user_data.get('force_password_change'):
567 570 h.flash('You are required to change your password', 'warning',
568 571 ignore_duplicate=True)
569 572 return self._dispatch_redirect(
570 573 url('my_account_password'), environ, start_response)
571 574
572 575 return WSGIController.__call__(self, environ, start_response)
573 576
574 577
575 578 def h_filter(s):
576 579 """
577 580 Custom filter for Mako templates. Mako by standard uses `markupsafe.escape`
578 581 we wrap this with additional functionality that converts None to empty
579 582 strings
580 583 """
581 584 if s is None:
582 585 return markupsafe.Markup()
583 586 return markupsafe.escape(s)
584 587
585 588
586 589 def add_events_routes(config):
587 590 """
588 591 Adds routing that can be used in events. Because some events are triggered
589 592 outside of pyramid context, we need to bootstrap request with some
590 593 routing registered
591 594 """
592 595 config.add_route(name='home', pattern='/')
593 596
594 597 config.add_route(name='repo_summary', pattern='/{repo_name}')
595 598 config.add_route(name='repo_summary_explicit', pattern='/{repo_name}/summary')
596 599 config.add_route(name='repo_group_home', pattern='/{repo_group_name}')
597 600
598 601 config.add_route(name='pullrequest_show',
599 602 pattern='/{repo_name}/pull-request/{pull_request_id}')
600 603 config.add_route(name='pull_requests_global',
601 604 pattern='/pull-request/{pull_request_id}')
602 605
603 606 config.add_route(name='repo_commit',
604 607 pattern='/{repo_name}/changeset/{commit_id}')
605 608 config.add_route(name='repo_files',
606 609 pattern='/{repo_name}/files/{commit_id}/{f_path}')
607 610
608 611
609 612 def bootstrap_request(**kwargs):
610 613 import pyramid.testing
611 614
612 615 class TestRequest(pyramid.testing.DummyRequest):
613 616 application_url = kwargs.pop('application_url', 'http://example.com')
614 617 host = kwargs.pop('host', 'example.com:80')
615 618 domain = kwargs.pop('domain', 'example.com')
616 619
617 620 class TestDummySession(pyramid.testing.DummySession):
618 621 def save(*arg, **kw):
619 622 pass
620 623
621 624 request = TestRequest(**kwargs)
622 625 request.session = TestDummySession()
623 626
624 627 config = pyramid.testing.setUp(request=request)
625 628 add_events_routes(config)
626 629 return request
627 630
@@ -1,540 +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 is_valid_repo, get_rhodecode_realm, get_rhodecode_base_path, SLUG_RE)
48 is_valid_repo, 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 from rhodecode.model.settings import SettingsModel
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
111 # base path of repo locations
112 self.basepath = get_rhodecode_base_path()
110 self.rhodecode_settings = SettingsModel().get_all_settings(cache=True)
111 self.basepath = rhodecode.CONFIG['base_path']
112 registry.rhodecode_settings = self.rhodecode_settings
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 241 @property
242 242 def is_shadow_repo_dir(self):
243 243 return os.path.isdir(self.vcs_repo_name)
244 244
245 245 def _check_permission(self, action, user, repo_name, ip_addr=None):
246 246 """
247 247 Checks permissions using action (push/pull) user and repository
248 248 name
249 249
250 250 :param action: push or pull action
251 251 :param user: user instance
252 252 :param repo_name: repository name
253 253 """
254 254 # check IP
255 255 inherit = user.inherit_default_permissions
256 256 ip_allowed = AuthUser.check_ip_allowed(user.user_id, ip_addr,
257 257 inherit_from_default=inherit)
258 258 if ip_allowed:
259 259 log.info('Access for IP:%s allowed', ip_addr)
260 260 else:
261 261 return False
262 262
263 263 if action == 'push':
264 264 if not HasPermissionAnyMiddleware('repository.write',
265 265 'repository.admin')(user,
266 266 repo_name):
267 267 return False
268 268
269 269 else:
270 270 # any other action need at least read permission
271 271 if not HasPermissionAnyMiddleware('repository.read',
272 272 'repository.write',
273 273 'repository.admin')(user,
274 274 repo_name):
275 275 return False
276 276
277 277 return True
278 278
279 279 def _check_ssl(self, environ, start_response):
280 280 """
281 281 Checks the SSL check flag and returns False if SSL is not present
282 282 and required True otherwise
283 283 """
284 284 org_proto = environ['wsgi._org_proto']
285 285 # check if we have SSL required ! if not it's a bad request !
286 286 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
287 287 if require_ssl and org_proto == 'http':
288 288 log.debug('proto is %s and SSL is required BAD REQUEST !',
289 289 org_proto)
290 290 return False
291 291 return True
292 292
293 293 def __call__(self, environ, start_response):
294 294 try:
295 295 return self._handle_request(environ, start_response)
296 296 except Exception:
297 297 log.exception("Exception while handling request")
298 298 appenlight.track_exception(environ)
299 299 return HTTPInternalServerError()(environ, start_response)
300 300 finally:
301 301 meta.Session.remove()
302 302
303 303 def _handle_request(self, environ, start_response):
304 304
305 305 if not self._check_ssl(environ, start_response):
306 306 reason = ('SSL required, while RhodeCode was unable '
307 307 'to detect this as SSL request')
308 308 log.debug('User not allowed to proceed, %s', reason)
309 309 return HTTPNotAcceptable(reason)(environ, start_response)
310 310
311 311 if not self.url_repo_name:
312 312 log.warning('Repository name is empty: %s', self.url_repo_name)
313 313 # failed to get repo name, we fail now
314 314 return HTTPNotFound()(environ, start_response)
315 315 log.debug('Extracted repo name is %s', self.url_repo_name)
316 316
317 317 ip_addr = get_ip_addr(environ)
318 318 user_agent = get_user_agent(environ)
319 319 username = None
320 320
321 321 # skip passing error to error controller
322 322 environ['pylons.status_code_redirect'] = True
323 323
324 324 # ======================================================================
325 325 # GET ACTION PULL or PUSH
326 326 # ======================================================================
327 327 action = self._get_action(environ)
328 328
329 329 # ======================================================================
330 330 # Check if this is a request to a shadow repository of a pull request.
331 331 # In this case only pull action is allowed.
332 332 # ======================================================================
333 333 if self.is_shadow_repo and action != 'pull':
334 334 reason = 'Only pull action is allowed for shadow repositories.'
335 335 log.debug('User not allowed to proceed, %s', reason)
336 336 return HTTPNotAcceptable(reason)(environ, start_response)
337 337
338 338 # Check if the shadow repo actually exists, in case someone refers
339 339 # to it, and it has been deleted because of successful merge.
340 340 if self.is_shadow_repo and not self.is_shadow_repo_dir:
341 341 return HTTPNotFound()(environ, start_response)
342 342
343 343 # ======================================================================
344 344 # CHECK ANONYMOUS PERMISSION
345 345 # ======================================================================
346 346 if action in ['pull', 'push']:
347 347 anonymous_user = User.get_default_user()
348 348 username = anonymous_user.username
349 349 if anonymous_user.active:
350 350 # ONLY check permissions if the user is activated
351 351 anonymous_perm = self._check_permission(
352 352 action, anonymous_user, self.acl_repo_name, ip_addr)
353 353 else:
354 354 anonymous_perm = False
355 355
356 356 if not anonymous_user.active or not anonymous_perm:
357 357 if not anonymous_user.active:
358 358 log.debug('Anonymous access is disabled, running '
359 359 'authentication')
360 360
361 361 if not anonymous_perm:
362 362 log.debug('Not enough credentials to access this '
363 363 'repository as anonymous user')
364 364
365 365 username = None
366 366 # ==============================================================
367 367 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
368 368 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
369 369 # ==============================================================
370 370
371 371 # try to auth based on environ, container auth methods
372 372 log.debug('Running PRE-AUTH for container based authentication')
373 373 pre_auth = authenticate(
374 374 '', '', environ, VCS_TYPE, registry=self.registry,
375 375 acl_repo_name=self.acl_repo_name)
376 376 if pre_auth and pre_auth.get('username'):
377 377 username = pre_auth['username']
378 378 log.debug('PRE-AUTH got %s as username', username)
379 379
380 380 # If not authenticated by the container, running basic auth
381 381 # before inject the calling repo_name for special scope checks
382 382 self.authenticate.acl_repo_name = self.acl_repo_name
383 383 if not username:
384 self.authenticate.realm = get_rhodecode_realm()
384 self.authenticate.realm = self.authenticate.get_rc_realm()
385 385
386 386 try:
387 387 result = self.authenticate(environ)
388 388 except (UserCreationError, NotAllowedToCreateUserError) as e:
389 389 log.error(e)
390 390 reason = safe_str(e)
391 391 return HTTPNotAcceptable(reason)(environ, start_response)
392 392
393 393 if isinstance(result, str):
394 394 AUTH_TYPE.update(environ, 'basic')
395 395 REMOTE_USER.update(environ, result)
396 396 username = result
397 397 else:
398 398 return result.wsgi_application(environ, start_response)
399 399
400 400 # ==============================================================
401 401 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
402 402 # ==============================================================
403 403 user = User.get_by_username(username)
404 404 if not self.valid_and_active_user(user):
405 405 return HTTPForbidden()(environ, start_response)
406 406 username = user.username
407 407 user.update_lastactivity()
408 408 meta.Session().commit()
409 409
410 410 # check user attributes for password change flag
411 411 user_obj = user
412 412 if user_obj and user_obj.username != User.DEFAULT_USER and \
413 413 user_obj.user_data.get('force_password_change'):
414 414 reason = 'password change required'
415 415 log.debug('User not allowed to authenticate, %s', reason)
416 416 return HTTPNotAcceptable(reason)(environ, start_response)
417 417
418 418 # check permissions for this repository
419 419 perm = self._check_permission(
420 420 action, user, self.acl_repo_name, ip_addr)
421 421 if not perm:
422 422 return HTTPForbidden()(environ, start_response)
423 423
424 424 # extras are injected into UI object and later available
425 425 # in hooks executed by rhodecode
426 426 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
427 427 extras = vcs_operation_context(
428 428 environ, repo_name=self.acl_repo_name, username=username,
429 429 action=action, scm=self.SCM, check_locking=check_locking,
430 430 is_shadow_repo=self.is_shadow_repo
431 431 )
432 432
433 433 # ======================================================================
434 434 # REQUEST HANDLING
435 435 # ======================================================================
436 436 repo_path = os.path.join(
437 437 safe_str(self.basepath), safe_str(self.vcs_repo_name))
438 438 log.debug('Repository path is %s', repo_path)
439 439
440 440 fix_PATH()
441 441
442 442 log.info(
443 443 '%s action on %s repo "%s" by "%s" from %s %s',
444 444 action, self.SCM, safe_str(self.url_repo_name),
445 445 safe_str(username), ip_addr, user_agent)
446 446
447 447 return self._generate_vcs_response(
448 448 environ, start_response, repo_path, extras, action)
449 449
450 450 @initialize_generator
451 451 def _generate_vcs_response(
452 452 self, environ, start_response, repo_path, extras, action):
453 453 """
454 454 Returns a generator for the response content.
455 455
456 456 This method is implemented as a generator, so that it can trigger
457 457 the cache validation after all content sent back to the client. It
458 458 also handles the locking exceptions which will be triggered when
459 459 the first chunk is produced by the underlying WSGI application.
460 460 """
461 461 callback_daemon, extras = self._prepare_callback_daemon(extras)
462 462 config = self._create_config(extras, self.acl_repo_name)
463 463 log.debug('HOOKS extras is %s', extras)
464 464 app = self._create_wsgi_app(repo_path, self.url_repo_name, config)
465 465
466 466 try:
467 467 with callback_daemon:
468 468 try:
469 469 response = app(environ, start_response)
470 470 finally:
471 471 # This statement works together with the decorator
472 472 # "initialize_generator" above. The decorator ensures that
473 473 # we hit the first yield statement before the generator is
474 474 # returned back to the WSGI server. This is needed to
475 475 # ensure that the call to "app" above triggers the
476 476 # needed callback to "start_response" before the
477 477 # generator is actually used.
478 478 yield "__init__"
479 479
480 480 for chunk in response:
481 481 yield chunk
482 482 except Exception as exc:
483 483 # TODO: martinb: Exceptions are only raised in case of the Pyro4
484 484 # backend. Refactor this except block after dropping Pyro4 support.
485 485 # TODO: johbo: Improve "translating" back the exception.
486 486 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
487 487 exc = HTTPLockedRC(*exc.args)
488 488 _code = rhodecode.CONFIG.get('lock_ret_code')
489 489 log.debug('Repository LOCKED ret code %s!', (_code,))
490 490 elif getattr(exc, '_vcs_kind', None) == 'requirement':
491 491 log.debug(
492 492 'Repository requires features unknown to this Mercurial')
493 493 exc = HTTPRequirementError(*exc.args)
494 494 else:
495 495 raise
496 496
497 497 for chunk in exc(environ, start_response):
498 498 yield chunk
499 499 finally:
500 500 # invalidate cache on push
501 501 try:
502 502 if action == 'push':
503 503 self._invalidate_cache(self.url_repo_name)
504 504 finally:
505 505 meta.Session.remove()
506 506
507 507 def _get_repository_name(self, environ):
508 508 """Get repository name out of the environmnent
509 509
510 510 :param environ: WSGI environment
511 511 """
512 512 raise NotImplementedError()
513 513
514 514 def _get_action(self, environ):
515 515 """Map request commands into a pull or push command.
516 516
517 517 :param environ: WSGI environment
518 518 """
519 519 raise NotImplementedError()
520 520
521 521 def _create_wsgi_app(self, repo_path, repo_name, config):
522 522 """Return the WSGI app that will finally handle the request."""
523 523 raise NotImplementedError()
524 524
525 525 def _create_config(self, extras, repo_name):
526 526 """Create a safe config representation."""
527 527 raise NotImplementedError()
528 528
529 529 def _prepare_callback_daemon(self, extras):
530 530 return prepare_callback_daemon(
531 531 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
532 532 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
533 533
534 534
535 535 def _should_check_locking(query_string):
536 536 # this is kind of hacky, but due to how mercurial handles client-server
537 537 # server see all operation on commit; bookmarks, phases and
538 538 # obsolescence marker in different transaction, we don't want to check
539 539 # locking on those
540 540 return query_string not in ['cmd=listkeys']
@@ -1,142 +1,142 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 import urlparse
23 23 import mock
24 24 import simplejson as json
25 25
26 26 from rhodecode.lib.vcs.backends.base import Config
27 27 from rhodecode.tests.lib.middleware import mock_scm_app
28 28 import rhodecode.lib.middleware.simplegit as simplegit
29 29
30 30
31 31 def get_environ(url, request_method):
32 32 """Construct a minimum WSGI environ based on the URL."""
33 33 parsed_url = urlparse.urlparse(url)
34 34 environ = {
35 35 'PATH_INFO': parsed_url.path,
36 36 'QUERY_STRING': parsed_url.query,
37 37 'REQUEST_METHOD': request_method,
38 38 }
39 39
40 40 return environ
41 41
42 42
43 43 @pytest.mark.parametrize(
44 44 'url, expected_action, request_method',
45 45 [
46 46 ('/foo/bar/info/refs?service=git-upload-pack', 'pull', 'GET'),
47 47 ('/foo/bar/info/refs?service=git-receive-pack', 'push', 'GET'),
48 48 ('/foo/bar/git-upload-pack', 'pull', 'GET'),
49 49 ('/foo/bar/git-receive-pack', 'push', 'GET'),
50 50 # Edge case: missing data for info/refs
51 51 ('/foo/info/refs?service=', 'pull', 'GET'),
52 52 ('/foo/info/refs', 'pull', 'GET'),
53 53 # Edge case: git command comes with service argument
54 54 ('/foo/git-upload-pack?service=git-receive-pack', 'pull', 'GET'),
55 55 ('/foo/git-receive-pack?service=git-upload-pack', 'push', 'GET'),
56 56 # Edge case: repo name conflicts with git commands
57 57 ('/git-receive-pack/git-upload-pack', 'pull', 'GET'),
58 58 ('/git-receive-pack/git-receive-pack', 'push', 'GET'),
59 59 ('/git-upload-pack/git-upload-pack', 'pull', 'GET'),
60 60 ('/git-upload-pack/git-receive-pack', 'push', 'GET'),
61 61 ('/foo/git-receive-pack', 'push', 'GET'),
62 62 # Edge case: not a smart protocol url
63 63 ('/foo/bar', 'pull', 'GET'),
64 64 # GIT LFS cases, batch
65 65 ('/foo/bar/info/lfs/objects/batch', 'push', 'GET'),
66 66 ('/foo/bar/info/lfs/objects/batch', 'pull', 'POST'),
67 67 # GIT LFS oid, dl/upl
68 68 ('/foo/bar/info/lfs/abcdeabcde', 'pull', 'GET'),
69 69 ('/foo/bar/info/lfs/abcdeabcde', 'push', 'PUT'),
70 70 ('/foo/bar/info/lfs/abcdeabcde', 'push', 'POST'),
71 71 # Edge case: repo name conflicts with git commands
72 72 ('/info/lfs/info/lfs/objects/batch', 'push', 'GET'),
73 73 ('/info/lfs/info/lfs/objects/batch', 'pull', 'POST'),
74 74
75 75 ])
76 def test_get_action(url, expected_action, request_method, pylonsapp):
76 def test_get_action(url, expected_action, request_method, pylonsapp, request_stub):
77 77 app = simplegit.SimpleGit(application=None,
78 78 config={'auth_ret_code': '', 'base_path': ''},
79 registry=None)
79 registry=request_stub.registry)
80 80 assert expected_action == app._get_action(get_environ(url, request_method))
81 81
82 82
83 83 @pytest.mark.parametrize(
84 84 'url, expected_repo_name, request_method',
85 85 [
86 86 ('/foo/info/refs?service=git-upload-pack', 'foo', 'GET'),
87 87 ('/foo/bar/info/refs?service=git-receive-pack', 'foo/bar', 'GET'),
88 88 ('/foo/git-upload-pack', 'foo', 'GET'),
89 89 ('/foo/git-receive-pack', 'foo', 'GET'),
90 90 ('/foo/bar/git-upload-pack', 'foo/bar', 'GET'),
91 91 ('/foo/bar/git-receive-pack', 'foo/bar', 'GET'),
92 92
93 93 # GIT LFS cases, batch
94 94 ('/foo/bar/info/lfs/objects/batch', 'foo/bar', 'GET'),
95 95 ('/example-git/info/lfs/objects/batch', 'example-git', 'POST'),
96 96 # GIT LFS oid, dl/upl
97 97 ('/foo/info/lfs/abcdeabcde', 'foo', 'GET'),
98 98 ('/foo/bar/info/lfs/abcdeabcde', 'foo/bar', 'PUT'),
99 99 ('/my-git-repo/info/lfs/abcdeabcde', 'my-git-repo', 'POST'),
100 100 # Edge case: repo name conflicts with git commands
101 101 ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'GET'),
102 102 ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'POST'),
103 103
104 104 ])
105 def test_get_repository_name(url, expected_repo_name, request_method, pylonsapp):
105 def test_get_repository_name(url, expected_repo_name, request_method, pylonsapp, request_stub):
106 106 app = simplegit.SimpleGit(application=None,
107 107 config={'auth_ret_code': '', 'base_path': ''},
108 registry=None)
108 registry=request_stub.registry)
109 109 assert expected_repo_name == app._get_repository_name(
110 110 get_environ(url, request_method))
111 111
112 112
113 def test_get_config(pylonsapp, user_util):
113 def test_get_config(user_util, pylonsapp, request_stub):
114 114 repo = user_util.create_repo(repo_type='git')
115 115 app = simplegit.SimpleGit(application=None,
116 116 config={'auth_ret_code': '', 'base_path': ''},
117 registry=None)
117 registry=request_stub.registry)
118 118 extras = {'foo': 'FOO', 'bar': 'BAR'}
119 119
120 120 # We copy the extras as the method below will change the contents.
121 121 git_config = app._create_config(dict(extras), repo_name=repo.repo_name)
122 122
123 123 expected_config = dict(extras)
124 124 expected_config.update({
125 125 'git_update_server_info': False,
126 126 'git_lfs_enabled': False,
127 127 'git_lfs_store_path': git_config['git_lfs_store_path']
128 128 })
129 129
130 130 assert git_config == expected_config
131 131
132 132
133 def test_create_wsgi_app_uses_scm_app_from_simplevcs(pylonsapp):
133 def test_create_wsgi_app_uses_scm_app_from_simplevcs(pylonsapp, request_stub):
134 134 config = {
135 135 'auth_ret_code': '',
136 136 'base_path': '',
137 137 'vcs.scm_app_implementation':
138 138 'rhodecode.tests.lib.middleware.mock_scm_app',
139 139 }
140 app = simplegit.SimpleGit(application=None, config=config, registry=None)
140 app = simplegit.SimpleGit(application=None, config=config, registry=request_stub.registry)
141 141 wsgi_app = app._create_wsgi_app('/tmp/test', 'test_repo', {})
142 142 assert wsgi_app is mock_scm_app.mock_git_wsgi
@@ -1,128 +1,129 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 urlparse
22 22
23 23 import mock
24 24 import pytest
25 25 import simplejson as json
26 26
27 27 from rhodecode.lib.vcs.backends.base import Config
28 28 from rhodecode.tests.lib.middleware import mock_scm_app
29 29 import rhodecode.lib.middleware.simplehg as simplehg
30 30
31 31
32 32 def get_environ(url):
33 33 """Construct a minimum WSGI environ based on the URL."""
34 34 parsed_url = urlparse.urlparse(url)
35 35 environ = {
36 36 'PATH_INFO': parsed_url.path,
37 37 'QUERY_STRING': parsed_url.query,
38 38 }
39 39
40 40 return environ
41 41
42 42
43 43 @pytest.mark.parametrize(
44 44 'url, expected_action',
45 45 [
46 46 ('/foo/bar?cmd=unbundle&key=tip', 'push'),
47 47 ('/foo/bar?cmd=pushkey&key=tip', 'push'),
48 48 ('/foo/bar?cmd=listkeys&key=tip', 'pull'),
49 49 ('/foo/bar?cmd=changegroup&key=tip', 'pull'),
50 50 # Edge case: unknown argument: assume pull
51 51 ('/foo/bar?cmd=unknown&key=tip', 'pull'),
52 52 ('/foo/bar?cmd=&key=tip', 'pull'),
53 53 # Edge case: not cmd argument
54 54 ('/foo/bar?key=tip', 'pull'),
55 55 ])
56 def test_get_action(url, expected_action):
56 def test_get_action(url, expected_action, request_stub):
57 57 app = simplehg.SimpleHg(application=None,
58 58 config={'auth_ret_code': '', 'base_path': ''},
59 registry=None)
59 registry=request_stub.registry)
60 60 assert expected_action == app._get_action(get_environ(url))
61 61
62 62
63 63 @pytest.mark.parametrize(
64 64 'url, expected_repo_name',
65 65 [
66 66 ('/foo?cmd=unbundle&key=tip', 'foo'),
67 67 ('/foo/bar?cmd=pushkey&key=tip', 'foo/bar'),
68 68 ('/foo/bar/baz?cmd=listkeys&key=tip', 'foo/bar/baz'),
69 69 # Repos with trailing slashes.
70 70 ('/foo/?cmd=unbundle&key=tip', 'foo'),
71 71 ('/foo/bar/?cmd=pushkey&key=tip', 'foo/bar'),
72 72 ('/foo/bar/baz/?cmd=listkeys&key=tip', 'foo/bar/baz'),
73 73 ])
74 def test_get_repository_name(url, expected_repo_name):
74 def test_get_repository_name(url, expected_repo_name, request_stub):
75 75 app = simplehg.SimpleHg(application=None,
76 76 config={'auth_ret_code': '', 'base_path': ''},
77 registry=None)
77 registry=request_stub.registry)
78 78 assert expected_repo_name == app._get_repository_name(get_environ(url))
79 79
80 80
81 def test_get_config(pylonsapp, user_util):
81 def test_get_config(user_util, pylonsapp, request_stub):
82 82 repo = user_util.create_repo(repo_type='git')
83 83 app = simplehg.SimpleHg(application=None,
84 84 config={'auth_ret_code': '', 'base_path': ''},
85 registry=None)
85 registry=request_stub.registry)
86 86 extras = [('foo', 'FOO', 'bar', 'BAR')]
87 87
88 88 hg_config = app._create_config(extras, repo_name=repo.repo_name)
89 89
90 90 config = simplehg.utils.make_db_config(repo=repo.repo_name)
91 91 config.set('rhodecode', 'RC_SCM_DATA', json.dumps(extras))
92 92 hg_config_org = config
93 93
94 94 expected_config = [
95 95 ('vcs_svn_tag', 'ff89f8c714d135d865f44b90e5413b88de19a55f', '/tags/*'),
96 96 ('web', 'push_ssl', 'False'),
97 97 ('web', 'allow_push', '*'),
98 98 ('web', 'allow_archive', 'gz zip bz2'),
99 99 ('web', 'baseurl', '/'),
100 100 ('vcs_git_lfs', 'store_location', hg_config_org.get('vcs_git_lfs', 'store_location')),
101 101 ('vcs_svn_branch', '9aac1a38c3b8a0cdc4ae0f960a5f83332bc4fa5e', '/branches/*'),
102 102 ('vcs_svn_branch', 'c7e6a611c87da06529fd0dd733308481d67c71a8', '/trunk'),
103 103 ('largefiles', 'usercache', hg_config_org.get('largefiles', 'usercache')),
104 104 ('hooks', 'preoutgoing.pre_pull', 'python:vcsserver.hooks.pre_pull'),
105 105 ('hooks', 'prechangegroup.pre_push', 'python:vcsserver.hooks.pre_push'),
106 106 ('hooks', 'outgoing.pull_logger', 'python:vcsserver.hooks.log_pull_action'),
107 107 ('hooks', 'pretxnchangegroup.pre_push', 'python:vcsserver.hooks.pre_push'),
108 108 ('hooks', 'changegroup.push_logger', 'python:vcsserver.hooks.log_push_action'),
109 109 ('hooks', 'changegroup.repo_size', 'python:vcsserver.hooks.repo_size'),
110 110 ('phases', 'publish', 'True'),
111 111 ('extensions', 'largefiles', ''),
112 112 ('paths', '/', hg_config_org.get('paths', '/')),
113 113 ('rhodecode', 'RC_SCM_DATA', '[["foo", "FOO", "bar", "BAR"]]')
114 114 ]
115 115 for entry in expected_config:
116 116 assert entry in hg_config
117 117
118 118
119 def test_create_wsgi_app_uses_scm_app_from_simplevcs():
119 def test_create_wsgi_app_uses_scm_app_from_simplevcs(request_stub):
120 120 config = {
121 121 'auth_ret_code': '',
122 122 'base_path': '',
123 123 'vcs.scm_app_implementation':
124 124 'rhodecode.tests.lib.middleware.mock_scm_app',
125 125 }
126 app = simplehg.SimpleHg(application=None, config=config, registry=None)
126 app = simplehg.SimpleHg(
127 application=None, config=config, registry=request_stub.registry)
127 128 wsgi_app = app._create_wsgi_app('/tmp/test', 'test_repo', {})
128 129 assert wsgi_app is mock_scm_app.mock_hg_wsgi
@@ -1,201 +1,201 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 from StringIO import StringIO
22 22
23 23 import pytest
24 24 from mock import patch, Mock
25 25
26 26 import rhodecode
27 27 from rhodecode.lib.middleware.simplesvn import SimpleSvn, SimpleSvnApp
28 28
29 29
30 30 class TestSimpleSvn(object):
31 31 @pytest.fixture(autouse=True)
32 def simple_svn(self, pylonsapp):
32 def simple_svn(self, pylonsapp, request_stub):
33 33 self.app = SimpleSvn(
34 34 application='None',
35 35 config={'auth_ret_code': '',
36 36 'base_path': rhodecode.CONFIG['base_path']},
37 registry=None)
37 registry=request_stub.registry)
38 38
39 39 def test_get_config(self):
40 40 extras = {'foo': 'FOO', 'bar': 'BAR'}
41 41 config = self.app._create_config(extras, repo_name='test-repo')
42 42 assert config == extras
43 43
44 44 @pytest.mark.parametrize(
45 45 'method', ['OPTIONS', 'PROPFIND', 'GET', 'REPORT'])
46 46 def test_get_action_returns_pull(self, method):
47 47 environment = {'REQUEST_METHOD': method}
48 48 action = self.app._get_action(environment)
49 49 assert action == 'pull'
50 50
51 51 @pytest.mark.parametrize(
52 52 'method', [
53 53 'MKACTIVITY', 'PROPPATCH', 'PUT', 'CHECKOUT', 'MKCOL', 'MOVE',
54 54 'COPY', 'DELETE', 'LOCK', 'UNLOCK', 'MERGE'
55 55 ])
56 56 def test_get_action_returns_push(self, method):
57 57 environment = {'REQUEST_METHOD': method}
58 58 action = self.app._get_action(environment)
59 59 assert action == 'push'
60 60
61 61 @pytest.mark.parametrize(
62 62 'path, expected_name', [
63 63 ('/hello-svn', 'hello-svn'),
64 64 ('/hello-svn/', 'hello-svn'),
65 65 ('/group/hello-svn/', 'group/hello-svn'),
66 66 ('/group/hello-svn/!svn/vcc/default', 'group/hello-svn'),
67 67 ])
68 68 def test_get_repository_name(self, path, expected_name):
69 69 environment = {'PATH_INFO': path}
70 70 name = self.app._get_repository_name(environment)
71 71 assert name == expected_name
72 72
73 73 def test_get_repository_name_subfolder(self, backend_svn):
74 74 repo = backend_svn.repo
75 75 environment = {
76 76 'PATH_INFO': '/{}/path/with/subfolders'.format(repo.repo_name)}
77 77 name = self.app._get_repository_name(environment)
78 78 assert name == repo.repo_name
79 79
80 80 def test_create_wsgi_app(self):
81 81 with patch.object(SimpleSvn, '_is_svn_enabled') as mock_method:
82 82 mock_method.return_value = False
83 83 with patch('rhodecode.lib.middleware.simplesvn.DisabledSimpleSvnApp') as (
84 84 wsgi_app_mock):
85 85 config = Mock()
86 86 wsgi_app = self.app._create_wsgi_app(
87 87 repo_path='', repo_name='', config=config)
88 88
89 89 wsgi_app_mock.assert_called_once_with(config)
90 90 assert wsgi_app == wsgi_app_mock()
91 91
92 92 def test_create_wsgi_app_when_enabled(self):
93 93 with patch.object(SimpleSvn, '_is_svn_enabled') as mock_method:
94 94 mock_method.return_value = True
95 95 with patch('rhodecode.lib.middleware.simplesvn.SimpleSvnApp') as (
96 96 wsgi_app_mock):
97 97 config = Mock()
98 98 wsgi_app = self.app._create_wsgi_app(
99 99 repo_path='', repo_name='', config=config)
100 100
101 101 wsgi_app_mock.assert_called_once_with(config)
102 102 assert wsgi_app == wsgi_app_mock()
103 103
104 104
105 105
106 106 class TestSimpleSvnApp(object):
107 107 data = '<xml></xml>'
108 108 path = '/group/my-repo'
109 109 wsgi_input = StringIO(data)
110 110 environment = {
111 111 'HTTP_DAV': (
112 112 'http://subversion.tigris.org/xmlns/dav/svn/depth,'
113 113 ' http://subversion.tigris.org/xmlns/dav/svn/mergeinfo'),
114 114 'HTTP_USER_AGENT': 'SVN/1.8.11 (x86_64-linux) serf/1.3.8',
115 115 'REQUEST_METHOD': 'OPTIONS',
116 116 'PATH_INFO': path,
117 117 'wsgi.input': wsgi_input,
118 118 'CONTENT_TYPE': 'text/xml',
119 119 'CONTENT_LENGTH': '130'
120 120 }
121 121
122 122 def setup_method(self, method):
123 123 self.host = 'http://localhost/'
124 124 self.app = SimpleSvnApp(
125 125 config={'subversion_http_server_url': self.host})
126 126
127 127 def test_get_request_headers_with_content_type(self):
128 128 expected_headers = {
129 129 'Dav': self.environment['HTTP_DAV'],
130 130 'User-Agent': self.environment['HTTP_USER_AGENT'],
131 131 'Content-Type': self.environment['CONTENT_TYPE'],
132 132 'Content-Length': self.environment['CONTENT_LENGTH']
133 133 }
134 134 headers = self.app._get_request_headers(self.environment)
135 135 assert headers == expected_headers
136 136
137 137 def test_get_request_headers_without_content_type(self):
138 138 environment = self.environment.copy()
139 139 environment.pop('CONTENT_TYPE')
140 140 expected_headers = {
141 141 'Dav': environment['HTTP_DAV'],
142 142 'Content-Length': self.environment['CONTENT_LENGTH'],
143 143 'User-Agent': environment['HTTP_USER_AGENT'],
144 144 }
145 145 request_headers = self.app._get_request_headers(environment)
146 146 assert request_headers == expected_headers
147 147
148 148 def test_get_response_headers(self):
149 149 headers = {
150 150 'Connection': 'keep-alive',
151 151 'Keep-Alive': 'timeout=5, max=100',
152 152 'Transfer-Encoding': 'chunked',
153 153 'Content-Encoding': 'gzip',
154 154 'MS-Author-Via': 'DAV',
155 155 'SVN-Supported-Posts': 'create-txn-with-props'
156 156 }
157 157 expected_headers = [
158 158 ('MS-Author-Via', 'DAV'),
159 159 ('SVN-Supported-Posts', 'create-txn-with-props'),
160 160 ]
161 161 response_headers = self.app._get_response_headers(headers)
162 162 assert sorted(response_headers) == sorted(expected_headers)
163 163
164 164 def test_get_url(self):
165 165 url = self.app._get_url(self.path)
166 166 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
167 167 assert url == expected_url
168 168
169 169 def test_call(self):
170 170 start_response = Mock()
171 171 response_mock = Mock()
172 172 response_mock.headers = {
173 173 'Content-Encoding': 'gzip',
174 174 'MS-Author-Via': 'DAV',
175 175 'SVN-Supported-Posts': 'create-txn-with-props'
176 176 }
177 177 response_mock.status_code = 200
178 178 response_mock.reason = 'OK'
179 179 with patch('rhodecode.lib.middleware.simplesvn.requests.request') as (
180 180 request_mock):
181 181 request_mock.return_value = response_mock
182 182 self.app(self.environment, start_response)
183 183
184 184 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
185 185 expected_request_headers = {
186 186 'Dav': self.environment['HTTP_DAV'],
187 187 'User-Agent': self.environment['HTTP_USER_AGENT'],
188 188 'Content-Type': self.environment['CONTENT_TYPE'],
189 189 'Content-Length': self.environment['CONTENT_LENGTH']
190 190 }
191 191 expected_response_headers = [
192 192 ('SVN-Supported-Posts', 'create-txn-with-props'),
193 193 ('MS-Author-Via', 'DAV'),
194 194 ]
195 195 request_mock.assert_called_once_with(
196 196 self.environment['REQUEST_METHOD'], expected_url,
197 197 data=self.data, headers=expected_request_headers)
198 198 response_mock.iter_content.assert_called_once_with(chunk_size=1024)
199 199 args, _ = start_response.call_args
200 200 assert args[0] == '200 OK'
201 201 assert sorted(args[1]) == sorted(expected_response_headers)
@@ -1,485 +1,498 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 from rhodecode.lib.utils2 import AttributeDict
26 27 from rhodecode.tests.utils import CustomTestApp
27 28
28 29 from rhodecode.lib.caching_query import FromCache
29 30 from rhodecode.lib.hooks_daemon import DummyHooksCallbackDaemon
30 31 from rhodecode.lib.middleware import simplevcs
31 32 from rhodecode.lib.middleware.https_fixup import HttpsFixup
32 33 from rhodecode.lib.middleware.utils import scm_app_http
33 34 from rhodecode.model.db import User, _hash_key
34 35 from rhodecode.model.meta import Session
35 36 from rhodecode.tests import (
36 37 HG_REPO, TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
37 38 from rhodecode.tests.lib.middleware import mock_scm_app
38 39
39 40
40 41 class StubVCSController(simplevcs.SimpleVCS):
41 42
42 43 SCM = 'hg'
43 44 stub_response_body = tuple()
44 45
45 46 def __init__(self, *args, **kwargs):
46 47 super(StubVCSController, self).__init__(*args, **kwargs)
47 48 self._action = 'pull'
48 49 self._is_shadow_repo_dir = True
49 50 self._name = HG_REPO
50 51 self.set_repo_names(None)
51 52
52 53 @property
53 54 def is_shadow_repo_dir(self):
54 55 return self._is_shadow_repo_dir
55 56
56 57 def _get_repository_name(self, environ):
57 58 return self._name
58 59
59 60 def _get_action(self, environ):
60 61 return self._action
61 62
62 63 def _create_wsgi_app(self, repo_path, repo_name, config):
63 64 def fake_app(environ, start_response):
64 65 headers = [
65 66 ('Http-Accept', 'application/mercurial')
66 67 ]
67 68 start_response('200 OK', headers)
68 69 return self.stub_response_body
69 70 return fake_app
70 71
71 72 def _create_config(self, extras, repo_name):
72 73 return None
73 74
74 75
75 76 @pytest.fixture
76 def vcscontroller(pylonsapp, config_stub):
77 def vcscontroller(pylonsapp, config_stub, request_stub):
77 78 config_stub.testing_securitypolicy()
78 79 config_stub.include('rhodecode.authentication')
79 80
80 #set_anonymous_access(True)
81 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
81 controller = StubVCSController(
82 pylonsapp, pylonsapp.config, request_stub.registry)
82 83 app = HttpsFixup(controller, pylonsapp.config)
83 84 app = CustomTestApp(app)
84 85
85 86 _remove_default_user_from_query_cache()
86 87
87 88 # Sanity checks that things are set up correctly
88 89 app.get('/' + HG_REPO, status=200)
89 90
90 91 app.controller = controller
91 92 return app
92 93
93 94
94 95 def _remove_default_user_from_query_cache():
95 96 user = User.get_default_user(cache=True)
96 97 query = Session().query(User).filter(User.username == user.username)
97 98 query = query.options(
98 99 FromCache("sql_cache_short", "get_user_%s" % _hash_key(user.username)))
99 100 query.invalidate()
100 101 Session().expire(user)
101 102
102 103
103 104 def test_handles_exceptions_during_permissions_checks(
104 105 vcscontroller, disable_anonymous_user):
105 106 user_and_pass = '%s:%s' % (TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
106 107 auth_password = base64.encodestring(user_and_pass).strip()
107 108 extra_environ = {
108 109 'AUTH_TYPE': 'Basic',
109 110 'HTTP_AUTHORIZATION': 'Basic %s' % auth_password,
110 111 'REMOTE_USER': TEST_USER_ADMIN_LOGIN,
111 112 }
112 113
113 114 # Verify that things are hooked up correctly
114 115 vcscontroller.get('/', status=200, extra_environ=extra_environ)
115 116
116 117 # Simulate trouble during permission checks
117 118 with mock.patch('rhodecode.model.db.User.get_by_username',
118 119 side_effect=Exception) as get_user:
119 120 # Verify that a correct 500 is returned and check that the expected
120 121 # code path was hit.
121 122 vcscontroller.get('/', status=500, extra_environ=extra_environ)
122 123 assert get_user.called
123 124
124 125
125 126 def test_returns_forbidden_if_no_anonymous_access(
126 127 vcscontroller, disable_anonymous_user):
127 128 vcscontroller.get('/', status=401)
128 129
129 130
130 131 class StubFailVCSController(simplevcs.SimpleVCS):
131 132 def _handle_request(self, environ, start_response):
132 133 raise Exception("BOOM")
133 134
134 135
135 136 @pytest.fixture(scope='module')
136 137 def fail_controller(pylonsapp):
137 controller = StubFailVCSController(pylonsapp, pylonsapp.config, None)
138 controller = StubFailVCSController(
139 pylonsapp, pylonsapp.config, pylonsapp.config)
138 140 controller = HttpsFixup(controller, pylonsapp.config)
139 141 controller = CustomTestApp(controller)
140 142 return controller
141 143
142 144
143 145 def test_handles_exceptions_as_internal_server_error(fail_controller):
144 146 fail_controller.get('/', status=500)
145 147
146 148
147 149 def test_provides_traceback_for_appenlight(fail_controller):
148 150 response = fail_controller.get(
149 151 '/', status=500, extra_environ={'appenlight.client': 'fake'})
150 152 assert 'appenlight.__traceback' in response.request.environ
151 153
152 154
153 def test_provides_utils_scm_app_as_scm_app_by_default(pylonsapp):
154 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
155 def test_provides_utils_scm_app_as_scm_app_by_default(pylonsapp, request_stub):
156 controller = StubVCSController(
157 pylonsapp, pylonsapp.config, request_stub.registry)
155 158 assert controller.scm_app is scm_app_http
156 159
157 160
158 def test_allows_to_override_scm_app_via_config(pylonsapp):
161 def test_allows_to_override_scm_app_via_config(pylonsapp, request_stub):
159 162 config = pylonsapp.config.copy()
160 163 config['vcs.scm_app_implementation'] = (
161 164 'rhodecode.tests.lib.middleware.mock_scm_app')
162 controller = StubVCSController(pylonsapp, config, None)
165 controller = StubVCSController(
166 pylonsapp, config, request_stub.registry)
163 167 assert controller.scm_app is mock_scm_app
164 168
165 169
166 170 @pytest.mark.parametrize('query_string, expected', [
167 171 ('cmd=stub_command', True),
168 172 ('cmd=listkeys', False),
169 173 ])
170 174 def test_should_check_locking(query_string, expected):
171 175 result = simplevcs._should_check_locking(query_string)
172 176 assert result == expected
173 177
174 178
175 179 class TestShadowRepoRegularExpression(object):
176 180 pr_segment = 'pull-request'
177 181 shadow_segment = 'repository'
178 182
179 183 @pytest.mark.parametrize('url, expected', [
180 184 # repo with/without groups
181 185 ('My-Repo/{pr_segment}/1/{shadow_segment}', True),
182 186 ('Group/My-Repo/{pr_segment}/2/{shadow_segment}', True),
183 187 ('Group/Sub-Group/My-Repo/{pr_segment}/3/{shadow_segment}', True),
184 188 ('Group/Sub-Group1/Sub-Group2/My-Repo/{pr_segment}/3/{shadow_segment}', True),
185 189
186 190 # pull request ID
187 191 ('MyRepo/{pr_segment}/1/{shadow_segment}', True),
188 192 ('MyRepo/{pr_segment}/1234567890/{shadow_segment}', True),
189 193 ('MyRepo/{pr_segment}/-1/{shadow_segment}', False),
190 194 ('MyRepo/{pr_segment}/invalid/{shadow_segment}', False),
191 195
192 196 # unicode
193 197 (u'Sp€çîál-Repö/{pr_segment}/1/{shadow_segment}', True),
194 198 (u'Sp€çîál-Gröüp/Sp€çîál-Repö/{pr_segment}/1/{shadow_segment}', True),
195 199
196 200 # trailing/leading slash
197 201 ('/My-Repo/{pr_segment}/1/{shadow_segment}', False),
198 202 ('My-Repo/{pr_segment}/1/{shadow_segment}/', False),
199 203 ('/My-Repo/{pr_segment}/1/{shadow_segment}/', False),
200 204
201 205 # misc
202 206 ('My-Repo/{pr_segment}/1/{shadow_segment}/extra', False),
203 207 ('My-Repo/{pr_segment}/1/{shadow_segment}extra', False),
204 208 ])
205 209 def test_shadow_repo_regular_expression(self, url, expected):
206 210 from rhodecode.lib.middleware.simplevcs import SimpleVCS
207 211 url = url.format(
208 212 pr_segment=self.pr_segment,
209 213 shadow_segment=self.shadow_segment)
210 214 match_obj = SimpleVCS.shadow_repo_re.match(url)
211 215 assert (match_obj is not None) == expected
212 216
213 217
214 218 @pytest.mark.backends('git', 'hg')
215 219 class TestShadowRepoExposure(object):
216 220
217 def test_pull_on_shadow_repo_propagates_to_wsgi_app(self, pylonsapp):
221 def test_pull_on_shadow_repo_propagates_to_wsgi_app(
222 self, pylonsapp, request_stub):
218 223 """
219 224 Check that a pull action to a shadow repo is propagated to the
220 225 underlying wsgi app.
221 226 """
222 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
227 controller = StubVCSController(
228 pylonsapp, pylonsapp.config, request_stub.registry)
223 229 controller._check_ssl = mock.Mock()
224 230 controller.is_shadow_repo = True
225 231 controller._action = 'pull'
226 232 controller._is_shadow_repo_dir = True
227 233 controller.stub_response_body = 'dummy body value'
228 234 environ_stub = {
229 235 'HTTP_HOST': 'test.example.com',
230 236 'HTTP_ACCEPT': 'application/mercurial',
231 237 'REQUEST_METHOD': 'GET',
232 238 'wsgi.url_scheme': 'http',
233 239 }
234 240
235 241 response = controller(environ_stub, mock.Mock())
236 242 response_body = ''.join(response)
237 243
238 244 # Assert that we got the response from the wsgi app.
239 245 assert response_body == controller.stub_response_body
240 246
241 def test_pull_on_shadow_repo_that_is_missing(self, pylonsapp):
247 def test_pull_on_shadow_repo_that_is_missing(self, pylonsapp, request_stub):
242 248 """
243 249 Check that a pull action to a shadow repo is propagated to the
244 250 underlying wsgi app.
245 251 """
246 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
252 controller = StubVCSController(
253 pylonsapp, pylonsapp.config, request_stub.registry)
247 254 controller._check_ssl = mock.Mock()
248 255 controller.is_shadow_repo = True
249 256 controller._action = 'pull'
250 257 controller._is_shadow_repo_dir = False
251 258 controller.stub_response_body = 'dummy body value'
252 259 environ_stub = {
253 260 'HTTP_HOST': 'test.example.com',
254 261 'HTTP_ACCEPT': 'application/mercurial',
255 262 'REQUEST_METHOD': 'GET',
256 263 'wsgi.url_scheme': 'http',
257 264 }
258 265
259 266 response = controller(environ_stub, mock.Mock())
260 267 response_body = ''.join(response)
261 268
262 269 # Assert that we got the response from the wsgi app.
263 270 assert '404 Not Found' in response_body
264 271
265 def test_push_on_shadow_repo_raises(self, pylonsapp):
272 def test_push_on_shadow_repo_raises(self, pylonsapp, request_stub):
266 273 """
267 274 Check that a push action to a shadow repo is aborted.
268 275 """
269 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
276 controller = StubVCSController(
277 pylonsapp, pylonsapp.config, request_stub.registry)
270 278 controller._check_ssl = mock.Mock()
271 279 controller.is_shadow_repo = True
272 280 controller._action = 'push'
273 281 controller.stub_response_body = 'dummy body value'
274 282 environ_stub = {
275 283 'HTTP_HOST': 'test.example.com',
276 284 'HTTP_ACCEPT': 'application/mercurial',
277 285 'REQUEST_METHOD': 'GET',
278 286 'wsgi.url_scheme': 'http',
279 287 }
280 288
281 289 response = controller(environ_stub, mock.Mock())
282 290 response_body = ''.join(response)
283 291
284 292 assert response_body != controller.stub_response_body
285 293 # Assert that a 406 error is returned.
286 294 assert '406 Not Acceptable' in response_body
287 295
288 def test_set_repo_names_no_shadow(self, pylonsapp):
296 def test_set_repo_names_no_shadow(self, pylonsapp, request_stub):
289 297 """
290 298 Check that the set_repo_names method sets all names to the one returned
291 299 by the _get_repository_name method on a request to a non shadow repo.
292 300 """
293 301 environ_stub = {}
294 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
302 controller = StubVCSController(
303 pylonsapp, pylonsapp.config, request_stub.registry)
295 304 controller._name = 'RepoGroup/MyRepo'
296 305 controller.set_repo_names(environ_stub)
297 306 assert not controller.is_shadow_repo
298 307 assert (controller.url_repo_name ==
299 308 controller.acl_repo_name ==
300 309 controller.vcs_repo_name ==
301 310 controller._get_repository_name(environ_stub))
302 311
303 def test_set_repo_names_with_shadow(self, pylonsapp, pr_util, config_stub):
312 def test_set_repo_names_with_shadow(
313 self, pylonsapp, pr_util, config_stub, request_stub):
304 314 """
305 315 Check that the set_repo_names method sets correct names on a request
306 316 to a shadow repo.
307 317 """
308 318 from rhodecode.model.pull_request import PullRequestModel
309 319
310 320 pull_request = pr_util.create_pull_request()
311 321 shadow_url = '{target}/{pr_segment}/{pr_id}/{shadow_segment}'.format(
312 322 target=pull_request.target_repo.repo_name,
313 323 pr_id=pull_request.pull_request_id,
314 324 pr_segment=TestShadowRepoRegularExpression.pr_segment,
315 325 shadow_segment=TestShadowRepoRegularExpression.shadow_segment)
316 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
326 controller = StubVCSController(
327 pylonsapp, pylonsapp.config, request_stub.registry)
317 328 controller._name = shadow_url
318 329 controller.set_repo_names({})
319 330
320 331 # Get file system path to shadow repo for assertions.
321 332 workspace_id = PullRequestModel()._workspace_id(pull_request)
322 333 target_vcs = pull_request.target_repo.scm_instance()
323 334 vcs_repo_name = target_vcs._get_shadow_repository_path(
324 335 workspace_id)
325 336
326 337 assert controller.vcs_repo_name == vcs_repo_name
327 338 assert controller.url_repo_name == shadow_url
328 339 assert controller.acl_repo_name == pull_request.target_repo.repo_name
329 340 assert controller.is_shadow_repo
330 341
331 342 def test_set_repo_names_with_shadow_but_missing_pr(
332 self, pylonsapp, pr_util, config_stub):
343 self, pylonsapp, pr_util, config_stub, request_stub):
333 344 """
334 345 Checks that the set_repo_names method enforces matching target repos
335 346 and pull request IDs.
336 347 """
337 348 pull_request = pr_util.create_pull_request()
338 349 shadow_url = '{target}/{pr_segment}/{pr_id}/{shadow_segment}'.format(
339 350 target=pull_request.target_repo.repo_name,
340 351 pr_id=999999999,
341 352 pr_segment=TestShadowRepoRegularExpression.pr_segment,
342 353 shadow_segment=TestShadowRepoRegularExpression.shadow_segment)
343 controller = StubVCSController(pylonsapp, pylonsapp.config, None)
354 controller = StubVCSController(
355 pylonsapp, pylonsapp.config, request_stub.registry)
344 356 controller._name = shadow_url
345 357 controller.set_repo_names({})
346 358
347 359 assert not controller.is_shadow_repo
348 360 assert (controller.url_repo_name ==
349 361 controller.acl_repo_name ==
350 362 controller.vcs_repo_name)
351 363
352 364
353 365 @pytest.mark.usefixtures('db')
354 366 class TestGenerateVcsResponse(object):
355 367
356 368 def test_ensures_that_start_response_is_called_early_enough(self):
357 369 self.call_controller_with_response_body(iter(['a', 'b']))
358 370 assert self.start_response.called
359 371
360 372 def test_invalidates_cache_after_body_is_consumed(self):
361 373 result = self.call_controller_with_response_body(iter(['a', 'b']))
362 374 assert not self.was_cache_invalidated()
363 375 # Consume the result
364 376 list(result)
365 377 assert self.was_cache_invalidated()
366 378
367 379 @mock.patch('rhodecode.lib.middleware.simplevcs.HTTPLockedRC')
368 380 def test_handles_locking_exception(self, http_locked_rc):
369 381 result = self.call_controller_with_response_body(
370 382 self.raise_result_iter(vcs_kind='repo_locked'))
371 383 assert not http_locked_rc.called
372 384 # Consume the result
373 385 list(result)
374 386 assert http_locked_rc.called
375 387
376 388 @mock.patch('rhodecode.lib.middleware.simplevcs.HTTPRequirementError')
377 389 def test_handles_requirement_exception(self, http_requirement):
378 390 result = self.call_controller_with_response_body(
379 391 self.raise_result_iter(vcs_kind='requirement'))
380 392 assert not http_requirement.called
381 393 # Consume the result
382 394 list(result)
383 395 assert http_requirement.called
384 396
385 397 @mock.patch('rhodecode.lib.middleware.simplevcs.HTTPLockedRC')
386 398 def test_handles_locking_exception_in_app_call(self, http_locked_rc):
387 399 app_factory_patcher = mock.patch.object(
388 400 StubVCSController, '_create_wsgi_app')
389 401 with app_factory_patcher as app_factory:
390 402 app_factory().side_effect = self.vcs_exception()
391 403 result = self.call_controller_with_response_body(['a'])
392 404 list(result)
393 405 assert http_locked_rc.called
394 406
395 407 def test_raises_unknown_exceptions(self):
396 408 result = self.call_controller_with_response_body(
397 409 self.raise_result_iter(vcs_kind='unknown'))
398 410 with pytest.raises(Exception):
399 411 list(result)
400 412
401 413 def test_prepare_callback_daemon_is_called(self):
402 414 def side_effect(extras):
403 415 return DummyHooksCallbackDaemon(), extras
404 416
405 417 prepare_patcher = mock.patch.object(
406 418 StubVCSController, '_prepare_callback_daemon')
407 419 with prepare_patcher as prepare_mock:
408 420 prepare_mock.side_effect = side_effect
409 421 self.call_controller_with_response_body(iter(['a', 'b']))
410 422 assert prepare_mock.called
411 423 assert prepare_mock.call_count == 1
412 424
413 425 def call_controller_with_response_body(self, response_body):
414 426 settings = {
415 427 'base_path': 'fake_base_path',
416 428 'vcs.hooks.protocol': 'http',
417 429 'vcs.hooks.direct_calls': False,
418 430 }
419 controller = StubVCSController(None, settings, None)
431 registry = AttributeDict()
432 controller = StubVCSController(None, settings, registry)
420 433 controller._invalidate_cache = mock.Mock()
421 434 controller.stub_response_body = response_body
422 435 self.start_response = mock.Mock()
423 436 result = controller._generate_vcs_response(
424 437 environ={}, start_response=self.start_response,
425 438 repo_path='fake_repo_path',
426 439 extras={}, action='push')
427 440 self.controller = controller
428 441 return result
429 442
430 443 def raise_result_iter(self, vcs_kind='repo_locked'):
431 444 """
432 445 Simulates an exception due to a vcs raised exception if kind vcs_kind
433 446 """
434 447 raise self.vcs_exception(vcs_kind=vcs_kind)
435 448 yield "never_reached"
436 449
437 450 def vcs_exception(self, vcs_kind='repo_locked'):
438 451 locked_exception = Exception('TEST_MESSAGE')
439 452 locked_exception._vcs_kind = vcs_kind
440 453 return locked_exception
441 454
442 455 def was_cache_invalidated(self):
443 456 return self.controller._invalidate_cache.called
444 457
445 458
446 459 class TestInitializeGenerator(object):
447 460
448 461 def test_drains_first_element(self):
449 462 gen = self.factory(['__init__', 1, 2])
450 463 result = list(gen)
451 464 assert result == [1, 2]
452 465
453 466 @pytest.mark.parametrize('values', [
454 467 [],
455 468 [1, 2],
456 469 ])
457 470 def test_raises_value_error(self, values):
458 471 with pytest.raises(ValueError):
459 472 self.factory(values)
460 473
461 474 @simplevcs.initialize_generator
462 475 def factory(self, iterable):
463 476 for elem in iterable:
464 477 yield elem
465 478
466 479
467 480 class TestPrepareHooksDaemon(object):
468 def test_calls_imported_prepare_callback_daemon(self, app_settings):
481 def test_calls_imported_prepare_callback_daemon(self, app_settings, request_stub):
469 482 expected_extras = {'extra1': 'value1'}
470 483 daemon = DummyHooksCallbackDaemon()
471 484
472 controller = StubVCSController(None, app_settings, None)
485 controller = StubVCSController(None, app_settings, request_stub.registry)
473 486 prepare_patcher = mock.patch.object(
474 487 simplevcs, 'prepare_callback_daemon',
475 488 return_value=(daemon, expected_extras))
476 489 with prepare_patcher as prepare_mock:
477 490 callback_daemon, extras = controller._prepare_callback_daemon(
478 491 expected_extras.copy())
479 492 prepare_mock.assert_called_once_with(
480 493 expected_extras,
481 494 protocol=app_settings['vcs.hooks.protocol'],
482 495 use_direct_calls=app_settings['vcs.hooks.direct_calls'])
483 496
484 497 assert callback_daemon == daemon
485 498 assert extras == extras
General Comments 0
You need to be logged in to leave comments. Login now