~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/tests/http_utils.py

  • Committer: Tim Penhey
  • Date: 2008-04-25 11:23:00 UTC
  • mto: (3473.1.1 ianc-integration)
  • mto: This revision was merged to the branch mainline in revision 3474.
  • Revision ID: tim@penhey.net-20080425112300-sf5soa5dg2d37kvc
Added tests.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright (C) 2005 by Canonical Ltd
2
 
 
 
1
# Copyright (C) 2005 Canonical Ltd
 
2
#
3
3
# This program is free software; you can redistribute it and/or modify
4
4
# it under the terms of the GNU General Public License as published by
5
5
# the Free Software Foundation; either version 2 of the License, or
6
6
# (at your option) any later version.
7
 
 
 
7
#
8
8
# This program is distributed in the hope that it will be useful,
9
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
11
# GNU General Public License for more details.
12
 
 
 
12
#
13
13
# You should have received a copy of the GNU General Public License
14
14
# along with this program; if not, write to the Free Software
15
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
16
16
 
17
 
import BaseHTTPServer, SimpleHTTPServer
18
 
from bzrlib.selftest import TestCaseInTempDir
19
 
 
20
 
 
21
 
class WebserverNotAvailable(Exception):
22
 
    pass
23
 
 
24
 
class BadWebserverPath(ValueError):
25
 
    def __str__(self):
26
 
        return 'path %s is not in %s' % self.args
27
 
 
28
 
class TestingHTTPRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
29
 
    def log_message(self, format, *args):
30
 
        self.server.test_case.log("webserver - %s - - [%s] %s\n" %
31
 
                                  (self.address_string(),
32
 
                                   self.log_date_time_string(),
33
 
                                   format%args))
34
 
 
35
 
class TestingHTTPServer(BaseHTTPServer.HTTPServer):
36
 
    def __init__(self, server_address, RequestHandlerClass, test_case):
37
 
        BaseHTTPServer.HTTPServer.__init__(self, server_address,
38
 
                                                RequestHandlerClass)
39
 
        self.test_case = test_case
40
 
 
41
 
class TestCaseWithWebserver(TestCaseInTempDir):
42
 
    """Derived class that starts a localhost-only webserver
43
 
    (in addition to what TestCaseInTempDir does).
44
 
 
45
 
    This is useful for testing RemoteBranch.
46
 
    """
47
 
 
48
 
    _HTTP_PORTS = range(13000, 0x8000)
49
 
 
50
 
    def _http_start(self):
51
 
        import SimpleHTTPServer, BaseHTTPServer, socket, errno
52
 
        httpd = None
53
 
        for port in self._HTTP_PORTS:
54
 
            try:
55
 
                httpd = TestingHTTPServer(('localhost', port),
56
 
                                          TestingHTTPRequestHandler,
57
 
                                          self)
58
 
            except socket.error, e:
59
 
                if e.args[0] == errno.EADDRINUSE:
60
 
                    continue
61
 
                print >>sys.stderr, "Cannot run webserver :-("
62
 
                raise
 
17
from cStringIO import StringIO
 
18
import errno
 
19
import md5
 
20
import re
 
21
import sha
 
22
import socket
 
23
import threading
 
24
import time
 
25
import urllib2
 
26
import urlparse
 
27
 
 
28
 
 
29
from bzrlib import (
 
30
    tests,
 
31
    transport,
 
32
    )
 
33
from bzrlib.smart import protocol
 
34
from bzrlib.tests import http_server
 
35
 
 
36
 
 
37
class HTTPServerWithSmarts(http_server.HttpServer):
 
38
    """HTTPServerWithSmarts extends the HttpServer with POST methods that will
 
39
    trigger a smart server to execute with a transport rooted at the rootdir of
 
40
    the HTTP server.
 
41
    """
 
42
 
 
43
    def __init__(self, protocol_version=None):
 
