##// END OF EJS Templates
vcs: Remove custom response header which contains backend key....
Martin Bornhold -
r950:21c331ba default
parent child Browse files
Show More
@@ -1,159 +1,155 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 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 from urlparse import urljoin
23 23
24 24 import requests
25 25 from webob.exc import HTTPNotAcceptable
26 26
27 27 from rhodecode.lib.middleware import simplevcs
28 28 from rhodecode.lib.utils import is_valid_repo
29 29 from rhodecode.lib.utils2 import str2bool
30 30
31 31 log = logging.getLogger(__name__)
32 32
33 33
34 34 class SimpleSvnApp(object):
35 35 IGNORED_HEADERS = [
36 36 'connection', 'keep-alive', 'content-encoding',
37 37 'transfer-encoding', 'content-length']
38 38
39 39 def __init__(self, config):
40 40 self.config = config
41 41
42 42 def __call__(self, environ, start_response):
43 43 request_headers = self._get_request_headers(environ)
44 44
45 45 data = environ['wsgi.input']
46 46 # johbo: Avoid that we end up with sending the request in chunked
47 47 # transfer encoding (mainly on Gunicorn). If we know the content
48 48 # length, then we should transfer the payload in one request.
49 49 if environ['REQUEST_METHOD'] == 'MKCOL' or 'CONTENT_LENGTH' in environ:
50 50 data = data.read()
51 51
52 52 response = requests.request(
53 53 environ['REQUEST_METHOD'], self._get_url(environ['PATH_INFO']),
54 54 data=data, headers=request_headers)
55 55
56 56 response_headers = self._get_response_headers(response.headers)
57 57 start_response(
58 58 '{} {}'.format(response.status_code, response.reason),
59 59 response_headers)
60 60 return response.iter_content(chunk_size=1024)
61 61
62 62 def _get_url(self, path):
63 63 return urljoin(
64 64 self.config.get('subversion_http_server_url', ''), path)
65 65
66 66 def _get_request_headers(self, environ):
67 67 headers = {}
68 68
69 69 for key in environ:
70 70 if not key.startswith('HTTP_'):
71 71 continue
72 72 new_key = key.split('_')
73 73 new_key = [k.capitalize() for k in new_key[1:]]
74 74 new_key = '-'.join(new_key)
75 75 headers[new_key] = environ[key]
76 76
77 77 if 'CONTENT_TYPE' in environ:
78 78 headers['Content-Type'] = environ['CONTENT_TYPE']
79 79
80 80 if 'CONTENT_LENGTH' in environ:
81 81 headers['Content-Length'] = environ['CONTENT_LENGTH']
82 82
83 83 return headers
84 84
85 85 def _get_response_headers(self, headers):
86 86 headers = [
87 87 (h, headers[h])
88 88 for h in headers
89 89 if h.lower() not in self.IGNORED_HEADERS
90 90 ]
91 91
92 # Add custom response header to indicate that this is a VCS response
93 # and which backend is used.
94 headers.append(('X-RhodeCode-Backend', 'svn'))
95
96 92 return headers
97 93
98 94
99 95 class DisabledSimpleSvnApp(object):
100 96 def __init__(self, config):
101 97 self.config = config
102 98
103 99 def __call__(self, environ, start_response):
104 100 reason = 'Cannot handle SVN call because: SVN HTTP Proxy is not enabled'
105 101 log.warning(reason)
106 102 return HTTPNotAcceptable(reason)(environ, start_response)
107 103
108 104
109 105 class SimpleSvn(simplevcs.SimpleVCS):
110 106
111 107 SCM = 'svn'
112 108 READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT')
113 109 DEFAULT_HTTP_SERVER = 'http://localhost:8090'
114 110
115 111 def _get_repository_name(self, environ):
116 112 """
117 113 Gets repository name out of PATH_INFO header
118 114
119 115 :param environ: environ where PATH_INFO is stored
120 116 """
121 117 path = environ['PATH_INFO'].split('!')
122 118 repo_name = path[0].strip('/')
123 119
124 120 # SVN includes the whole path in it's requests, including
125 121 # subdirectories inside the repo. Therefore we have to search for
126 122 # the repo root directory.
127 123 if not is_valid_repo(repo_name, self.basepath, self.SCM):
128 124 current_path = ''
129 125 for component in repo_name.split('/'):
130 126 current_path += component
131 127 if is_valid_repo(current_path, self.basepath, self.SCM):
132 128 return current_path
133 129 current_path += '/'
134 130
135 131 return repo_name
136 132
137 133 def _get_action(self, environ):
138 134 return (
139 135 'pull'
140 136 if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS
141 137 else 'push')
142 138
143 139 def _create_wsgi_app(self, repo_path, repo_name, config):
144 140 if self._is_svn_enabled():
145 141 return SimpleSvnApp(config)
146 142 # we don't have http proxy enabled return dummy request handler
147 143 return DisabledSimpleSvnApp(config)
148 144
149 145 def _is_svn_enabled(self):
150 146 conf = self.repo_vcs_config
151 147 return str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
152 148
153 149 def _create_config(self, extras, repo_name):
154 150 conf = self.repo_vcs_config
155 151 server_url = conf.get('vcs_svn_proxy', 'http_server_url')
156 152 server_url = server_url or self.DEFAULT_HTTP_SERVER
157 153
158 154 extras['subversion_http_server_url'] = server_url
159 155 return extras
@@ -1,140 +1,136 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2016 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 Implementation of the scm_app interface using raw HTTP communication.
23 23 """
24 24
25 25 import base64
26 26 import logging
27 27 import urlparse
28 28 import wsgiref.util
29 29
30 30 import msgpack
31 31 import requests
32 32 import webob.request
33 33
34 34 import rhodecode
35 35
36 36
37 37 log = logging.getLogger(__name__)
38 38
39 39
40 40 def create_git_wsgi_app(repo_path, repo_name, config, backend):
41 41 url = _vcs_streaming_url() + 'git/'
42 42 return VcsHttpProxy(url, repo_path, repo_name, config, backend)
43 43
44 44
45 45 def create_hg_wsgi_app(repo_path, repo_name, config, backend):
46 46 url = _vcs_streaming_url() + 'hg/'
47 47 return VcsHttpProxy(url, repo_path, repo_name, config, backend)
48 48
49 49
50 50 def _vcs_streaming_url():
51 51 template = 'http://{}/stream/'
52 52 return template.format(rhodecode.CONFIG['vcs.server'])
53 53
54 54
55 55 # TODO: johbo: Avoid the global.
56 56 session = requests.Session()
57 57 # Requests speedup, avoid reading .netrc and similar
58 58 session.trust_env = False
59 59
60 60
61 61 class VcsHttpProxy(object):
62 62 """
63 63 A WSGI application which proxies vcs requests.
64 64
65 65 The goal is to shuffle the data around without touching it. The only
66 66 exception is the extra data from the config object which we send to the
67 67 server as well.
68 68 """
69 69
70 70 def __init__(self, url, repo_path, repo_name, config, backend):
71 71 """
72 72 :param str url: The URL of the VCSServer to call.
73 73 """
74 74 self._url = url
75 75 self._repo_name = repo_name
76 76 self._repo_path = repo_path
77 77 self._config = config
78 78 self._backend = backend
79 79 log.debug(
80 80 "Creating VcsHttpProxy for repo %s, url %s",
81 81 repo_name, url)
82 82
83 83 def __call__(self, environ, start_response):
84 84 status = '200 OK'
85 85
86 86 config = msgpack.packb(self._config)
87 87 request = webob.request.Request(environ)
88 88 request_headers = request.headers
89 89 request_headers.update({
90 90 # TODO: johbo: Remove this, rely on URL path only
91 91 'X-RC-Repo-Name': self._repo_name,
92 92 'X-RC-Repo-Path': self._repo_path,
93 93 'X-RC-Path-Info': environ['PATH_INFO'],
94 94 # TODO: johbo: Avoid encoding and put this into payload?
95 95 'X-RC-Repo-Config': base64.b64encode(config),
96 96 })
97 97
98 98 data = environ['wsgi.input'].read()
99 99 method = environ['REQUEST_METHOD']
100 100
101 101 # Preserve the query string
102 102 url = self._url
103 103 url = urlparse.urljoin(url, self._repo_name)
104 104 if environ.get('QUERY_STRING'):
105 105 url += '?' + environ['QUERY_STRING']
106 106
107 107 response = session.request(
108 108 method, url,
109 109 data=data,
110 110 headers=request_headers,
111 111 stream=True)
112 112
113 113 # Preserve the headers of the response, except hop_by_hop ones
114 114 response_headers = [
115 115 (h, v) for h, v in response.headers.items()
116 116 if not wsgiref.util.is_hop_by_hop(h)
117 117 ]
118 118
119 # Add custom response header to indicate that this is a VCS response
120 # and which backend is used.
121 response_headers.append(('X-RhodeCode-Backend', self._backend))
122
123 119 # TODO: johbo: Better way to get the status including text?
124 120 status = str(response.status_code)
125 121 start_response(status, response_headers)
126 122 return _maybe_stream(response)
127 123
128 124
129 125 def _maybe_stream(response):
130 126 """
131 127 Try to generate chunks from the response if it is chunked.
132 128 """
133 129 if _is_chunked(response):
134 130 return response.raw.read_chunked()
135 131 else:
136 132 return [response.content]
137 133
138 134
139 135 def _is_chunked(response):
140 136 return response.headers.get('Transfer-Encoding', '') == 'chunked'
@@ -1,104 +1,100 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2016 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 Utility to call a WSGI app wrapped in a WSGIAppCaller object.
23 23 """
24 24
25 25 import logging
26 26
27 27 from Pyro4.errors import ConnectionClosedError
28 28
29 29
30 30 log = logging.getLogger(__name__)
31 31
32 32
33 33 def _get_clean_environ(environ):
34 34 """Return a copy of the WSGI environment without wsgi.* keys.
35 35
36 36 It also omits any non-string values.
37 37
38 38 :param environ: WSGI environment to clean
39 39 :type environ: dict
40 40
41 41 :returns: WSGI environment to pass to WSGIAppCaller.handle.
42 42 :rtype: dict
43 43 """
44 44 clean_environ = dict(
45 45 (k, v) for k, v in environ.iteritems()
46 46 if type(v) == str and type(k) == str and not k.startswith('wsgi.')
47 47 )
48 48
49 49 return clean_environ
50 50
51 51
52 52 # pylint: disable=too-few-public-methods
53 53 class RemoteAppCaller(object):
54 54 """Create and calls a remote WSGI app using the given factory.
55 55
56 56 It first cleans the environment, so as to reduce the data transferred.
57 57 """
58 58
59 59 def __init__(self, remote_wsgi, backend, *args, **kwargs):
60 60 """
61 61 :param remote_wsgi: The remote wsgi object that creates a
62 62 WSGIAppCaller. This object
63 63 has to have a handle method, with the signature:
64 64 handle(environ, start_response, *args, **kwargs)
65 65 :param backend: Key (str) of the SCM backend that is in use.
66 66 :param args: args to be passed to the app creation
67 67 :param kwargs: kwargs to be passed to the app creation
68 68 """
69 69 self._remote_wsgi = remote_wsgi
70 70 self._backend = backend
71 71 self._args = args
72 72 self._kwargs = kwargs
73 73
74 74 def __call__(self, environ, start_response):
75 75 """
76 76 :param environ: WSGI environment with which the app will be run
77 77 :type environ: dict
78 78 :param start_response: callable of WSGI protocol
79 79 :type start_response: callable
80 80
81 81 :returns: an iterable with the data returned by the app
82 82 :rtype: iterable<str>
83 83 """
84 84 log.debug("Forwarding WSGI request via proxy %s", self._remote_wsgi)
85 85 input_data = environ['wsgi.input'].read()
86 86 clean_environ = _get_clean_environ(environ)
87 87
88 88 try:
89 89 data, status, headers = self._remote_wsgi.handle(
90 90 clean_environ, input_data, *self._args, **self._kwargs)
91 91 except ConnectionClosedError:
92 92 log.debug('Remote Pyro Server ConnectionClosedError')
93 93 self._remote_wsgi._pyroReconnect(tries=15)
94 94 data, status, headers = self._remote_wsgi.handle(
95 95 clean_environ, input_data, *self._args, **self._kwargs)
96 96
97 # Add custom response header to indicate that this is a VCS response
98 # and which backend is used.
99 headers.append(('X-RhodeCode-Backend', self._backend))
100
101 97 log.debug("Got result from proxy, returning to WSGI container")
102 98 start_response(status, headers)
103 99
104 100 return data
@@ -1,203 +1,201 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 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 32 def simple_svn(self, pylonsapp):
33 33 self.app = SimpleSvn(
34 34 application='None',
35 35 config={'auth_ret_code': '',
36 36 'base_path': rhodecode.CONFIG['base_path']},
37 37 registry=None)
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 ('X-RhodeCode-Backend', 'svn'),
161 160 ]
162 161 response_headers = self.app._get_response_headers(headers)
163 162 assert sorted(response_headers) == sorted(expected_headers)
164 163
165 164 def test_get_url(self):
166 165 url = self.app._get_url(self.path)
167 166 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
168 167 assert url == expected_url
169 168
170 169 def test_call(self):
171 170 start_response = Mock()
172 171 response_mock = Mock()
173 172 response_mock.headers = {
174 173 'Content-Encoding': 'gzip',
175 174 'MS-Author-Via': 'DAV',
176 175 'SVN-Supported-Posts': 'create-txn-with-props'
177 176 }
178 177 response_mock.status_code = 200
179 178 response_mock.reason = 'OK'
180 179 with patch('rhodecode.lib.middleware.simplesvn.requests.request') as (
181 180 request_mock):
182 181 request_mock.return_value = response_mock
183 182 self.app(self.environment, start_response)
184 183
185 184 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
186 185 expected_request_headers = {
187 186 'Dav': self.environment['HTTP_DAV'],
188 187 'User-Agent': self.environment['HTTP_USER_AGENT'],
189 188 'Content-Type': self.environment['CONTENT_TYPE'],
190 189 'Content-Length': self.environment['CONTENT_LENGTH']
191 190 }
192 191 expected_response_headers = [
193 192 ('SVN-Supported-Posts', 'create-txn-with-props'),
194 193 ('MS-Author-Via', 'DAV'),
195 ('X-RhodeCode-Backend', 'svn'),
196 194 ]
197 195 request_mock.assert_called_once_with(
198 196 self.environment['REQUEST_METHOD'], expected_url,
199 197 data=self.data, headers=expected_request_headers)
200 198 response_mock.iter_content.assert_called_once_with(chunk_size=1024)
201 199 args, _ = start_response.call_args
202 200 assert args[0] == '200 OK'
203 201 assert sorted(args[1]) == sorted(expected_response_headers)
General Comments 0
You need to be logged in to leave comments. Login now