##// END OF EJS Templates
git-lfs: fixed bug #5399 git-lfs application failed to generate HTTPS urls properly.
dan -
r3781:b4126386 default
parent child Browse files
Show More
@@ -1,155 +1,156 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 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 SimpleGit middleware for handling git protocol request (push/clone etc.)
23 23 It's implemented with basic auth function
24 24 """
25 25 import os
26 26 import re
27 27 import logging
28 28 import urlparse
29 29
30 30 import rhodecode
31 31 from rhodecode.lib import utils
32 32 from rhodecode.lib import utils2
33 33 from rhodecode.lib.middleware import simplevcs
34 34
35 35 log = logging.getLogger(__name__)
36 36
37 37
38 38 GIT_PROTO_PAT = re.compile(
39 39 r'^/(.+)/(info/refs|info/lfs/(.+)|git-upload-pack|git-receive-pack)')
40 40 GIT_LFS_PROTO_PAT = re.compile(r'^/(.+)/(info/lfs/(.+))')
41 41
42 42
43 43 def default_lfs_store():
44 44 """
45 45 Default lfs store location, it's consistent with Mercurials large file
46 46 store which is in .cache/largefiles
47 47 """
48 48 from rhodecode.lib.vcs.backends.git import lfs_store
49 49 user_home = os.path.expanduser("~")
50 50 return lfs_store(user_home)
51 51
52 52
53 53 class SimpleGit(simplevcs.SimpleVCS):
54 54
55 55 SCM = 'git'
56 56
57 57 def _get_repository_name(self, environ):
58 58 """
59 59 Gets repository name out of PATH_INFO header
60 60
61 61 :param environ: environ where PATH_INFO is stored
62 62 """
63 63 repo_name = GIT_PROTO_PAT.match(environ['PATH_INFO']).group(1)
64 64 # for GIT LFS, and bare format strip .git suffix from names
65 65 if repo_name.endswith('.git'):
66 66 repo_name = repo_name[:-4]
67 67 return repo_name
68 68
69 69 def _get_lfs_action(self, path, request_method):
70 70 """
71 71 return an action based on LFS requests type.
72 72 Those routes are handled inside vcsserver app.
73 73
74 74 batch -> POST to /info/lfs/objects/batch => PUSH/PULL
75 75 batch is based on the `operation.
76 76 that could be download or upload, but those are only
77 77 instructions to fetch so we return pull always
78 78
79 79 download -> GET to /info/lfs/{oid} => PULL
80 80 upload -> PUT to /info/lfs/{oid} => PUSH
81 81
82 82 verification -> POST to /info/lfs/verify => PULL
83 83
84 84 """
85 85
86 86 match_obj = GIT_LFS_PROTO_PAT.match(path)
87 87 _parts = match_obj.groups()
88 88 repo_name, path, operation = _parts
89 89 log.debug(
90 90 'LFS: detecting operation based on following '
91 91 'data: %s, req_method:%s', _parts, request_method)
92 92
93 93 if operation == 'verify':
94 94 return 'pull'
95 95 elif operation == 'objects/batch':
96 96 # batch sends back instructions for API to dl/upl we report it
97 97 # as pull
98 98 if request_method == 'POST':
99 99 return 'pull'
100 100
101 101 elif operation:
102 102 # probably a OID, upload is PUT, download a GET
103 103 if request_method == 'GET':
104 104 return 'pull'
105 105 else:
106 106 return 'push'
107 107
108 108 # if default not found require push, as action
109 109 return 'push'
110 110
111 111 _ACTION_MAPPING = {
112 112 'git-receive-pack': 'push',
113 113 'git-upload-pack': 'pull',
114 114 }
115 115
116 116 def _get_action(self, environ):
117 117 """
118 118 Maps git request commands into a pull or push command.
119 119 In case of unknown/unexpected data, it returns 'pull' to be safe.
120 120
121 121 :param environ:
122 122 """
123 123 path = environ['PATH_INFO']
124 124
125 125 if path.endswith('/info/refs'):
126 126 query = urlparse.parse_qs(environ['QUERY_STRING'])
127 127 service_cmd = query.get('service', [''])[0]
128 128 return self._ACTION_MAPPING.get(service_cmd, 'pull')
129 129
130 130 elif GIT_LFS_PROTO_PAT.match(environ['PATH_INFO']):
131 131 return self._get_lfs_action(
132 132 environ['PATH_INFO'], environ['REQUEST_METHOD'])
133 133
134 134 elif path.endswith('/git-receive-pack'):
135 135 return 'push'
136 136 elif path.endswith('/git-upload-pack'):
137 137 return 'pull'
138 138
139 139 return 'pull'
140 140
141 141 def _create_wsgi_app(self, repo_path, repo_name, config):
142 142 return self.scm_app.create_git_wsgi_app(
143 143 repo_path, repo_name, config)
144 144
145 def _create_config(self, extras, repo_name):
145 def _create_config(self, extras, repo_name, scheme='http'):
146 146 extras['git_update_server_info'] = utils2.str2bool(
147 147 rhodecode.CONFIG.get('git_update_server_info'))
148 148
149 149 config = utils.make_db_config(repo=repo_name)
150 150 custom_store = config.get('vcs_git_lfs', 'store_location')
151 151
152 152 extras['git_lfs_enabled'] = utils2.str2bool(
153 153 config.get('vcs_git_lfs', 'enabled'))
154 154 extras['git_lfs_store_path'] = custom_store or default_lfs_store()
155 extras['git_lfs_http_scheme'] = scheme
155 156 return extras
@@ -1,160 +1,160 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 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 SimpleHG middleware for handling mercurial protocol request
23 23 (push/clone etc.). It's implemented with basic auth function
24 24 """
25 25
26 26 import logging
27 27 import urlparse
28 28 import urllib
29 29
30 30 from rhodecode.lib import utils
31 31 from rhodecode.lib.ext_json import json
32 32 from rhodecode.lib.middleware import simplevcs
33 33
34 34 log = logging.getLogger(__name__)
35 35
36 36
37 37 class SimpleHg(simplevcs.SimpleVCS):
38 38
39 39 SCM = 'hg'
40 40
41 41 def _get_repository_name(self, environ):
42 42 """
43 43 Gets repository name out of PATH_INFO header
44 44
45 45 :param environ: environ where PATH_INFO is stored
46 46 """
47 47 repo_name = environ['PATH_INFO']
48 48 if repo_name and repo_name.startswith('/'):
49 49 # remove only the first leading /
50 50 repo_name = repo_name[1:]
51 51 return repo_name.rstrip('/')
52 52
53 53 _ACTION_MAPPING = {
54 54 'changegroup': 'pull',
55 55 'changegroupsubset': 'pull',
56 56 'getbundle': 'pull',
57 57 'stream_out': 'pull',
58 58 'listkeys': 'pull',
59 59 'between': 'pull',
60 60 'branchmap': 'pull',
61 61 'branches': 'pull',
62 62 'clonebundles': 'pull',
63 63 'capabilities': 'pull',
64 64 'debugwireargs': 'pull',
65 65 'heads': 'pull',
66 66 'lookup': 'pull',
67 67 'hello': 'pull',
68 68 'known': 'pull',
69 69
70 70 # largefiles
71 71 'putlfile': 'push',
72 72 'getlfile': 'pull',
73 73 'statlfile': 'pull',
74 74 'lheads': 'pull',
75 75
76 76 # evolve
77 77 'evoext_obshashrange_v1': 'pull',
78 78 'evoext_obshash': 'pull',
79 79 'evoext_obshash1': 'pull',
80 80
81 81 'unbundle': 'push',
82 82 'pushkey': 'push',
83 83 }
84 84
85 85 @classmethod
86 86 def _get_xarg_headers(cls, environ):
87 87 i = 1
88 88 chunks = [] # gather chunks stored in multiple 'hgarg_N'
89 89 while True:
90 90 head = environ.get('HTTP_X_HGARG_{}'.format(i))
91 91 if not head:
92 92 break
93 93 i += 1
94 94 chunks.append(urllib.unquote_plus(head))
95 95 full_arg = ''.join(chunks)
96 96 pref = 'cmds='
97 97 if full_arg.startswith(pref):
98 98 # strip the cmds= header defining our batch commands
99 99 full_arg = full_arg[len(pref):]
100 100 cmds = full_arg.split(';')
101 101 return cmds
102 102
103 103 @classmethod
104 104 def _get_batch_cmd(cls, environ):
105 105 """
106 106 Handle batch command send commands. Those are ';' separated commands
107 107 sent by batch command that server needs to execute. We need to extract
108 108 those, and map them to our ACTION_MAPPING to get all push/pull commands
109 109 specified in the batch
110 110 """
111 111 default = 'push'
112 112 batch_cmds = []
113 113 try:
114 114 cmds = cls._get_xarg_headers(environ)
115 115 for pair in cmds:
116 116 parts = pair.split(' ', 1)
117 117 if len(parts) != 2:
118 118 continue
119 119 # entry should be in a format `key ARGS`
120 120 cmd, args = parts
121 121 action = cls._ACTION_MAPPING.get(cmd, default)
122 122 batch_cmds.append(action)
123 123 except Exception:
124 124 log.exception('Failed to extract batch commands operations')
125 125
126 126 # in case we failed, (e.g malformed data) assume it's PUSH sub-command
127 127 # for safety
128 128 return batch_cmds or [default]
129 129
130 130 def _get_action(self, environ):
131 131 """
132 132 Maps mercurial request commands into a pull or push command.
133 133 In case of unknown/unexpected data, it returns 'push' to be safe.
134 134
135 135 :param environ:
136 136 """
137 137 default = 'push'
138 138 query = urlparse.parse_qs(environ['QUERY_STRING'],
139 139 keep_blank_values=True)
140 140
141 141 if 'cmd' in query:
142 142 cmd = query['cmd'][0]
143 143 if cmd == 'batch':
144 144 cmds = self._get_batch_cmd(environ)
145 145 if 'push' in cmds:
146 146 return 'push'
147 147 else:
148 148 return 'pull'
149 149 return self._ACTION_MAPPING.get(cmd, default)
150 150
151 151 return default
152 152
153 153 def _create_wsgi_app(self, repo_path, repo_name, config):
154 154 return self.scm_app.create_hg_wsgi_app(repo_path, repo_name, config)
155 155
156 def _create_config(self, extras, repo_name):
156 def _create_config(self, extras, repo_name, scheme='http'):
157 157 config = utils.make_db_config(repo=repo_name)
158 158 config.set('rhodecode', 'RC_SCM_DATA', json.dumps(extras))
159 159
160 160 return config.serialize()
@@ -1,228 +1,228 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 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 import logging
23 23 import urllib
24 24 import urlparse
25 25
26 26 import requests
27 27 from pyramid.httpexceptions import HTTPNotAcceptable
28 28
29 29 from rhodecode.lib import rc_cache
30 30 from rhodecode.lib.middleware import simplevcs
31 31 from rhodecode.lib.utils import is_valid_repo
32 32 from rhodecode.lib.utils2 import str2bool, safe_int
33 33 from rhodecode.lib.ext_json import json
34 34 from rhodecode.lib.hooks_daemon import store_txn_id_data
35 35
36 36
37 37 log = logging.getLogger(__name__)
38 38
39 39
40 40 class SimpleSvnApp(object):
41 41 IGNORED_HEADERS = [
42 42 'connection', 'keep-alive', 'content-encoding',
43 43 'transfer-encoding', 'content-length']
44 44 rc_extras = {}
45 45
46 46 def __init__(self, config):
47 47 self.config = config
48 48
49 49 def __call__(self, environ, start_response):
50 50 request_headers = self._get_request_headers(environ)
51 51 data = environ['wsgi.input']
52 52 req_method = environ['REQUEST_METHOD']
53 53 has_content_length = 'CONTENT_LENGTH' in environ
54 54 path_info = self._get_url(
55 55 self.config.get('subversion_http_server_url', ''), environ['PATH_INFO'])
56 56 transfer_encoding = environ.get('HTTP_TRANSFER_ENCODING', '')
57 57 log.debug('Handling: %s method via `%s`', req_method, path_info)
58 58
59 59 # stream control flag, based on request and content type...
60 60 stream = False
61 61
62 62 if req_method in ['MKCOL'] or has_content_length:
63 63 data_processed = False
64 64 # read chunk to check if we have txn-with-props
65 65 initial_data = data.read(1024)
66 66 if initial_data.startswith('(create-txn-with-props'):
67 67 data = initial_data + data.read()
68 68 # store on-the-fly our rc_extra using svn revision properties
69 69 # those can be read later on in hooks executed so we have a way
70 70 # to pass in the data into svn hooks
71 71 rc_data = base64.urlsafe_b64encode(json.dumps(self.rc_extras))
72 72 rc_data_len = len(rc_data)
73 73 # header defines data length, and serialized data
74 74 skel = ' rc-scm-extras {} {}'.format(rc_data_len, rc_data)
75 75 data = data[:-2] + skel + '))'
76 76 data_processed = True
77 77
78 78 if not data_processed:
79 79 # NOTE(johbo): Avoid that we end up with sending the request in chunked
80 80 # transfer encoding (mainly on Gunicorn). If we know the content
81 81 # length, then we should transfer the payload in one request.
82 82 data = initial_data + data.read()
83 83
84 84 if req_method in ['GET', 'PUT'] or transfer_encoding == 'chunked':
85 85 # NOTE(marcink): when getting/uploading files we want to STREAM content
86 86 # back to the client/proxy instead of buffering it here...
87 87 stream = True
88 88
89 89 stream = stream
90 90 log.debug('Calling SVN PROXY at `%s`, using method:%s. Stream: %s',
91 91 path_info, req_method, stream)
92 92 try:
93 93 response = requests.request(
94 94 req_method, path_info,
95 95 data=data, headers=request_headers, stream=stream)
96 96 except requests.ConnectionError:
97 97 log.exception('ConnectionError occurred for endpoint %s', path_info)
98 98 raise
99 99
100 100 if response.status_code not in [200, 401]:
101 101 text = '\n{}'.format(response.text) if response.text else ''
102 102 if response.status_code >= 500:
103 103 log.error('Got SVN response:%s with text:`%s`', response, text)
104 104 else:
105 105 log.debug('Got SVN response:%s with text:`%s`', response, text)
106 106 else:
107 107 log.debug('got response code: %s', response.status_code)
108 108
109 109 response_headers = self._get_response_headers(response.headers)
110 110
111 111 if response.headers.get('SVN-Txn-name'):
112 112 svn_tx_id = response.headers.get('SVN-Txn-name')
113 113 txn_id = rc_cache.utils.compute_key_from_params(
114 114 self.config['repository'], svn_tx_id)
115 115 port = safe_int(self.rc_extras['hooks_uri'].split(':')[-1])
116 116 store_txn_id_data(txn_id, {'port': port})
117 117
118 118 start_response(
119 119 '{} {}'.format(response.status_code, response.reason),
120 120 response_headers)
121 121 return response.iter_content(chunk_size=1024)
122 122
123 123 def _get_url(self, svn_http_server, path):
124 124 svn_http_server_url = (svn_http_server or '').rstrip('/')
125 125 url_path = urlparse.urljoin(svn_http_server_url + '/', (path or '').lstrip('/'))
126 126 url_path = urllib.quote(url_path, safe="/:=~+!$,;'")
127 127 return url_path
128 128
129 129 def _get_request_headers(self, environ):
130 130 headers = {}
131 131
132 132 for key in environ:
133 133 if not key.startswith('HTTP_'):
134 134 continue
135 135 new_key = key.split('_')
136 136 new_key = [k.capitalize() for k in new_key[1:]]
137 137 new_key = '-'.join(new_key)
138 138 headers[new_key] = environ[key]
139 139
140 140 if 'CONTENT_TYPE' in environ:
141 141 headers['Content-Type'] = environ['CONTENT_TYPE']
142 142
143 143 if 'CONTENT_LENGTH' in environ:
144 144 headers['Content-Length'] = environ['CONTENT_LENGTH']
145 145
146 146 return headers
147 147
148 148 def _get_response_headers(self, headers):
149 149 headers = [
150 150 (h, headers[h])
151 151 for h in headers
152 152 if h.lower() not in self.IGNORED_HEADERS
153 153 ]
154 154
155 155 return headers
156 156
157 157
158 158 class DisabledSimpleSvnApp(object):
159 159 def __init__(self, config):
160 160 self.config = config
161 161
162 162 def __call__(self, environ, start_response):
163 163 reason = 'Cannot handle SVN call because: SVN HTTP Proxy is not enabled'
164 164 log.warning(reason)
165 165 return HTTPNotAcceptable(reason)(environ, start_response)
166 166
167 167
168 168 class SimpleSvn(simplevcs.SimpleVCS):
169 169
170 170 SCM = 'svn'
171 171 READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT')
172 172 DEFAULT_HTTP_SERVER = 'http://localhost:8090'
173 173
174 174 def _get_repository_name(self, environ):
175 175 """
176 176 Gets repository name out of PATH_INFO header
177 177
178 178 :param environ: environ where PATH_INFO is stored
179 179 """
180 180 path = environ['PATH_INFO'].split('!')
181 181 repo_name = path[0].strip('/')
182 182
183 183 # SVN includes the whole path in it's requests, including
184 184 # subdirectories inside the repo. Therefore we have to search for
185 185 # the repo root directory.
186 186 if not is_valid_repo(
187 187 repo_name, self.base_path, explicit_scm=self.SCM):
188 188 current_path = ''
189 189 for component in repo_name.split('/'):
190 190 current_path += component
191 191 if is_valid_repo(
192 192 current_path, self.base_path, explicit_scm=self.SCM):
193 193 return current_path
194 194 current_path += '/'
195 195
196 196 return repo_name
197 197
198 198 def _get_action(self, environ):
199 199 return (
200 200 'pull'
201 201 if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS
202 202 else 'push')
203 203
204 204 def _should_use_callback_daemon(self, extras, environ, action):
205 205 # only MERGE command triggers hooks, so we don't want to start
206 206 # hooks server too many times. POST however starts the svn transaction
207 207 # so we also need to run the init of callback daemon of POST
208 208 if environ['REQUEST_METHOD'] in ['MERGE', 'POST']:
209 209 return True
210 210 return False
211 211
212 212 def _create_wsgi_app(self, repo_path, repo_name, config):
213 213 if self._is_svn_enabled():
214 214 return SimpleSvnApp(config)
215 215 # we don't have http proxy enabled return dummy request handler
216 216 return DisabledSimpleSvnApp(config)
217 217
218 218 def _is_svn_enabled(self):
219 219 conf = self.repo_vcs_config
220 220 return str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
221 221
222 def _create_config(self, extras, repo_name):
222 def _create_config(self, extras, repo_name, scheme='http'):
223 223 conf = self.repo_vcs_config
224 224 server_url = conf.get('vcs_svn_proxy', 'http_server_url')
225 225 server_url = server_url or self.DEFAULT_HTTP_SERVER
226 226
227 227 extras['subversion_http_server_url'] = server_url
228 228 return extras
@@ -1,669 +1,678 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2019 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 re
28 28 import logging
29 29 import importlib
30 30 from functools import wraps
31 31 from StringIO import StringIO
32 32 from lxml import etree
33 33
34 34 import time
35 35 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
36 36
37 37 from pyramid.httpexceptions import (
38 38 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
39 39 from zope.cachedescriptors.property import Lazy as LazyProperty
40 40
41 41 import rhodecode
42 42 from rhodecode.authentication.base import authenticate, VCS_TYPE, loadplugin
43 43 from rhodecode.lib import rc_cache
44 44 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
45 45 from rhodecode.lib.base import (
46 46 BasicAuth, get_ip_addr, get_user_agent, vcs_operation_context)
47 47 from rhodecode.lib.exceptions import (UserCreationError, NotAllowedToCreateUserError)
48 48 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
49 49 from rhodecode.lib.middleware import appenlight
50 50 from rhodecode.lib.middleware.utils import scm_app_http
51 51 from rhodecode.lib.utils import is_valid_repo, SLUG_RE
52 52 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool, safe_unicode
53 53 from rhodecode.lib.vcs.conf import settings as vcs_settings
54 54 from rhodecode.lib.vcs.backends import base
55 55
56 56 from rhodecode.model import meta
57 57 from rhodecode.model.db import User, Repository, PullRequest
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.pull_request import PullRequestModel
60 60 from rhodecode.model.settings import SettingsModel, VcsSettingsModel
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 def extract_svn_txn_id(acl_repo_name, data):
66 66 """
67 67 Helper method for extraction of svn txn_id from submitted XML data during
68 68 POST operations
69 69 """
70 70 try:
71 71 root = etree.fromstring(data)
72 72 pat = re.compile(r'/txn/(?P<txn_id>.*)')
73 73 for el in root:
74 74 if el.tag == '{DAV:}source':
75 75 for sub_el in el:
76 76 if sub_el.tag == '{DAV:}href':
77 77 match = pat.search(sub_el.text)
78 78 if match:
79 79 svn_tx_id = match.groupdict()['txn_id']
80 80 txn_id = rc_cache.utils.compute_key_from_params(
81 81 acl_repo_name, svn_tx_id)
82 82 return txn_id
83 83 except Exception:
84 84 log.exception('Failed to extract txn_id')
85 85
86 86
87 87 def initialize_generator(factory):
88 88 """
89 89 Initializes the returned generator by draining its first element.
90 90
91 91 This can be used to give a generator an initializer, which is the code
92 92 up to the first yield statement. This decorator enforces that the first
93 93 produced element has the value ``"__init__"`` to make its special
94 94 purpose very explicit in the using code.
95 95 """
96 96
97 97 @wraps(factory)
98 98 def wrapper(*args, **kwargs):
99 99 gen = factory(*args, **kwargs)
100 100 try:
101 101 init = gen.next()
102 102 except StopIteration:
103 103 raise ValueError('Generator must yield at least one element.')
104 104 if init != "__init__":
105 105 raise ValueError('First yielded element must be "__init__".')
106 106 return gen
107 107 return wrapper
108 108
109 109
110 110 class SimpleVCS(object):
111 111 """Common functionality for SCM HTTP handlers."""
112 112
113 113 SCM = 'unknown'
114 114
115 115 acl_repo_name = None
116 116 url_repo_name = None
117 117 vcs_repo_name = None
118 118 rc_extras = {}
119 119
120 120 # We have to handle requests to shadow repositories different than requests
121 121 # to normal repositories. Therefore we have to distinguish them. To do this
122 122 # we use this regex which will match only on URLs pointing to shadow
123 123 # repositories.
124 124 shadow_repo_re = re.compile(
125 125 '(?P<groups>(?:{slug_pat}/)*)' # repo groups
126 126 '(?P<target>{slug_pat})/' # target repo
127 127 'pull-request/(?P<pr_id>\d+)/' # pull request
128 128 'repository$' # shadow repo
129 129 .format(slug_pat=SLUG_RE.pattern))
130 130
131 131 def __init__(self, config, registry):
132 132 self.registry = registry
133 133 self.config = config
134 134 # re-populated by specialized middleware
135 135 self.repo_vcs_config = base.Config()
136 136 self.rhodecode_settings = SettingsModel().get_all_settings(cache=True)
137 137
138 138 registry.rhodecode_settings = self.rhodecode_settings
139 139 # authenticate this VCS request using authfunc
140 140 auth_ret_code_detection = \
141 141 str2bool(self.config.get('auth_ret_code_detection', False))
142 142 self.authenticate = BasicAuth(
143 143 '', authenticate, registry, config.get('auth_ret_code'),
144 144 auth_ret_code_detection)
145 145 self.ip_addr = '0.0.0.0'
146 146
147 147 @LazyProperty
148 148 def global_vcs_config(self):
149 149 try:
150 150 return VcsSettingsModel().get_ui_settings_as_config_obj()
151 151 except Exception:
152 152 return base.Config()
153 153
154 154 @property
155 155 def base_path(self):
156 156 settings_path = self.repo_vcs_config.get(*VcsSettingsModel.PATH_SETTING)
157 157
158 158 if not settings_path:
159 159 settings_path = self.global_vcs_config.get(*VcsSettingsModel.PATH_SETTING)
160 160
161 161 if not settings_path:
162 162 # try, maybe we passed in explicitly as config option
163 163 settings_path = self.config.get('base_path')
164 164
165 165 if not settings_path:
166 166 raise ValueError('FATAL: base_path is empty')
167 167 return settings_path
168 168
169 169 def set_repo_names(self, environ):
170 170 """
171 171 This will populate the attributes acl_repo_name, url_repo_name,
172 172 vcs_repo_name and is_shadow_repo. In case of requests to normal (non
173 173 shadow) repositories all names are equal. In case of requests to a
174 174 shadow repository the acl-name points to the target repo of the pull
175 175 request and the vcs-name points to the shadow repo file system path.
176 176 The url-name is always the URL used by the vcs client program.
177 177
178 178 Example in case of a shadow repo:
179 179 acl_repo_name = RepoGroup/MyRepo
180 180 url_repo_name = RepoGroup/MyRepo/pull-request/3/repository
181 181 vcs_repo_name = /repo/base/path/RepoGroup/.__shadow_MyRepo_pr-3'
182 182 """
183 183 # First we set the repo name from URL for all attributes. This is the
184 184 # default if handling normal (non shadow) repo requests.
185 185 self.url_repo_name = self._get_repository_name(environ)
186 186 self.acl_repo_name = self.vcs_repo_name = self.url_repo_name
187 187 self.is_shadow_repo = False
188 188
189 189 # Check if this is a request to a shadow repository.
190 190 match = self.shadow_repo_re.match(self.url_repo_name)
191 191 if match:
192 192 match_dict = match.groupdict()
193 193
194 194 # Build acl repo name from regex match.
195 195 acl_repo_name = safe_unicode('{groups}{target}'.format(
196 196 groups=match_dict['groups'] or '',
197 197 target=match_dict['target']))
198 198
199 199 # Retrieve pull request instance by ID from regex match.
200 200 pull_request = PullRequest.get(match_dict['pr_id'])
201 201
202 202 # Only proceed if we got a pull request and if acl repo name from
203 203 # URL equals the target repo name of the pull request.
204 204 if pull_request and \
205 205 (acl_repo_name == pull_request.target_repo.repo_name):
206 206 repo_id = pull_request.target_repo.repo_id
207 207 # Get file system path to shadow repository.
208 208 workspace_id = PullRequestModel()._workspace_id(pull_request)
209 209 target_vcs = pull_request.target_repo.scm_instance()
210 210 vcs_repo_name = target_vcs._get_shadow_repository_path(
211 211 repo_id, workspace_id)
212 212
213 213 # Store names for later usage.
214 214 self.vcs_repo_name = vcs_repo_name
215 215 self.acl_repo_name = acl_repo_name
216 216 self.is_shadow_repo = True
217 217
218 218 log.debug('Setting all VCS repository names: %s', {
219 219 'acl_repo_name': self.acl_repo_name,
220 220 'url_repo_name': self.url_repo_name,
221 221 'vcs_repo_name': self.vcs_repo_name,
222 222 })
223 223
224 224 @property
225 225 def scm_app(self):
226 226 custom_implementation = self.config['vcs.scm_app_implementation']
227 227 if custom_implementation == 'http':
228 228 log.info('Using HTTP implementation of scm app.')
229 229 scm_app_impl = scm_app_http
230 230 else:
231 231 log.info('Using custom implementation of scm_app: "{}"'.format(
232 232 custom_implementation))
233 233 scm_app_impl = importlib.import_module(custom_implementation)
234 234 return scm_app_impl
235 235
236 236 def _get_by_id(self, repo_name):
237 237 """
238 238 Gets a special pattern _<ID> from clone url and tries to replace it
239 239 with a repository_name for support of _<ID> non changeable urls
240 240 """
241 241
242 242 data = repo_name.split('/')
243 243 if len(data) >= 2:
244 244 from rhodecode.model.repo import RepoModel
245 245 by_id_match = RepoModel().get_repo_by_id(repo_name)
246 246 if by_id_match:
247 247 data[1] = by_id_match.repo_name
248 248
249 249 return safe_str('/'.join(data))
250 250
251 251 def _invalidate_cache(self, repo_name):
252 252 """
253 253 Set's cache for this repository for invalidation on next access
254 254
255 255 :param repo_name: full repo name, also a cache key
256 256 """
257 257 ScmModel().mark_for_invalidation(repo_name)
258 258
259 259 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
260 260 db_repo = Repository.get_by_repo_name(repo_name)
261 261 if not db_repo:
262 262 log.debug('Repository `%s` not found inside the database.',
263 263 repo_name)
264 264 return False
265 265
266 266 if db_repo.repo_type != scm_type:
267 267 log.warning(
268 268 'Repository `%s` have incorrect scm_type, expected %s got %s',
269 269 repo_name, db_repo.repo_type, scm_type)
270 270 return False
271 271
272 272 config = db_repo._config
273 273 config.set('extensions', 'largefiles', '')
274 274 return is_valid_repo(
275 275 repo_name, base_path,
276 276 explicit_scm=scm_type, expect_scm=scm_type, config=config)
277 277
278 278 def valid_and_active_user(self, user):
279 279 """
280 280 Checks if that user is not empty, and if it's actually object it checks
281 281 if he's active.
282 282
283 283 :param user: user object or None
284 284 :return: boolean
285 285 """
286 286 if user is None:
287 287 return False
288 288
289 289 elif user.active:
290 290 return True
291 291
292 292 return False
293 293
294 294 @property
295 295 def is_shadow_repo_dir(self):
296 296 return os.path.isdir(self.vcs_repo_name)
297 297
298 298 def _check_permission(self, action, user, auth_user, repo_name, ip_addr=None,
299 299 plugin_id='', plugin_cache_active=False, cache_ttl=0):
300 300 """
301 301 Checks permissions using action (push/pull) user and repository
302 302 name. If plugin_cache and ttl is set it will use the plugin which
303 303 authenticated the user to store the cached permissions result for N
304 304 amount of seconds as in cache_ttl
305 305
306 306 :param action: push or pull action
307 307 :param user: user instance
308 308 :param repo_name: repository name
309 309 """
310 310
311 311 log.debug('AUTH_CACHE_TTL for permissions `%s` active: %s (TTL: %s)',
312 312 plugin_id, plugin_cache_active, cache_ttl)
313 313
314 314 user_id = user.user_id
315 315 cache_namespace_uid = 'cache_user_auth.{}'.format(user_id)
316 316 region = rc_cache.get_or_create_region('cache_perms', cache_namespace_uid)
317 317
318 318 @region.conditional_cache_on_arguments(namespace=cache_namespace_uid,
319 319 expiration_time=cache_ttl,
320 320 condition=plugin_cache_active)
321 321 def compute_perm_vcs(
322 322 cache_name, plugin_id, action, user_id, repo_name, ip_addr):
323 323
324 324 log.debug('auth: calculating permission access now...')
325 325 # check IP
326 326 inherit = user.inherit_default_permissions
327 327 ip_allowed = AuthUser.check_ip_allowed(
328 328 user_id, ip_addr, inherit_from_default=inherit)
329 329 if ip_allowed:
330 330 log.info('Access for IP:%s allowed', ip_addr)
331 331 else:
332 332 return False
333 333
334 334 if action == 'push':
335 335 perms = ('repository.write', 'repository.admin')
336 336 if not HasPermissionAnyMiddleware(*perms)(auth_user, repo_name):
337 337 return False
338 338
339 339 else:
340 340 # any other action need at least read permission
341 341 perms = (
342 342 'repository.read', 'repository.write', 'repository.admin')
343 343 if not HasPermissionAnyMiddleware(*perms)(auth_user, repo_name):
344 344 return False
345 345
346 346 return True
347 347
348 348 start = time.time()
349 349 log.debug('Running plugin `%s` permissions check', plugin_id)
350 350
351 351 # for environ based auth, password can be empty, but then the validation is
352 352 # on the server that fills in the env data needed for authentication
353 353 perm_result = compute_perm_vcs(
354 354 'vcs_permissions', plugin_id, action, user.user_id, repo_name, ip_addr)
355 355
356 356 auth_time = time.time() - start
357 357 log.debug('Permissions for plugin `%s` completed in %.3fs, '
358 358 'expiration time of fetched cache %.1fs.',
359 359 plugin_id, auth_time, cache_ttl)
360 360
361 361 return perm_result
362 362
363 def _get_http_scheme(self, environ):
364 try:
365 return environ['wsgi.url_scheme']
366 except Exception:
367 log.exception('Failed to read http scheme')
368 return 'http'
369
363 370 def _check_ssl(self, environ, start_response):
364 371 """
365 372 Checks the SSL check flag and returns False if SSL is not present
366 373 and required True otherwise
367 374 """
368 375 org_proto = environ['wsgi._org_proto']
369 376 # check if we have SSL required ! if not it's a bad request !
370 377 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
371 378 if require_ssl and org_proto == 'http':
372 379 log.debug(
373 380 'Bad request: detected protocol is `%s` and '
374 381 'SSL/HTTPS is required.', org_proto)
375 382 return False
376 383 return True
377 384
378 385 def _get_default_cache_ttl(self):
379 386 # take AUTH_CACHE_TTL from the `rhodecode` auth plugin
380 387 plugin = loadplugin('egg:rhodecode-enterprise-ce#rhodecode')
381 388 plugin_settings = plugin.get_settings()
382 389 plugin_cache_active, cache_ttl = plugin.get_ttl_cache(
383 390 plugin_settings) or (False, 0)
384 391 return plugin_cache_active, cache_ttl
385 392
386 393 def __call__(self, environ, start_response):
387 394 try:
388 395 return self._handle_request(environ, start_response)
389 396 except Exception:
390 397 log.exception("Exception while handling request")
391 398 appenlight.track_exception(environ)
392 399 return HTTPInternalServerError()(environ, start_response)
393 400 finally:
394 401 meta.Session.remove()
395 402
396 403 def _handle_request(self, environ, start_response):
397 404 if not self._check_ssl(environ, start_response):
398 405 reason = ('SSL required, while RhodeCode was unable '
399 406 'to detect this as SSL request')
400 407 log.debug('User not allowed to proceed, %s', reason)
401 408 return HTTPNotAcceptable(reason)(environ, start_response)
402 409
403 410 if not self.url_repo_name:
404 411 log.warning('Repository name is empty: %s', self.url_repo_name)
405 412 # failed to get repo name, we fail now
406 413 return HTTPNotFound()(environ, start_response)
407 414 log.debug('Extracted repo name is %s', self.url_repo_name)
408 415
409 416 ip_addr = get_ip_addr(environ)
410 417 user_agent = get_user_agent(environ)
411 418 username = None
412 419
413 420 # skip passing error to error controller
414 421 environ['pylons.status_code_redirect'] = True
415 422
416 423 # ======================================================================
417 424 # GET ACTION PULL or PUSH
418 425 # ======================================================================
419 426 action = self._get_action(environ)
420 427
421 428 # ======================================================================
422 429 # Check if this is a request to a shadow repository of a pull request.
423 430 # In this case only pull action is allowed.
424 431 # ======================================================================
425 432 if self.is_shadow_repo and action != 'pull':
426 433 reason = 'Only pull action is allowed for shadow repositories.'
427 434 log.debug('User not allowed to proceed, %s', reason)
428 435 return HTTPNotAcceptable(reason)(environ, start_response)
429 436
430 437 # Check if the shadow repo actually exists, in case someone refers
431 438 # to it, and it has been deleted because of successful merge.
432 439 if self.is_shadow_repo and not self.is_shadow_repo_dir:
433 440 log.debug(
434 441 'Shadow repo detected, and shadow repo dir `%s` is missing',
435 442 self.is_shadow_repo_dir)
436 443 return HTTPNotFound()(environ, start_response)
437 444
438 445 # ======================================================================
439 446 # CHECK ANONYMOUS PERMISSION
440 447 # ======================================================================
441 448 detect_force_push = False
442 449 check_branch_perms = False
443 450 if action in ['pull', 'push']:
444 451 user_obj = anonymous_user = User.get_default_user()
445 452 auth_user = user_obj.AuthUser()
446 453 username = anonymous_user.username
447 454 if anonymous_user.active:
448 455 plugin_cache_active, cache_ttl = self._get_default_cache_ttl()
449 456 # ONLY check permissions if the user is activated
450 457 anonymous_perm = self._check_permission(
451 458 action, anonymous_user, auth_user, self.acl_repo_name, ip_addr,
452 459 plugin_id='anonymous_access',
453 460 plugin_cache_active=plugin_cache_active,
454 461 cache_ttl=cache_ttl,
455 462 )
456 463 else:
457 464 anonymous_perm = False
458 465
459 466 if not anonymous_user.active or not anonymous_perm:
460 467 if not anonymous_user.active:
461 468 log.debug('Anonymous access is disabled, running '
462 469 'authentication')
463 470
464 471 if not anonymous_perm:
465 472 log.debug('Not enough credentials to access this '
466 473 'repository as anonymous user')
467 474
468 475 username = None
469 476 # ==============================================================
470 477 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
471 478 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
472 479 # ==============================================================
473 480
474 481 # try to auth based on environ, container auth methods
475 482 log.debug('Running PRE-AUTH for container based authentication')
476 483 pre_auth = authenticate(
477 484 '', '', environ, VCS_TYPE, registry=self.registry,
478 485 acl_repo_name=self.acl_repo_name)
479 486 if pre_auth and pre_auth.get('username'):
480 487 username = pre_auth['username']
481 488 log.debug('PRE-AUTH got %s as username', username)
482 489 if pre_auth:
483 490 log.debug('PRE-AUTH successful from %s',
484 491 pre_auth.get('auth_data', {}).get('_plugin'))
485 492
486 493 # If not authenticated by the container, running basic auth
487 494 # before inject the calling repo_name for special scope checks
488 495 self.authenticate.acl_repo_name = self.acl_repo_name
489 496
490 497 plugin_cache_active, cache_ttl = False, 0
491 498 plugin = None
492 499 if not username:
493 500 self.authenticate.realm = self.authenticate.get_rc_realm()
494 501
495 502 try:
496 503 auth_result = self.authenticate(environ)
497 504 except (UserCreationError, NotAllowedToCreateUserError) as e:
498 505 log.error(e)
499 506 reason = safe_str(e)
500 507 return HTTPNotAcceptable(reason)(environ, start_response)
501 508
502 509 if isinstance(auth_result, dict):
503 510 AUTH_TYPE.update(environ, 'basic')
504 511 REMOTE_USER.update(environ, auth_result['username'])
505 512 username = auth_result['username']
506 513 plugin = auth_result.get('auth_data', {}).get('_plugin')
507 514 log.info(
508 515 'MAIN-AUTH successful for user `%s` from %s plugin',
509 516 username, plugin)
510 517
511 518 plugin_cache_active, cache_ttl = auth_result.get(
512 519 'auth_data', {}).get('_ttl_cache') or (False, 0)
513 520 else:
514 521 return auth_result.wsgi_application(environ, start_response)
515 522
516 523 # ==============================================================
517 524 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
518 525 # ==============================================================
519 526 user = User.get_by_username(username)
520 527 if not self.valid_and_active_user(user):
521 528 return HTTPForbidden()(environ, start_response)
522 529 username = user.username
523 530 user_id = user.user_id
524 531
525 532 # check user attributes for password change flag
526 533 user_obj = user
527 534 auth_user = user_obj.AuthUser()
528 535 if user_obj and user_obj.username != User.DEFAULT_USER and \
529 536 user_obj.user_data.get('force_password_change'):
530 537 reason = 'password change required'
531 538 log.debug('User not allowed to authenticate, %s', reason)
532 539 return HTTPNotAcceptable(reason)(environ, start_response)
533 540
534 541 # check permissions for this repository
535 542 perm = self._check_permission(
536 543 action, user, auth_user, self.acl_repo_name, ip_addr,
537 544 plugin, plugin_cache_active, cache_ttl)
538 545 if not perm:
539 546 return HTTPForbidden()(environ, start_response)
540 547 environ['rc_auth_user_id'] = user_id
541 548
542 549 if action == 'push':
543 550 perms = auth_user.get_branch_permissions(self.acl_repo_name)
544 551 if perms:
545 552 check_branch_perms = True
546 553 detect_force_push = True
547 554
548 555 # extras are injected into UI object and later available
549 556 # in hooks executed by RhodeCode
550 557 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
551 558
552 559 extras = vcs_operation_context(
553 560 environ, repo_name=self.acl_repo_name, username=username,
554 561 action=action, scm=self.SCM, check_locking=check_locking,
555 562 is_shadow_repo=self.is_shadow_repo, check_branch_perms=check_branch_perms,
556 563 detect_force_push=detect_force_push
557 564 )
558 565
559 566 # ======================================================================
560 567 # REQUEST HANDLING
561 568 # ======================================================================
562 569 repo_path = os.path.join(
563 570 safe_str(self.base_path), safe_str(self.vcs_repo_name))
564 571 log.debug('Repository path is %s', repo_path)
565 572
566 573 fix_PATH()
567 574
568 575 log.info(
569 576 '%s action on %s repo "%s" by "%s" from %s %s',
570 577 action, self.SCM, safe_str(self.url_repo_name),
571 578 safe_str(username), ip_addr, user_agent)
572 579
573 580 return self._generate_vcs_response(
574 581 environ, start_response, repo_path, extras, action)
575 582
576 583 @initialize_generator
577 584 def _generate_vcs_response(
578 585 self, environ, start_response, repo_path, extras, action):
579 586 """
580 587 Returns a generator for the response content.
581 588
582 589 This method is implemented as a generator, so that it can trigger
583 590 the cache validation after all content sent back to the client. It
584 591 also handles the locking exceptions which will be triggered when
585 592 the first chunk is produced by the underlying WSGI application.
586 593 """
587 594 txn_id = ''
588 595 if 'CONTENT_LENGTH' in environ and environ['REQUEST_METHOD'] == 'MERGE':
589 596 # case for SVN, we want to re-use the callback daemon port
590 597 # so we use the txn_id, for this we peek the body, and still save
591 598 # it as wsgi.input
592 599 data = environ['wsgi.input'].read()
593 600 environ['wsgi.input'] = StringIO(data)
594 601 txn_id = extract_svn_txn_id(self.acl_repo_name, data)
595 602
596 603 callback_daemon, extras = self._prepare_callback_daemon(
597 604 extras, environ, action, txn_id=txn_id)
598 605 log.debug('HOOKS extras is %s', extras)
599 606
600 config = self._create_config(extras, self.acl_repo_name)
607 http_scheme = self._get_http_scheme(environ)
608
609 config = self._create_config(extras, self.acl_repo_name, scheme=http_scheme)
601 610 app = self._create_wsgi_app(repo_path, self.url_repo_name, config)
602 611 with callback_daemon:
603 612 app.rc_extras = extras
604 613
605 614 try:
606 615 response = app(environ, start_response)
607 616 finally:
608 617 # This statement works together with the decorator
609 618 # "initialize_generator" above. The decorator ensures that
610 619 # we hit the first yield statement before the generator is
611 620 # returned back to the WSGI server. This is needed to
612 621 # ensure that the call to "app" above triggers the
613 622 # needed callback to "start_response" before the
614 623 # generator is actually used.
615 624 yield "__init__"
616 625
617 626 # iter content
618 627 for chunk in response:
619 628 yield chunk
620 629
621 630 try:
622 631 # invalidate cache on push
623 632 if action == 'push':
624 633 self._invalidate_cache(self.url_repo_name)
625 634 finally:
626 635 meta.Session.remove()
627 636
628 637 def _get_repository_name(self, environ):
629 638 """Get repository name out of the environmnent
630 639
631 640 :param environ: WSGI environment
632 641 """
633 642 raise NotImplementedError()
634 643
635 644 def _get_action(self, environ):
636 645 """Map request commands into a pull or push command.
637 646
638 647 :param environ: WSGI environment
639 648 """
640 649 raise NotImplementedError()
641 650
642 651 def _create_wsgi_app(self, repo_path, repo_name, config):
643 652 """Return the WSGI app that will finally handle the request."""
644 653 raise NotImplementedError()
645 654
646 def _create_config(self, extras, repo_name):
655 def _create_config(self, extras, repo_name, scheme='http'):
647 656 """Create a safe config representation."""
648 657 raise NotImplementedError()
649 658
650 659 def _should_use_callback_daemon(self, extras, environ, action):
651 660 return True
652 661
653 662 def _prepare_callback_daemon(self, extras, environ, action, txn_id=None):
654 663 direct_calls = vcs_settings.HOOKS_DIRECT_CALLS
655 664 if not self._should_use_callback_daemon(extras, environ, action):
656 665 # disable callback daemon for actions that don't require it
657 666 direct_calls = True
658 667
659 668 return prepare_callback_daemon(
660 669 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
661 670 host=vcs_settings.HOOKS_HOST, use_direct_calls=direct_calls, txn_id=txn_id)
662 671
663 672
664 673 def _should_check_locking(query_string):
665 674 # this is kind of hacky, but due to how mercurial handles client-server
666 675 # server see all operation on commit; bookmarks, phases and
667 676 # obsolescence marker in different transaction, we don't want to check
668 677 # locking on those
669 678 return query_string not in ['cmd=listkeys']
@@ -1,139 +1,140 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 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 76 def test_get_action(url, expected_action, request_method, baseapp, request_stub):
77 77 app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''},
78 78 registry=request_stub.registry)
79 79 assert expected_action == app._get_action(get_environ(url, request_method))
80 80
81 81
82 82 @pytest.mark.parametrize(
83 83 'url, expected_repo_name, request_method',
84 84 [
85 85 ('/foo/info/refs?service=git-upload-pack', 'foo', 'GET'),
86 86 ('/foo/bar/info/refs?service=git-receive-pack', 'foo/bar', 'GET'),
87 87 ('/foo/git-upload-pack', 'foo', 'GET'),
88 88 ('/foo/git-receive-pack', 'foo', 'GET'),
89 89 ('/foo/bar/git-upload-pack', 'foo/bar', 'GET'),
90 90 ('/foo/bar/git-receive-pack', 'foo/bar', 'GET'),
91 91
92 92 # GIT LFS cases, batch
93 93 ('/foo/bar/info/lfs/objects/batch', 'foo/bar', 'GET'),
94 94 ('/example-git/info/lfs/objects/batch', 'example-git', 'POST'),
95 95 # GIT LFS oid, dl/upl
96 96 ('/foo/info/lfs/abcdeabcde', 'foo', 'GET'),
97 97 ('/foo/bar/info/lfs/abcdeabcde', 'foo/bar', 'PUT'),
98 98 ('/my-git-repo/info/lfs/abcdeabcde', 'my-git-repo', 'POST'),
99 99 # Edge case: repo name conflicts with git commands
100 100 ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'GET'),
101 101 ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'POST'),
102 102
103 103 ])
104 104 def test_get_repository_name(url, expected_repo_name, request_method, baseapp, request_stub):
105 105 app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''},
106 106 registry=request_stub.registry)
107 107 assert expected_repo_name == app._get_repository_name(
108 108 get_environ(url, request_method))
109 109
110 110
111 111 def test_get_config(user_util, baseapp, request_stub):
112 112 repo = user_util.create_repo(repo_type='git')
113 113 app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''},
114 114 registry=request_stub.registry)
115 115 extras = {'foo': 'FOO', 'bar': 'BAR'}
116 116
117 117 # We copy the extras as the method below will change the contents.
118 118 git_config = app._create_config(dict(extras), repo_name=repo.repo_name)
119 119
120 120 expected_config = dict(extras)
121 121 expected_config.update({
122 122 'git_update_server_info': False,
123 123 'git_lfs_enabled': False,
124 'git_lfs_store_path': git_config['git_lfs_store_path']
124 'git_lfs_store_path': git_config['git_lfs_store_path'],
125 'git_lfs_http_scheme': 'http'
125 126 })
126 127
127 128 assert git_config == expected_config
128 129
129 130
130 131 def test_create_wsgi_app_uses_scm_app_from_simplevcs(baseapp, request_stub):
131 132 config = {
132 133 'auth_ret_code': '',
133 134 'base_path': '',
134 135 'vcs.scm_app_implementation':
135 136 'rhodecode.tests.lib.middleware.mock_scm_app',
136 137 }
137 138 app = simplegit.SimpleGit(config=config, registry=request_stub.registry)
138 139 wsgi_app = app._create_wsgi_app('/tmp/test', 'test_repo', {})
139 140 assert wsgi_app is mock_scm_app.mock_git_wsgi
@@ -1,475 +1,475 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2019 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import base64
22 22
23 23 import mock
24 24 import pytest
25 25
26 26 from rhodecode.lib.utils2 import AttributeDict
27 27 from rhodecode.tests.utils import CustomTestApp
28 28
29 29 from rhodecode.lib.caching_query import FromCache
30 30 from rhodecode.lib.hooks_daemon import DummyHooksCallbackDaemon
31 31 from rhodecode.lib.middleware import simplevcs
32 32 from rhodecode.lib.middleware.https_fixup import HttpsFixup
33 33 from rhodecode.lib.middleware.utils import scm_app_http
34 34 from rhodecode.model.db import User, _hash_key
35 35 from rhodecode.model.meta import Session
36 36 from rhodecode.tests import (
37 37 HG_REPO, TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
38 38 from rhodecode.tests.lib.middleware import mock_scm_app
39 39
40 40
41 41 class StubVCSController(simplevcs.SimpleVCS):
42 42
43 43 SCM = 'hg'
44 44 stub_response_body = tuple()
45 45
46 46 def __init__(self, *args, **kwargs):
47 47 super(StubVCSController, self).__init__(*args, **kwargs)
48 48 self._action = 'pull'
49 49 self._is_shadow_repo_dir = True
50 50 self._name = HG_REPO
51 51 self.set_repo_names(None)
52 52
53 53 @property
54 54 def is_shadow_repo_dir(self):
55 55 return self._is_shadow_repo_dir
56 56
57 57 def _get_repository_name(self, environ):
58 58 return self._name
59 59
60 60 def _get_action(self, environ):
61 61 return self._action
62 62
63 63 def _create_wsgi_app(self, repo_path, repo_name, config):
64 64 def fake_app(environ, start_response):
65 65 headers = [
66 66 ('Http-Accept', 'application/mercurial')
67 67 ]
68 68 start_response('200 OK', headers)
69 69 return self.stub_response_body
70 70 return fake_app
71 71
72 def _create_config(self, extras, repo_name):
72 def _create_config(self, extras, repo_name, scheme='http'):
73 73 return None
74 74
75 75
76 76 @pytest.fixture
77 77 def vcscontroller(baseapp, config_stub, request_stub):
78 78 config_stub.testing_securitypolicy()
79 79 config_stub.include('rhodecode.authentication')
80 80 config_stub.include('rhodecode.authentication.plugins.auth_rhodecode')
81 81 config_stub.include('rhodecode.authentication.plugins.auth_token')
82 82
83 83 controller = StubVCSController(
84 84 baseapp.config.get_settings(), request_stub.registry)
85 85 app = HttpsFixup(controller, baseapp.config.get_settings())
86 86 app = CustomTestApp(app)
87 87
88 88 _remove_default_user_from_query_cache()
89 89
90 90 # Sanity checks that things are set up correctly
91 91 app.get('/' + HG_REPO, status=200)
92 92
93 93 app.controller = controller
94 94 return app
95 95
96 96
97 97 def _remove_default_user_from_query_cache():
98 98 user = User.get_default_user(cache=True)
99 99 query = Session().query(User).filter(User.username == user.username)
100 100 query = query.options(
101 101 FromCache("sql_cache_short", "get_user_%s" % _hash_key(user.username)))
102 102 query.invalidate()
103 103 Session().expire(user)
104 104
105 105
106 106 def test_handles_exceptions_during_permissions_checks(
107 107 vcscontroller, disable_anonymous_user):
108 108 user_and_pass = '%s:%s' % (TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_PASS)
109 109 auth_password = base64.encodestring(user_and_pass).strip()
110 110 extra_environ = {
111 111 'AUTH_TYPE': 'Basic',
112 112 'HTTP_AUTHORIZATION': 'Basic %s' % auth_password,
113 113 'REMOTE_USER': TEST_USER_ADMIN_LOGIN,
114 114 }
115 115
116 116 # Verify that things are hooked up correctly
117 117 vcscontroller.get('/', status=200, extra_environ=extra_environ)
118 118
119 119 # Simulate trouble during permission checks
120 120 with mock.patch('rhodecode.model.db.User.get_by_username',
121 121 side_effect=Exception) as get_user:
122 122 # Verify that a correct 500 is returned and check that the expected
123 123 # code path was hit.
124 124 vcscontroller.get('/', status=500, extra_environ=extra_environ)
125 125 assert get_user.called
126 126
127 127
128 128 def test_returns_forbidden_if_no_anonymous_access(
129 129 vcscontroller, disable_anonymous_user):
130 130 vcscontroller.get('/', status=401)
131 131
132 132
133 133 class StubFailVCSController(simplevcs.SimpleVCS):
134 134 def _handle_request(self, environ, start_response):
135 135 raise Exception("BOOM")
136 136
137 137
138 138 @pytest.fixture(scope='module')
139 139 def fail_controller(baseapp):
140 140 controller = StubFailVCSController(
141 141 baseapp.config.get_settings(), baseapp.config)
142 142 controller = HttpsFixup(controller, baseapp.config.get_settings())
143 143 controller = CustomTestApp(controller)
144 144 return controller
145 145
146 146
147 147 def test_handles_exceptions_as_internal_server_error(fail_controller):
148 148 fail_controller.get('/', status=500)
149 149
150 150
151 151 def test_provides_traceback_for_appenlight(fail_controller):
152 152 response = fail_controller.get(
153 153 '/', status=500, extra_environ={'appenlight.client': 'fake'})
154 154 assert 'appenlight.__traceback' in response.request.environ
155 155
156 156
157 157 def test_provides_utils_scm_app_as_scm_app_by_default(baseapp, request_stub):
158 158 controller = StubVCSController(baseapp.config.get_settings(), request_stub.registry)
159 159 assert controller.scm_app is scm_app_http
160 160
161 161
162 162 def test_allows_to_override_scm_app_via_config(baseapp, request_stub):
163 163 config = baseapp.config.get_settings().copy()
164 164 config['vcs.scm_app_implementation'] = (
165 165 'rhodecode.tests.lib.middleware.mock_scm_app')
166 166 controller = StubVCSController(config, request_stub.registry)
167 167 assert controller.scm_app is mock_scm_app
168 168
169 169
170 170 @pytest.mark.parametrize('query_string, expected', [
171 171 ('cmd=stub_command', True),
172 172 ('cmd=listkeys', False),
173 173 ])
174 174 def test_should_check_locking(query_string, expected):
175 175 result = simplevcs._should_check_locking(query_string)
176 176 assert result == expected
177 177
178 178
179 179 class TestShadowRepoRegularExpression(object):
180 180 pr_segment = 'pull-request'
181 181 shadow_segment = 'repository'
182 182
183 183 @pytest.mark.parametrize('url, expected', [
184 184 # repo with/without groups
185 185 ('My-Repo/{pr_segment}/1/{shadow_segment}', True),
186 186 ('Group/My-Repo/{pr_segment}/2/{shadow_segment}', True),
187 187 ('Group/Sub-Group/My-Repo/{pr_segment}/3/{shadow_segment}', True),
188 188 ('Group/Sub-Group1/Sub-Group2/My-Repo/{pr_segment}/3/{shadow_segment}', True),
189 189
190 190 # pull request ID
191 191 ('MyRepo/{pr_segment}/1/{shadow_segment}', True),
192 192 ('MyRepo/{pr_segment}/1234567890/{shadow_segment}', True),
193 193 ('MyRepo/{pr_segment}/-1/{shadow_segment}', False),
194 194 ('MyRepo/{pr_segment}/invalid/{shadow_segment}', False),
195 195
196 196 # unicode
197 197 (u'Sp€çîál-Repö/{pr_segment}/1/{shadow_segment}', True),
198 198 (u'Sp€çîál-Gröüp/Sp€çîál-Repö/{pr_segment}/1/{shadow_segment}', True),
199 199
200 200 # trailing/leading slash
201 201 ('/My-Repo/{pr_segment}/1/{shadow_segment}', False),
202 202 ('My-Repo/{pr_segment}/1/{shadow_segment}/', False),
203 203 ('/My-Repo/{pr_segment}/1/{shadow_segment}/', False),
204 204
205 205 # misc
206 206 ('My-Repo/{pr_segment}/1/{shadow_segment}/extra', False),
207 207 ('My-Repo/{pr_segment}/1/{shadow_segment}extra', False),
208 208 ])
209 209 def test_shadow_repo_regular_expression(self, url, expected):
210 210 from rhodecode.lib.middleware.simplevcs import SimpleVCS
211 211 url = url.format(
212 212 pr_segment=self.pr_segment,
213 213 shadow_segment=self.shadow_segment)
214 214 match_obj = SimpleVCS.shadow_repo_re.match(url)
215 215 assert (match_obj is not None) == expected
216 216
217 217
218 218 @pytest.mark.backends('git', 'hg')
219 219 class TestShadowRepoExposure(object):
220 220
221 221 def test_pull_on_shadow_repo_propagates_to_wsgi_app(
222 222 self, baseapp, request_stub):
223 223 """
224 224 Check that a pull action to a shadow repo is propagated to the
225 225 underlying wsgi app.
226 226 """
227 227 controller = StubVCSController(
228 228 baseapp.config.get_settings(), request_stub.registry)
229 229 controller._check_ssl = mock.Mock()
230 230 controller.is_shadow_repo = True
231 231 controller._action = 'pull'
232 232 controller._is_shadow_repo_dir = True
233 233 controller.stub_response_body = 'dummy body value'
234 234 controller._get_default_cache_ttl = mock.Mock(
235 235 return_value=(False, 0))
236 236
237 237 environ_stub = {
238 238 'HTTP_HOST': 'test.example.com',
239 239 'HTTP_ACCEPT': 'application/mercurial',
240 240 'REQUEST_METHOD': 'GET',
241 241 'wsgi.url_scheme': 'http',
242 242 }
243 243
244 244 response = controller(environ_stub, mock.Mock())
245 245 response_body = ''.join(response)
246 246
247 247 # Assert that we got the response from the wsgi app.
248 248 assert response_body == controller.stub_response_body
249 249
250 250 def test_pull_on_shadow_repo_that_is_missing(self, baseapp, request_stub):
251 251 """
252 252 Check that a pull action to a shadow repo is propagated to the
253 253 underlying wsgi app.
254 254 """
255 255 controller = StubVCSController(
256 256 baseapp.config.get_settings(), request_stub.registry)
257 257 controller._check_ssl = mock.Mock()
258 258 controller.is_shadow_repo = True
259 259 controller._action = 'pull'
260 260 controller._is_shadow_repo_dir = False
261 261 controller.stub_response_body = 'dummy body value'
262 262 environ_stub = {
263 263 'HTTP_HOST': 'test.example.com',
264 264 'HTTP_ACCEPT': 'application/mercurial',
265 265 'REQUEST_METHOD': 'GET',
266 266 'wsgi.url_scheme': 'http',
267 267 }
268 268
269 269 response = controller(environ_stub, mock.Mock())
270 270 response_body = ''.join(response)
271 271
272 272 # Assert that we got the response from the wsgi app.
273 273 assert '404 Not Found' in response_body
274 274
275 275 def test_push_on_shadow_repo_raises(self, baseapp, request_stub):
276 276 """
277 277 Check that a push action to a shadow repo is aborted.
278 278 """
279 279 controller = StubVCSController(
280 280 baseapp.config.get_settings(), request_stub.registry)
281 281 controller._check_ssl = mock.Mock()
282 282 controller.is_shadow_repo = True
283 283 controller._action = 'push'
284 284 controller.stub_response_body = 'dummy body value'
285 285 environ_stub = {
286 286 'HTTP_HOST': 'test.example.com',
287 287 'HTTP_ACCEPT': 'application/mercurial',
288 288 'REQUEST_METHOD': 'GET',
289 289 'wsgi.url_scheme': 'http',
290 290 }
291 291
292 292 response = controller(environ_stub, mock.Mock())
293 293 response_body = ''.join(response)
294 294
295 295 assert response_body != controller.stub_response_body
296 296 # Assert that a 406 error is returned.
297 297 assert '406 Not Acceptable' in response_body
298 298
299 299 def test_set_repo_names_no_shadow(self, baseapp, request_stub):
300 300 """
301 301 Check that the set_repo_names method sets all names to the one returned
302 302 by the _get_repository_name method on a request to a non shadow repo.
303 303 """
304 304 environ_stub = {}
305 305 controller = StubVCSController(
306 306 baseapp.config.get_settings(), request_stub.registry)
307 307 controller._name = 'RepoGroup/MyRepo'
308 308 controller.set_repo_names(environ_stub)
309 309 assert not controller.is_shadow_repo
310 310 assert (controller.url_repo_name ==
311 311 controller.acl_repo_name ==
312 312 controller.vcs_repo_name ==
313 313 controller._get_repository_name(environ_stub))
314 314
315 315 def test_set_repo_names_with_shadow(
316 316 self, baseapp, pr_util, config_stub, request_stub):
317 317 """
318 318 Check that the set_repo_names method sets correct names on a request
319 319 to a shadow repo.
320 320 """
321 321 from rhodecode.model.pull_request import PullRequestModel
322 322
323 323 pull_request = pr_util.create_pull_request()
324 324 shadow_url = '{target}/{pr_segment}/{pr_id}/{shadow_segment}'.format(
325 325 target=pull_request.target_repo.repo_name,
326 326 pr_id=pull_request.pull_request_id,
327 327 pr_segment=TestShadowRepoRegularExpression.pr_segment,
328 328 shadow_segment=TestShadowRepoRegularExpression.shadow_segment)
329 329 controller = StubVCSController(
330 330 baseapp.config.get_settings(), request_stub.registry)
331 331 controller._name = shadow_url
332 332 controller.set_repo_names({})
333 333
334 334 # Get file system path to shadow repo for assertions.
335 335 workspace_id = PullRequestModel()._workspace_id(pull_request)
336 336 target_vcs = pull_request.target_repo.scm_instance()
337 337 vcs_repo_name = target_vcs._get_shadow_repository_path(
338 338 pull_request.target_repo.repo_id, workspace_id)
339 339
340 340 assert controller.vcs_repo_name == vcs_repo_name
341 341 assert controller.url_repo_name == shadow_url
342 342 assert controller.acl_repo_name == pull_request.target_repo.repo_name
343 343 assert controller.is_shadow_repo
344 344
345 345 def test_set_repo_names_with_shadow_but_missing_pr(
346 346 self, baseapp, pr_util, config_stub, request_stub):
347 347 """
348 348 Checks that the set_repo_names method enforces matching target repos
349 349 and pull request IDs.
350 350 """
351 351 pull_request = pr_util.create_pull_request()
352 352 shadow_url = '{target}/{pr_segment}/{pr_id}/{shadow_segment}'.format(
353 353 target=pull_request.target_repo.repo_name,
354 354 pr_id=999999999,
355 355 pr_segment=TestShadowRepoRegularExpression.pr_segment,
356 356 shadow_segment=TestShadowRepoRegularExpression.shadow_segment)
357 357 controller = StubVCSController(
358 358 baseapp.config.get_settings(), request_stub.registry)
359 359 controller._name = shadow_url
360 360 controller.set_repo_names({})
361 361
362 362 assert not controller.is_shadow_repo
363 363 assert (controller.url_repo_name ==
364 364 controller.acl_repo_name ==
365 365 controller.vcs_repo_name)
366 366
367 367
368 368 @pytest.mark.usefixtures('baseapp')
369 369 class TestGenerateVcsResponse(object):
370 370
371 371 def test_ensures_that_start_response_is_called_early_enough(self):
372 372 self.call_controller_with_response_body(iter(['a', 'b']))
373 373 assert self.start_response.called
374 374
375 375 def test_invalidates_cache_after_body_is_consumed(self):
376 376 result = self.call_controller_with_response_body(iter(['a', 'b']))
377 377 assert not self.was_cache_invalidated()
378 378 # Consume the result
379 379 list(result)
380 380 assert self.was_cache_invalidated()
381 381
382 382 def test_raises_unknown_exceptions(self):
383 383 result = self.call_controller_with_response_body(
384 384 self.raise_result_iter(vcs_kind='unknown'))
385 385 with pytest.raises(Exception):
386 386 list(result)
387 387
388 388 def test_prepare_callback_daemon_is_called(self):
389 389 def side_effect(extras, environ, action, txn_id=None):
390 390 return DummyHooksCallbackDaemon(), extras
391 391
392 392 prepare_patcher = mock.patch.object(
393 393 StubVCSController, '_prepare_callback_daemon')
394 394 with prepare_patcher as prepare_mock:
395 395 prepare_mock.side_effect = side_effect
396 396 self.call_controller_with_response_body(iter(['a', 'b']))
397 397 assert prepare_mock.called
398 398 assert prepare_mock.call_count == 1
399 399
400 400 def call_controller_with_response_body(self, response_body):
401 401 settings = {
402 402 'base_path': 'fake_base_path',
403 403 'vcs.hooks.protocol': 'http',
404 404 'vcs.hooks.direct_calls': False,
405 405 }
406 406 registry = AttributeDict()
407 407 controller = StubVCSController(settings, registry)
408 408 controller._invalidate_cache = mock.Mock()
409 409 controller.stub_response_body = response_body
410 410 self.start_response = mock.Mock()
411 411 result = controller._generate_vcs_response(
412 412 environ={}, start_response=self.start_response,
413 413 repo_path='fake_repo_path',
414 414 extras={}, action='push')
415 415 self.controller = controller
416 416 return result
417 417
418 418 def raise_result_iter(self, vcs_kind='repo_locked'):
419 419 """
420 420 Simulates an exception due to a vcs raised exception if kind vcs_kind
421 421 """
422 422 raise self.vcs_exception(vcs_kind=vcs_kind)
423 423 yield "never_reached"
424 424
425 425 def vcs_exception(self, vcs_kind='repo_locked'):
426 426 locked_exception = Exception('TEST_MESSAGE')
427 427 locked_exception._vcs_kind = vcs_kind
428 428 return locked_exception
429 429
430 430 def was_cache_invalidated(self):
431 431 return self.controller._invalidate_cache.called
432 432
433 433
434 434 class TestInitializeGenerator(object):
435 435
436 436 def test_drains_first_element(self):
437 437 gen = self.factory(['__init__', 1, 2])
438 438 result = list(gen)
439 439 assert result == [1, 2]
440 440
441 441 @pytest.mark.parametrize('values', [
442 442 [],
443 443 [1, 2],
444 444 ])
445 445 def test_raises_value_error(self, values):
446 446 with pytest.raises(ValueError):
447 447 self.factory(values)
448 448
449 449 @simplevcs.initialize_generator
450 450 def factory(self, iterable):
451 451 for elem in iterable:
452 452 yield elem
453 453
454 454
455 455 class TestPrepareHooksDaemon(object):
456 456 def test_calls_imported_prepare_callback_daemon(self, app_settings, request_stub):
457 457 expected_extras = {'extra1': 'value1'}
458 458 daemon = DummyHooksCallbackDaemon()
459 459
460 460 controller = StubVCSController(app_settings, request_stub.registry)
461 461 prepare_patcher = mock.patch.object(
462 462 simplevcs, 'prepare_callback_daemon',
463 463 return_value=(daemon, expected_extras))
464 464 with prepare_patcher as prepare_mock:
465 465 callback_daemon, extras = controller._prepare_callback_daemon(
466 466 expected_extras.copy(), {}, 'push')
467 467 prepare_mock.assert_called_once_with(
468 468 expected_extras,
469 469 protocol=app_settings['vcs.hooks.protocol'],
470 470 host=app_settings['vcs.hooks.host'],
471 471 txn_id=None,
472 472 use_direct_calls=app_settings['vcs.hooks.direct_calls'])
473 473
474 474 assert callback_daemon == daemon
475 475 assert extras == extras
General Comments 0
You need to be logged in to leave comments. Login now