44
        http_server.HttpServer.__init__(self, SmartRequestHandler,
 
45
                                        protocol_version=protocol_version)
 
46
 
 
47
 
 
48
class SmartRequestHandler(http_server.TestingHTTPRequestHandler):
 
49
    """Extend TestingHTTPRequestHandler to support smart client POSTs."""
 
50
 
 
51
    def do_POST(self):
 
52
        """Hand the request off to a smart server instance."""
 
53
        self.send_response(200)
 
54
        self.send_header("Content-type", "application/octet-stream")
 
55
        t = transport.get_transport(self.server.test_case_server._home_dir)
 
56
        # TODO: We might like to support streaming responses.  1.0 allows no
 
57
        # Content-length in this case, so for integrity we should perform our
 
58
        # own chunking within the stream.
 
59
        # 1.1 allows chunked responses, and in this case we could chunk using
 
60
        # the HTTP chunking as this will allow HTTP persistence safely, even if
 
61
        # we have to stop early due to error, but we would also have to use the
 
62
        # HTTP trailer facility which may not be widely available.
 
63
        out_buffer = StringIO()
 
64
        smart_protocol_request = protocol.SmartServerRequestProtocolOne(
 
65
                t, out_buffer.write)
 
66
        # if this fails, we should return 400 bad request, but failure is
 
67
        # failure for now - RBC 20060919
 
68
        data_length = int(self.headers['Content-Length'])
 
69
        # Perhaps there should be a SmartServerHTTPMedium that takes care of
 
70
        # feeding the bytes in the http request to the smart_protocol_request,
 
71
        # but for now it's simpler to just feed the bytes directly.
 
72
        smart_protocol_request.accept_bytes(self.rfile.read(data_length))
 
73
        assert smart_protocol_request.next_read_size() == 0, (
 
74
            "not finished reading, but all data sent to protocol.")
 
75
        self.send_header("Content-Length", str(len(out_buffer.getvalue())))
 
76
        self.end_headers()
 
77
        self.wfile.write(out_buffer.getvalue())
 
78
 
 
79
 
 
80
class TestCaseWithWebserver(tests.TestCaseWithTransport):
 
81
    """A support class that provides readonly urls that are http://.
 
82
 
 
83
    This is done by forcing the readonly server to be an http
 
84
    one. This will currently fail if the primary transport is not
 
85
    backed by regular disk files.
 
86
    """
 
87
    def setUp(self):
 
88
        super(TestCaseWithWebserver, self).setUp()
 
89
        self.transport_readonly_server = http_server.HttpServer
 
90
 
 
91
 
 
92
class TestCaseWithTwoWebservers(TestCaseWithWebserver):
 
93
    """A support class providing readonly urls on two servers that are http://.
 
94
 
 
95
    We set up two webservers to allows various tests involving
 
96
    proxies or redirections from one server to the other.
 
97
    """
 
98
    def setUp(self):
 
99
        super(TestCaseWithTwoWebservers, self).setUp()
 
100
        self.transport_secondary_server = http_server.HttpServer
 
101
        self.__secondary_server = None
 
102
 
 
103
    def create_transport_secondary_server(self):
 
104
        """Create a transport server from class defined at init.
 
105
 
 
106
        This is mostly a hook for daughter classes.
 
107
        """
 
108
        return self.transport_secondary_server()
 
109
 
 
110
    def get_secondary_server(self):
 
111
        """Get the server instance for the secondary transport."""
 
112
        if self.__secondary_server is None:
 
113
            self.__secondary_server = self.create_transport_secondary_server()
 
114
            self.__secondary_server.setUp()
 
115
            self.addCleanup(self.__secondary_server.tearDown)
 
116
        return self.__secondary_server
 
117
 
 
118
 
 
119
class ProxyServer(http_server.HttpServer):
 
120
    """A proxy test server for http transports."""
 
121
 
 
122
    proxy_requests = True
 
123
 
 
124
 
 
125
class RedirectRequestHandler(http_server.TestingHTTPRequestHandler):
 
126
    """Redirect all request to the specified server"""
 
127
 
 
128
    def parse_request(self):
 
129
        """Redirect a single HTTP request to another host"""
 
130
        valid = http_server.TestingHTTPRequestHandler.parse_request(self)
 
131
        if valid:
 
132
            tcs = self.server.test_case_server
 
133
            code, target = tcs.is_redirected(self.path)
 
134
            if code is not None and target is not None:
 
135
                # Redirect as instructed
 
136
                self.send_response(code)
 
137
                self.send_header('Location', target)
 
138
                # We do not send a body
 
139
                self.send_header('Content-Length', '0')
 
140
                self.end_headers()
 
141
                return False # The job is done
63
142
            else:
64
 
                break
65
 
 
66
 
        if httpd is None:
67
 
            raise WebserverNotAvailable("Cannot run webserver :-( "
68
 
                                        "no free ports in range %s..%s" %
69
 
                                        (_HTTP_PORTS[0], _HTTP_PORTS[-1]))
70
 
 
71
 
        self._http_base_url = 'http://localhost:%s/' % port
72
 
        self._http_starting.release()
73
 
        httpd.socket.settimeout(1)
74
 
 
75
 
        while self._http_running:
76
 
            try:
77
 
                httpd.handle_request()
78
 
            except socket.timeout:
 
143
                # We leave the parent class serve the request
79
144
                pass
80
 
 
81
 
    def get_remote_url(self, path):
82
 
        import os
83
 
 
84
 
        path_parts = path.split(os.path.sep)
85
 
        if os.path.isabs(path):
86
 
            if path_parts[:len(self._local_path_parts)] != \
87
 
                   self._local_path_parts:
88
 
                raise BadWebserverPath(path, self.test_dir)
89
 
            remote_path = '/'.join(path_parts[len(self._local_path_parts):])
 
145
        return valid
 
146
 
 
147
 
 
148
class HTTPServerRedirecting(http_server.HttpServer):
 
149
    """An HttpServer redirecting to another server """
 
150
 
 
151
    def __init__(self, request_handler=RedirectRequestHandler,
 
152
                 protocol_version=None):
 
153
        http_server.HttpServer.__init__(self, request_handler,
 
154
                                        protocol_version=protocol_version)
 
155
        # redirections is a list of tuples (source, target, code)
 
156
        # - source is a regexp for the paths requested
 
157
        # - target is a replacement for re.sub describing where
 
158
        #   the request will be redirected
 
159
        # - code is the http error code associated to the
 
160
        #   redirection (301 permanent, 302 temporarry, etc
 
161
        self.redirections = []
 
162
 
 
163
    def redirect_to(self, host, port):
 
164
        """Redirect all requests to a specific host:port"""
 
165
        self.redirections = [('(.*)',
 
166
                              r'http://%s:%s\1' % (host, port) ,
 
167
                              301)]
 
168
 
 
169
    def is_redirected(self, path):
 
170
        """Is the path redirected by this server.
 
171
 
 
172
        :param path: the requested relative path
 
173
 
 
174
        :returns: a tuple (code, target) if a matching
 
175
             redirection is found, (None, None) otherwise.
 
176
        """
 
177
        code = None
 
178
        target = None
 
179
        for (rsource, rtarget, rcode) in self.redirections:
 
180
            target, match = re.subn(rsource, rtarget, path)
 
181
            if match:
 
182
                code = rcode
 
183
                break # The first match wins
 
184
            else:
 
185
                target = None
 
186
        return code, target
 
187
 
 
188
 
 
189
class TestCaseWithRedirectedWebserver(TestCaseWithTwoWebservers):
 
190
   """A support class providing redirections from one server to another.
 
191
 
 
192
   We set up two webservers to allows various tests involving
 
193
   redirections.
 
194
   The 'old' server is redirected to the 'new' server.
 
195
   """
 
196
 
 
197
   def create_transport_secondary_server(self):
 
198
       """Create the secondary server redirecting to the primary server"""
 
199
       new = self.get_readonly_server()
 
200
       redirecting = HTTPServerRedirecting()
 
201
       redirecting.redirect_to(new.host, new.port)
 
202
       return redirecting
 
203
 
 
204
   def setUp(self):
 
205
       super(TestCaseWithRedirectedWebserver, self).setUp()
 
206
       # The redirections will point to the new server
 
207
       self.new_server = self.get_readonly_server()
 
208
       # The requests to the old server will be redirected
 
209
       self.old_server = self.get_secondary_server()
 
210
 
 
211
 
 
212
class AuthRequestHandler(http_server.TestingHTTPRequestHandler):
 
213
    """Requires an authentication to process requests.
 
214
 
 
215
    This is intended to be used with a server that always and
 
216
    only use one authentication scheme (implemented by daughter
 
217
    classes).
 
218
    """
 
219
 
 
220
    # The following attributes should be defined in the server
 
221
    # - auth_header_sent: the header name sent to require auth
 
222
    # - auth_header_recv: the header received containing auth
 
223
    # - auth_error_code: the error code to indicate auth required
 
224
 
 
225
    def do_GET(self):
 
226
        if self.authorized():
 
227
            return http_server.TestingHTTPRequestHandler.do_GET(self)
90
228
        else:
91
 
            remote_path = '/'.join(path_parts)
92
 
 
93
 
        self._http_starting.acquire()
94
 
        self._http_starting.release()
95
 
        return self._http_base_url + remote_path
96
 
 
97
 
    def setUp(self):
98
 
        super(TestCaseWithWebserver, self).setUp()
99
 
        import threading, os
100
 
        self._local_path_parts = self.test_dir.split(os.path.sep)
101
 
        self._http_starting = threading.Lock()
102
 
        self._http_starting.acquire()
103
 
        self._http_running = True
104
 
        self._http_base_url = None
105
 
        self._http_thread = threading.Thread(target=self._http_start)
106
 
        self._http_thread.setDaemon(True)
107
 
        self._http_thread.start()
108
 
 
109
 
    def tearDown(self):
110
 
        self._http_running = False
111
 
        self._http_thread.join()
112
 
        super(TestCaseWithWebserver, self).tearDown()
 
229
            # Note that we must update test_case_server *before*
 
230
            # sending the error or the client may try to read it
 
231
            # before we have sent the whole error back.
 
232
            tcs = self.server.test_case_server
 
233
            tcs.auth_required_errors += 1
 
234
            self.send_response(tcs.auth_error_code)
 
235
            self.send_header_auth_reqed()
 
236
            # We do not send a body
 
237
            self.send_header('Content-Length', '0')
 
238
            self.end_headers()
 
239
            return
 
240
 
 
241
 
 
242
class BasicAuthRequestHandler(AuthRequestHandler):
 
243
    """Implements the basic authentication of a request"""
 
244
 
 
245
    def authorized(self):
 
246
        tcs = self.server.test_case_server
 
247
        if tcs.auth_scheme != 'basic':
 
248
            return False
 
249
 
 
250
        auth_header = self.headers.get(tcs.auth_header_recv, None)
 
251
        if auth_header:
 
252
            scheme, raw_auth = auth_header.split(' ', 1)
 
253
            if scheme.lower() == tcs.auth_scheme:
 
254
                user, password = raw_auth.decode('base64').split(':')
 
255
                return tcs.authorized(user, password)
 
256
 
 
257
        return False
 
258
 
 
259
    def send_header_auth_reqed(self):
 
260
        tcs = self.server.test_case_server
 
261
        self.send_header(tcs.auth_header_sent,
 
262
                         'Basic realm="%s"' % tcs.auth_realm)
 
263
 
 
264
 
 
265
# FIXME: We could send an Authentication-Info header too when
 
266
# the authentication is succesful
 
267
 
 
268
class DigestAuthRequestHandler(AuthRequestHandler):
 
269
    """Implements the digest authentication of a request.
 
270
 
 
271
    We need persistence for some attributes and that can't be
 
272
    achieved here since we get instantiated for each request. We
 
273
    rely on the DigestAuthServer to take care of them.
 
274
    """
 
275
 
 
276
    def authorized(self):
 
277
        tcs = self.server.test_case_server
 
278
        if tcs.auth_scheme != 'digest':
 
279
            return False
 
280
 
 
281
        auth_header = self.headers.get(tcs.auth_header_recv, None)
 
282
        if auth_header is None:
 
283
            return False
 
284
        scheme, auth = auth_header.split(None, 1)
 
285
        if scheme.lower() == tcs.auth_scheme:
 
286
            auth_dict = urllib2.parse_keqv_list(urllib2.parse_http_list(auth))
 
287
 
 
288
            return tcs.digest_authorized(auth_dict, self.command)
 
289
 
 
290
        return False
 
291
 
 
292
    def send_header_auth_reqed(self):
 
293
        tcs = self.server.test_case_server
 
294
        header = 'Digest realm="%s", ' % tcs.auth_realm
 
295
        header += 'nonce="%s", algorithm="%s", qop="auth"' % (tcs.auth_nonce,
 
296
                                                              'MD5')
 
297
        self.send_header(tcs.auth_header_sent,header)
 
298
 
 
299
 
 
300
class AuthServer(http_server.HttpServer):
 
301
    """Extends HttpServer with a dictionary of passwords.
 
302
 
 
303
    This is used as a base class for various schemes which should
 
304
    all use or redefined the associated AuthRequestHandler.
 
305
 
 
306
    Note that no users are defined by default, so add_user should
 
307
    be called before issuing the first request.
 
308
    """
 
309
 
 
310
    # The following attributes should be set dy daughter classes
 
311
    # and are used by AuthRequestHandler.
 
312
    auth_header_sent = None
 
313
    auth_header_recv = None
 
314
    auth_error_code = None
 
315
    auth_realm = "Thou should not pass"
 
316
 
 
317
    def __init__(self, request_handler, auth_scheme,
 
318
                 protocol_version=None):
 
319
        http_server.HttpServer.__init__(self, request_handler,
 
320
                                        protocol_version=protocol_version)
 
321
        self.auth_scheme = auth_scheme
 
322
        self.password_of = {}
 
323
        self.auth_required_errors = 0
 
324
 
 
325
    def add_user(self, user, password):
 
326
        """Declare a user with an associated password.
 
327
 
 
328
        password can be empty, use an empty string ('') in that
 
329
        case, not None.
 
330
        """
 
331
        self.password_of[user] = password
 
332
 
 
333
    def authorized(self, user, password):
 
334
        """Check that the given user provided the right password"""
 
335
        expected_password = self.password_of.get(user, None)
 
336
        return expected_password is not None and password == expected_password
 
337
 
 
338
 
 
339
# FIXME: There is some code duplication with
 
340
# _urllib2_wrappers.py.DigestAuthHandler. If that duplication
 
341
# grows, it may require a refactoring. Also, we don't implement
 
342
# SHA algorithm nor MD5-sess here, but that does not seem worth
 
343
# it.
 
344
class DigestAuthServer(AuthServer):
 
345
    """A digest authentication server"""
 
346
 
 
347
    auth_nonce = 'now!'
 
348
 
 
349
    def __init__(self, request_handler, auth_scheme,
 
350
                 protocol_version=None):
 
351
        AuthServer.__init__(self, request_handler, auth_scheme,
 
352
                            protocol_version=protocol_version)
 
353
 
 
354
    def digest_authorized(self, auth, command):
 
355
        nonce = auth['nonce']
 
356
        if nonce != self.auth_nonce:
 
357
            return False
 
358
        realm = auth['realm']
 
359
        if realm != self.auth_realm:
 
360
            return False
 
361
        user = auth['username']
 
362
        if not self.password_of.has_key(user):
 
363
            return False
 
364
        algorithm= auth['algorithm']
 
365
        if algorithm != 'MD5':
 
366
            return False
 
367
        qop = auth['qop']
 
368
        if qop != 'auth':
 
369
            return False
 
370
 
 
371
        password = self.password_of[user]
 
372
 
 
373
        # Recalculate the response_digest to compare with the one
 
374
        # sent by the client
 
375
        A1 = '%s:%s:%s' % (user, realm, password)
 
376
        A2 = '%s:%s' % (command, auth['uri'])
 
377
 
 
378
        H = lambda x: md5.new(x).hexdigest()
 
379
        KD = lambda secret, data: H("%s:%s" % (secret, data))
 
380
 
 
381
        nonce_count = int(auth['nc'], 16)
 
382
 
 
383
        ncvalue = '%08x' % nonce_count
 
384
 
 
385
        cnonce = auth['cnonce']
 
386
        noncebit = '%s:%s:%s:%s:%s' % (nonce, ncvalue, cnonce, qop, H(A2))
 
387
        response_digest = KD(H(A1), noncebit)
 
388
 
 
389
        return response_digest == auth['response']
 
390
 
 
391
class HTTPAuthServer(AuthServer):
 
392
    """An HTTP server requiring authentication"""
 
393
 
 
394
    def init_http_auth(self):
 
395
        self.auth_header_sent = 'WWW-Authenticate'
 
396
        self.auth_header_recv = 'Authorization'
 
397
        self.auth_error_code = 401
 
398
 
 
399
 
 
400
class ProxyAuthServer(AuthServer):
 
401
    """A proxy server requiring authentication"""
 
402
 
 
403
    def init_proxy_auth(self):
 
404
        self.proxy_requests = True
 
405
        self.auth_header_sent = 'Proxy-Authenticate'
 
406
        self.auth_header_recv = 'Proxy-Authorization'
 
407
        self.auth_error_code = 407
 
408
 
 
409
 
 
410
class HTTPBasicAuthServer(HTTPAuthServer):
 
411
    """An HTTP server requiring basic authentication"""
 
412
 
 
413
    def __init__(self, protocol_version=None):
 
414
        HTTPAuthServer.__init__(self, BasicAuthRequestHandler, 'basic',
 
415
                                protocol_version=protocol_version)
 
416
        self.init_http_auth()
 
417
 
 
418
 
 
419
class HTTPDigestAuthServer(DigestAuthServer, HTTPAuthServer):
 
420
    """An HTTP server requiring digest authentication"""
 
421
 
 
422
    def __init__(self, protocol_version=None):
 
423
        DigestAuthServer.__init__(self, DigestAuthRequestHandler, 'digest',
 
424
                                  protocol_version=protocol_version)
 
425
        self.init_http_auth()
 
426
 
 
427
 
 
428
class ProxyBasicAuthServer(ProxyAuthServer):
 
429
    """A proxy server requiring basic authentication"""
 
430
 
 
431
    def __init__(self, protocol_version=None):
 
432
        ProxyAuthServer.__init__(self, BasicAuthRequestHandler, 'basic',
 
433
                                 protocol_version=protocol_version)
 
434
        self.init_proxy_auth()
 
435
 
 
436
 
 
437
class ProxyDigestAuthServer(DigestAuthServer, ProxyAuthServer):
 
438
    """A proxy server requiring basic authentication"""
 
439
 
 
440
    def __init__(self, protocol_version=None):
 
441
        ProxyAuthServer.__init__(self, DigestAuthRequestHandler, 'digest',
 
442
                                 protocol_version=protocol_version)
 
443
        self.init_proxy_auth()
 
444
 
 
445