Merge lp:~alecu/ubuntu-sso-client/and-post into lp:ubuntu-sso-client

Proposed by Alejandro J. Cura
Status: Merged
Approved by: Alejandro J. Cura
Approved revision: 843
Merged at revision: 835
Proposed branch: lp:~alecu/ubuntu-sso-client/and-post
Merge into: lp:ubuntu-sso-client
Diff against target: 869 lines (+530/-59)
9 files modified
ubuntu_sso/utils/webclient/common.py (+43/-7)
ubuntu_sso/utils/webclient/libsoup.py (+6/-4)
ubuntu_sso/utils/webclient/qtnetwork.py (+7/-5)
ubuntu_sso/utils/webclient/restful.py (+13/-5)
ubuntu_sso/utils/webclient/tests/test_restful.py (+38/-27)
ubuntu_sso/utils/webclient/tests/test_timestamp.py (+140/-0)
ubuntu_sso/utils/webclient/tests/test_webclient.py (+128/-1)
ubuntu_sso/utils/webclient/timestamp.py (+74/-0)
ubuntu_sso/utils/webclient/txweb.py (+81/-10)
To merge this branch: bzr merge lp:~alecu/ubuntu-sso-client/and-post
Reviewer Review Type Date Requested Status
Natalia Bidart (community) Approve
Manuel de la Peña (community) Approve
Review via email: mp+88949@code.launchpad.net

Commit message

Restfulclient calls are now POST; response.headers are now case-insensitive dicts; OAuth timestamp sync with the server (LP: #916034)

Description of the change

Restfulclient calls are now POST; response.headers are now case-insensitive dicts; OAuth timestamp sync with the server (LP: #916034)

To post a comment you must log in.
Revision history for this message
Manuel de la Peña (mandel) wrote :

The header dictionary looks very similar to the InsensitiveDict found in twisted.python.util. Can we reuse the twisted implementation?

review: Needs Fixing
Revision history for this message
Alejandro J. Cura (alecu) wrote :

> The header dictionary looks very similar to the InsensitiveDict found in
> twisted.python.util. Can we reuse the twisted implementation?

The HeaderDict is slightly different from the twisted InsensitiveDict in that the former is a specialization of defaultdict from the stdlib.

The thing is that *we could* simulate a defaultdict from the code in each webclient, by checking for existence of the element and creating it just before using it. But I think the code would be repeated but slightly different in each webclient, so I think it looks better this way.

lp:~alecu/ubuntu-sso-client/and-post updated
842. By Alejandro J. Cura

change the assert to assertEqual

Revision history for this message
Manuel de la Peña (mandel) wrote :

+1

review: Approve
Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Can headers be in "any" language? I was wondering since lowering a string may collide two different words in some languages. I know Turkish has several i's with unexpected upper/lower values.

Also, I think we need to call self.content.close() on connectionLost() (inside StringProtocol).

review: Needs Information
Revision history for this message
Alejandro J. Cura (alecu) wrote :

> Can headers be in "any" language? I was wondering since lowering a string may
> collide two different words in some languages. I know Turkish has several i's
> with unexpected upper/lower values.

The "name" of the headers must be ASCII according to the http spec, so it's not an issue in this case.

> Also, I think we need to call self.content.close() on connectionLost() (inside
> StringProtocol).

I don't think it's necessary to do .close() on StringIO objects, since the resources used by them are freed as soon as the objects are collected.

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Looks good!

One tiny note: copyright for timestamp checker files is 2011, perhaps we should make that 2012?

review: Approve
lp:~alecu/ubuntu-sso-client/and-post updated
843. By Alejandro J. Cura

fix copyright date on files

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'ubuntu_sso/utils/webclient/common.py'
2--- ubuntu_sso/utils/webclient/common.py 2012-01-06 20:13:11 +0000
3+++ ubuntu_sso/utils/webclient/common.py 2012-01-19 00:42:23 +0000
4@@ -15,11 +15,12 @@
5 # with this program. If not, see <http://www.gnu.org/licenses/>.
6 """The common bits of a webclient."""
7
8-import time
9+import collections
10
11 from httplib2 import iri2uri
12 from oauth import oauth
13-from twisted.internet import defer
14+
15+from ubuntu_sso.utils.webclient.timestamp import TimestampChecker
16
17
18 class WebClientError(Exception):
19@@ -39,25 +40,60 @@
20 self.headers = headers
21
22
23+class HeaderDict(collections.defaultdict):
24+ """A case insensitive dict for headers."""
25+
26+ # pylint: disable=E1002
27+ def __init__(self, *args, **kwargs):
28+ """Handle case-insensitive keys."""
29+ super(HeaderDict, self).__init__(list, *args, **kwargs)
30+ # pylint: disable=E1101
31+ for key, value in self.items():
32+ super(HeaderDict, self).__delitem__(key)
33+ self[key] = value
34+
35+ def __setitem__(self, key, value):
36+ """Set the value with a case-insensitive key."""
37+ super(HeaderDict, self).__setitem__(key.lower(), value)
38+
39+ def __getitem__(self, key):
40+ """Get the value with a case-insensitive key."""
41+ return super(HeaderDict, self).__getitem__(key.lower())
42+
43+ def __delitem__(self, key):
44+ """Delete the item with the case-insensitive key."""
45+ super(HeaderDict, self).__delitem__(key.lower())
46+
47+ def __contains__(self, key):
48+ """Check the containment with a case-insensitive key."""
49+ return super(HeaderDict, self).__contains__(key.lower())
50+
51+
52 class BaseWebClient(object):
53 """The webclient base class, to be extended by backends."""
54
55+ timestamp_checker = None
56+
57 def __init__(self, username=None, password=None):
58 """Initialize this instance."""
59 self.username = username
60 self.password = password
61
62 def request(self, iri, method="GET", extra_headers=None,
63- oauth_credentials=None):
64+ oauth_credentials=None, post_content=None):
65 """Return a deferred that will be fired with a Response object."""
66 raise NotImplementedError
67
68+ @classmethod
69+ def get_timestamp_checker(cls):
70+ """Get the timestamp checker for this class of webclient."""
71+ if cls.timestamp_checker is None:
72+ cls.timestamp_checker = TimestampChecker(cls())
73+ return cls.timestamp_checker
74+
75 def get_timestamp(self):
76 """Get a timestamp synchronized with the server."""
77- # pylint: disable=W0511
78- # TODO: get the synchronized timestamp
79- timestamp = time.time()
80- return defer.succeed(int(timestamp))
81+ return self.get_timestamp_checker().get_faithful_time()
82
83 def force_use_proxy(self, settings):
84 """Setup this webclient to use the given proxy settings."""
85
86=== modified file 'ubuntu_sso/utils/webclient/libsoup.py'
87--- ubuntu_sso/utils/webclient/libsoup.py 2012-01-04 21:26:50 +0000
88+++ ubuntu_sso/utils/webclient/libsoup.py 2012-01-19 00:42:23 +0000
89@@ -17,12 +17,11 @@
90
91 import httplib
92
93-from collections import defaultdict
94-
95 from twisted.internet import defer
96
97 from ubuntu_sso.utils.webclient.common import (
98 BaseWebClient,
99+ HeaderDict,
100 Response,
101 UnauthorizedError,
102 WebClientError,
103@@ -48,7 +47,7 @@
104 def _on_message(self, session, message, d):
105 """Handle the result of an http message."""
106 if message.status_code == httplib.OK:
107- headers = defaultdict(list)
108+ headers = HeaderDict()
109 response_headers = message.get_property("response-headers")
110 add_header = lambda key, value, _: headers[key].append(value)
111 response_headers.foreach(add_header, None)
112@@ -68,7 +67,7 @@
113
114 @defer.inlineCallbacks
115 def request(self, iri, method="GET", extra_headers=None,
116- oauth_credentials=None):
117+ oauth_credentials=None, post_content=None):
118 """Return a deferred that will be fired with a Response object."""
119 uri = self.iri_to_uri(iri)
120 if extra_headers:
121@@ -88,6 +87,9 @@
122 for key, value in headers.iteritems():
123 message.request_headers.append(key, value)
124
125+ if post_content:
126+ message.request_body.append(post_content)
127+
128 self.session.queue_message(message, self._on_message, d)
129 response = yield d
130 defer.returnValue(response)
131
132=== modified file 'ubuntu_sso/utils/webclient/qtnetwork.py'
133--- ubuntu_sso/utils/webclient/qtnetwork.py 2012-01-12 17:46:59 +0000
134+++ ubuntu_sso/utils/webclient/qtnetwork.py 2012-01-19 00:42:23 +0000
135@@ -17,9 +17,8 @@
136
137 import sys
138
139-from collections import defaultdict
140-
141 from PyQt4.QtCore import (
142+ QBuffer,
143 QCoreApplication,
144 QUrl,
145 )
146@@ -33,6 +32,7 @@
147
148 from ubuntu_sso.utils.webclient.common import (
149 BaseWebClient,
150+ HeaderDict,
151 Response,
152 UnauthorizedError,
153 WebClientError,
154@@ -62,7 +62,7 @@
155
156 @defer.inlineCallbacks
157 def request(self, iri, method="GET", extra_headers=None,
158- oauth_credentials=None):
159+ oauth_credentials=None, post_content=None):
160 """Return a deferred that will be fired with a Response object."""
161 uri = self.iri_to_uri(iri)
162 request = QNetworkRequest(QUrl(uri))
163@@ -87,7 +87,9 @@
164 elif method == "HEAD":
165 reply = self.nam.head(request)
166 else:
167- reply = self.nam.sendCustomRequest(request, method)
168+ post_buffer = QBuffer()
169+ post_buffer.setData(post_content)
170+ reply = self.nam.sendCustomRequest(request, method, post_buffer)
171 self.replies[reply] = d
172 result = yield d
173 defer.returnValue(result)
174@@ -104,7 +106,7 @@
175 error = reply.error()
176 content = reply.readAll()
177 if not error:
178- headers = defaultdict(list)
179+ headers = HeaderDict()
180 for key, value in reply.rawHeaderPairs():
181 headers[str(key)].append(str(value))
182 response = Response(bytes(content), headers)
183
184=== modified file 'ubuntu_sso/utils/webclient/restful.py'
185--- ubuntu_sso/utils/webclient/restful.py 2012-01-06 18:57:43 +0000
186+++ ubuntu_sso/utils/webclient/restful.py 2012-01-19 00:42:23 +0000
187@@ -22,6 +22,10 @@
188
189 from ubuntu_sso.utils import webclient
190
191+POST_HEADERS = {
192+ "content-type": "application/x-www-form-urlencoded",
193+}
194+
195
196 class RestfulClient(object):
197 """A proxy-enabled restful client."""
198@@ -39,14 +43,18 @@
199 def restcall(self, method, **kwargs):
200 """Make a restful call."""
201 assert isinstance(method, unicode)
202+ params = {}
203 for key, value in kwargs.items():
204 if isinstance(value, basestring):
205 assert isinstance(value, unicode)
206- kwargs[key] = value.encode("utf-8")
207+ params[key] = json.dumps(value)
208 namespace, operation = method.split(".")
209- kwargs["ws.op"] = operation
210- encoded_args = urllib.urlencode(kwargs)
211- iri = self.service_iri + namespace + "?" + encoded_args
212+ params["ws.op"] = operation
213+ encoded_args = urllib.urlencode(params)
214+ iri = self.service_iri + namespace
215 creds = self.oauth_credentials
216- result = yield self.webclient.request(iri, oauth_credentials=creds)
217+ result = yield self.webclient.request(iri, method="POST",
218+ oauth_credentials=creds,
219+ post_content=encoded_args,
220+ extra_headers=POST_HEADERS)
221 defer.returnValue(json.loads(result.content))
222
223=== modified file 'ubuntu_sso/utils/webclient/tests/test_restful.py'
224--- ubuntu_sso/utils/webclient/tests/test_restful.py 2012-01-06 18:57:43 +0000
225+++ ubuntu_sso/utils/webclient/tests/test_restful.py 2012-01-19 00:42:23 +0000
226@@ -87,40 +87,51 @@
227 self.assertEqual(len(self.wc.called), 1)
228
229 @defer.inlineCallbacks
230- def get_parsed_url(self):
231- """Call the sample operation, and return the url used."""
232+ def test_restful_namespace_added_to_url(self):
233+ """The restful namespace is added to the url."""
234 yield self.rc.restcall(SAMPLE_OPERATION, **SAMPLE_ARGS)
235 iri, _, _ = self.wc.called[0]
236 uri = iri.encode("ascii")
237- defer.returnValue(urlparse.urlparse(uri))
238-
239- @defer.inlineCallbacks
240- def test_restful_namespace_added_to_url(self):
241- """The restful namespace is added to the url."""
242- url = yield self.get_parsed_url()
243+ url = urlparse.urlparse(uri)
244+ # pylint: disable=E1101
245 self.assertTrue(url.path.endswith(SAMPLE_NAMESPACE),
246 "The namespace is included in url")
247
248 @defer.inlineCallbacks
249- def test_restful_method_added_to_url(self):
250- """The restful method is added to the url."""
251- url = yield self.get_parsed_url()
252- url_query = urlparse.parse_qs(url.query)
253- self.assertEqual(url_query["ws.op"][0], SAMPLE_METHOD)
254-
255- @defer.inlineCallbacks
256- def test_arguments_added_to_call(self):
257- """The keyword arguments are used in the called url."""
258- url = yield self.get_parsed_url()
259- url_query = dict(urlparse.parse_qsl(url.query))
260- del(url_query["ws.op"])
261- expected = {}
262- for key, value in SAMPLE_ARGS.items():
263- if isinstance(value, unicode):
264- expected[key] = value.encode("utf-8")
265- else:
266- expected[key] = str(value)
267- self.assertEqual(url_query, expected)
268+ def test_restful_method_added_to_params(self):
269+ """The restful method is added to the params."""
270+ yield self.rc.restcall(SAMPLE_OPERATION, **SAMPLE_ARGS)
271+ _, _, webcall_kwargs = self.wc.called[0]
272+ wc_params = urlparse.parse_qs(webcall_kwargs["post_content"])
273+ self.assertEqual(wc_params["ws.op"][0], SAMPLE_METHOD)
274+
275+ @defer.inlineCallbacks
276+ def test_arguments_added_as_json_to_webcall(self):
277+ """The keyword arguments are used as json in the webcall."""
278+ yield self.rc.restcall(SAMPLE_OPERATION, **SAMPLE_ARGS)
279+ _, _, webcall_kwargs = self.wc.called[0]
280+ params = urlparse.parse_qsl(webcall_kwargs["post_content"])
281+ result = {}
282+ for key, value in params:
283+ if key == "ws.op":
284+ continue
285+ result[key] = restful.json.loads(value)
286+ self.assertEqual(result, SAMPLE_ARGS)
287+
288+ @defer.inlineCallbacks
289+ def test_post_header_sent(self):
290+ """A header is sent specifying the contents of the post."""
291+ yield self.rc.restcall(SAMPLE_OPERATION, **SAMPLE_ARGS)
292+ _, _, webcall_kwargs = self.wc.called[0]
293+ self.assertEqual(restful.POST_HEADERS,
294+ webcall_kwargs["extra_headers"])
295+
296+ @defer.inlineCallbacks
297+ def test_post_method_set(self):
298+ """The method of the webcall is set to POST."""
299+ yield self.rc.restcall(SAMPLE_OPERATION, **SAMPLE_ARGS)
300+ _, _, webcall_kwargs = self.wc.called[0]
301+ self.assertEqual("POST", webcall_kwargs["method"])
302
303 @defer.inlineCallbacks
304 def test_return_value_json_parsed(self):
305
306=== added file 'ubuntu_sso/utils/webclient/tests/test_timestamp.py'
307--- ubuntu_sso/utils/webclient/tests/test_timestamp.py 1970-01-01 00:00:00 +0000
308+++ ubuntu_sso/utils/webclient/tests/test_timestamp.py 2012-01-19 00:42:23 +0000
309@@ -0,0 +1,140 @@
310+# -*- coding: utf-8 -*-
311+#
312+# Copyright 2011-2012 Canonical Ltd.
313+#
314+# This program is free software: you can redistribute it and/or modify it
315+# under the terms of the GNU General Public License version 3, as published
316+# by the Free Software Foundation.
317+#
318+# This program is distributed in the hope that it will be useful, but
319+# WITHOUT ANY WARRANTY; without even the implied warranties of
320+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
321+# PURPOSE. See the GNU General Public License for more details.
322+#
323+# You should have received a copy of the GNU General Public License along
324+# with this program. If not, see <http://www.gnu.org/licenses/>.
325+"""Tests for the timestamp sync classes."""
326+
327+from twisted.application import internet, service
328+from twisted.internet import defer
329+from twisted.trial.unittest import TestCase
330+from twisted.web import server, resource
331+
332+from ubuntu_sso.utils.webclient import timestamp, webclient_factory
333+
334+
335+class FakedError(Exception):
336+ """Stub to replace Request.error."""
337+
338+
339+class RootResource(resource.Resource):
340+ """A root resource that logs the number of calls."""
341+
342+ isLeaf = True
343+
344+ def __init__(self, *args, **kwargs):
345+ """Initialize this fake instance."""
346+ resource.Resource.__init__(self, *args, **kwargs)
347+ self.count = 0
348+ self.request_headers = []
349+
350+ # pylint: disable=C0103
351+ def render_HEAD(self, request):
352+ """Increase the counter on each render."""
353+ self.request_headers.append(request.requestHeaders)
354+ self.count += 1
355+ return ""
356+
357+
358+class MockWebServer(object):
359+ """A mock webserver for testing."""
360+
361+ # pylint: disable=E1101
362+ def __init__(self):
363+ """Start up this instance."""
364+ self.root = RootResource()
365+ site = server.Site(self.root)
366+ application = service.Application('web')
367+ self.service_collection = service.IServiceCollection(application)
368+ self.tcpserver = internet.TCPServer(0, site)
369+ self.tcpserver.setServiceParent(self.service_collection)
370+ self.service_collection.startService()
371+
372+ def get_iri(self):
373+ """Build the url for this mock server."""
374+ # pylint: disable=W0212
375+ port_num = self.tcpserver._port.getHost().port
376+ return u"http://localhost:%d/" % port_num
377+
378+ def stop(self):
379+ """Shut it down."""
380+ self.service_collection.stopService()
381+
382+
383+class TimestampCheckerTestCase(TestCase):
384+ """Tests for the timestamp checker."""
385+
386+ @defer.inlineCallbacks
387+ def setUp(self):
388+ yield super(TimestampCheckerTestCase, self).setUp()
389+ self.ws = MockWebServer()
390+ self.addCleanup(self.ws.stop)
391+ self.wc = webclient_factory()
392+ self.addCleanup(self.wc.shutdown)
393+ self.patch(timestamp.TimestampChecker, "SERVER_IRI", self.ws.get_iri())
394+
395+ @defer.inlineCallbacks
396+ def test_returned_value_is_int(self):
397+ """The returned value is an integer."""
398+ checker = timestamp.TimestampChecker(self.wc)
399+ result = yield checker.get_faithful_time()
400+ self.assertEqual(type(result), int)
401+
402+ @defer.inlineCallbacks
403+ def test_first_call_does_head(self):
404+ """The first call gets the clock from our web."""
405+ checker = timestamp.TimestampChecker(self.wc)
406+ yield checker.get_faithful_time()
407+ self.assertEqual(self.ws.root.count, 1)
408+
409+ @defer.inlineCallbacks
410+ def test_second_call_is_cached(self):
411+ """For the second call, the time is cached."""
412+ checker = timestamp.TimestampChecker(self.wc)
413+ yield checker.get_faithful_time()
414+ yield checker.get_faithful_time()
415+ self.assertEqual(self.ws.root.count, 1)
416+
417+ @defer.inlineCallbacks
418+ def test_after_timeout_cache_expires(self):
419+ """After some time, the cache expires."""
420+ fake_timestamp = 1
421+ self.patch(timestamp.time, "time", lambda: fake_timestamp)
422+ checker = timestamp.TimestampChecker(self.wc)
423+ yield checker.get_faithful_time()
424+ fake_timestamp += timestamp.TimestampChecker.CHECKING_INTERVAL
425+ yield checker.get_faithful_time()
426+ self.assertEqual(self.ws.root.count, 2)
427+
428+ @defer.inlineCallbacks
429+ def test_server_error_means_skew_not_updated(self):
430+ """When server can't be reached, the skew is not updated."""
431+ fake_timestamp = 1
432+ self.patch(timestamp.time, "time", lambda: fake_timestamp)
433+ checker = timestamp.TimestampChecker(self.wc)
434+ failing_get_server_time = lambda _: defer.fail(FakedError())
435+ self.patch(checker, "get_server_time", failing_get_server_time)
436+ yield checker.get_faithful_time()
437+ self.assertEqual(checker.skew, 0)
438+ self.assertEqual(checker.next_check,
439+ fake_timestamp + timestamp.TimestampChecker.ERROR_INTERVAL)
440+
441+ @defer.inlineCallbacks
442+ def test_server_date_sends_nocache_headers(self):
443+ """Getting the server date sends the no-cache headers."""
444+ checker = timestamp.TimestampChecker(self.wc)
445+ yield checker.get_server_date_header(self.ws.get_iri())
446+ self.assertEqual(len(self.ws.root.request_headers), 1)
447+ headers = self.ws.root.request_headers[0]
448+ result = headers.getRawHeaders("Cache-Control")
449+ self.assertEqual(result, ["no-cache"])
450
451=== modified file 'ubuntu_sso/utils/webclient/tests/test_webclient.py'
452--- ubuntu_sso/utils/webclient/tests/test_webclient.py 2012-01-12 13:55:30 +0000
453+++ ubuntu_sso/utils/webclient/tests/test_webclient.py 2012-01-19 00:42:23 +0000
454@@ -28,6 +28,7 @@
455 from ubuntuone.devtools.testcases.squid import SquidTestCase
456
457 from ubuntu_sso.utils import webclient
458+from ubuntu_sso.utils.webclient.common import HeaderDict
459
460 ANY_VALUE = object()
461 SAMPLE_KEY = "result"
462@@ -42,12 +43,15 @@
463 token_secret="the token secret",
464 )
465 SAMPLE_HEADERS = {SAMPLE_KEY: SAMPLE_VALUE}
466+SAMPLE_POST_PARAMS = {"param1": "value1", "param2": "value2"}
467
468 SIMPLERESOURCE = "simpleresource"
469+POSTABLERESOURECE = "postableresourece"
470 THROWERROR = "throwerror"
471 UNAUTHORIZED = "unauthorized"
472 HEADONLY = "headonly"
473 VERIFYHEADERS = "verifyheaders"
474+VERIFYPOSTPARAMS = "verifypostparams"
475 GUARDED = "guarded"
476 OAUTHRESOURCE = "oauthresource"
477
478@@ -70,6 +74,14 @@
479 return SAMPLE_RESOURCE
480
481
482+class PostableResource(resource.Resource):
483+ """A resource that only answers to POST requests."""
484+
485+ def render_POST(self, request):
486+ """Make a bit of html out of these resource's content."""
487+ return SAMPLE_RESOURCE
488+
489+
490 class HeadOnlyResource(resource.Resource):
491 """A resource that fails if called with a method other than HEAD."""
492
493@@ -87,6 +99,29 @@
494 if headers != [SAMPLE_VALUE]:
495 request.setResponseCode(http.BAD_REQUEST)
496 return "ERROR: Expected header not present."
497+ request.setHeader(SAMPLE_KEY, SAMPLE_VALUE)
498+ return SAMPLE_RESOURCE
499+
500+
501+class VerifyPostParameters(resource.Resource):
502+ """A resource that answers to POST requests with some parameters."""
503+
504+ def fetch_post_args_only(self, request):
505+ """Fetch only the POST arguments, not the args in the url."""
506+ request.process = lambda: None
507+ request.requestReceived(request.method, request.path,
508+ request.clientproto)
509+ return request.args
510+
511+ def render_POST(self, request):
512+ """Verify the parameters that we've been called with."""
513+ post_params = self.fetch_post_args_only(request)
514+ expected = dict((key, [val]) for key, val
515+ in SAMPLE_POST_PARAMS.items())
516+ if post_params != expected:
517+ request.setResponseCode(http.BAD_REQUEST)
518+ return "ERROR: Expected arguments not present, %r != %r" % (
519+ post_params, expected)
520 return SAMPLE_RESOURCE
521
522
523@@ -142,6 +177,7 @@
524 """Start up this instance."""
525 root = resource.Resource()
526 root.putChild(SIMPLERESOURCE, SimpleResource())
527+ root.putChild(POSTABLERESOURECE, PostableResource())
528
529 root.putChild(THROWERROR, resource.NoResource())
530
531@@ -150,6 +186,7 @@
532 root.putChild(UNAUTHORIZED, unauthorized_resource)
533 root.putChild(HEADONLY, HeadOnlyResource())
534 root.putChild(VERIFYHEADERS, VerifyHeadersResource())
535+ root.putChild(VERIFYPOSTPARAMS, VerifyPostParameters())
536 root.putChild(OAUTHRESOURCE, OAuthCheckerResource())
537
538 db = checkers.InMemoryUsernamePasswordDatabaseDontUse()
539@@ -259,6 +296,25 @@
540 webclient.WebClientError)
541
542 @defer.inlineCallbacks
543+ def test_post(self):
544+ """Test a post request."""
545+ result = yield self.wc.request(self.base_iri + POSTABLERESOURECE,
546+ method="POST")
547+ self.assertEqual(SAMPLE_RESOURCE, result.content)
548+
549+ @defer.inlineCallbacks
550+ def test_post_with_args(self):
551+ """Test a post request with arguments."""
552+ args = urllib.urlencode(SAMPLE_POST_PARAMS)
553+ iri = self.base_iri + VERIFYPOSTPARAMS + "?" + args
554+ headers = {
555+ "content-type": "application/x-www-form-urlencoded",
556+ }
557+ result = yield self.wc.request(iri, method="POST",
558+ extra_headers=headers, post_content=args)
559+ self.assertEqual(SAMPLE_RESOURCE, result.content)
560+
561+ @defer.inlineCallbacks
562 def test_unauthorized(self):
563 """Detect when a request failed with the UNAUTHORIZED http code."""
564 yield self.assertFailure(self.wc.request(self.base_iri + UNAUTHORIZED),
565@@ -275,7 +331,8 @@
566 """The extra_headers are sent to the server."""
567 result = yield self.wc.request(self.base_iri + VERIFYHEADERS,
568 extra_headers=SAMPLE_HEADERS)
569- self.assertEqual(SAMPLE_RESOURCE, result.content)
570+ self.assertIn(SAMPLE_KEY, result.headers)
571+ self.assertEqual(result.headers[SAMPLE_KEY], [SAMPLE_VALUE])
572
573 @defer.inlineCallbacks
574 def test_send_basic_auth(self):
575@@ -294,6 +351,22 @@
576 self.assertEqual(SAMPLE_RESOURCE, result.content)
577
578 @defer.inlineCallbacks
579+ def test_oauth_signing_uses_timestamp(self):
580+ """OAuth signing uses the timestamp."""
581+ called = []
582+
583+ def fake_get_faithful_time():
584+ """A fake get_timestamp"""
585+ called.append(True)
586+ return defer.succeed(1)
587+
588+ tsc = self.wc.get_timestamp_checker()
589+ self.patch(tsc, "get_faithful_time", fake_get_faithful_time)
590+ yield self.wc.request(self.base_iri + OAUTHRESOURCE,
591+ oauth_credentials=SAMPLE_CREDENTIALS)
592+ self.assertTrue(called, "The timestamp must be retrieved.")
593+
594+ @defer.inlineCallbacks
595 def test_returned_content_are_bytes(self):
596 """The returned content are bytes."""
597 result = yield self.wc.request(self.base_iri + OAUTHRESOURCE,
598@@ -302,6 +375,35 @@
599 "The type of %r must be bytes" % result.content)
600
601
602+class TimestampCheckerTestCase(TestCase):
603+ """Tests for the timestampchecker classmethod."""
604+
605+ @defer.inlineCallbacks
606+ def setUp(self):
607+ """Initialize this testcase."""
608+ yield super(TimestampCheckerTestCase, self).setUp()
609+ self.wc = webclient.webclient_factory()
610+ self.patch(self.wc.__class__, "timestamp_checker", None)
611+
612+ def test_timestamp_checker_has_the_same_class_as_the_creator(self):
613+ """The TimestampChecker has the same class."""
614+ tsc = self.wc.get_timestamp_checker()
615+ self.assertEqual(tsc.webclient.__class__, self.wc.__class__)
616+
617+ def test_timestamp_checker_uses_different_webclient_than_the_creator(self):
618+ """The TimestampChecker uses a different webclient than the creator."""
619+ tsc = self.wc.get_timestamp_checker()
620+ self.assertNotEqual(tsc.webclient, self.wc)
621+
622+ def test_timestamp_checker_is_the_same_for_all_webclients(self):
623+ """The TimestampChecker is the same for all webclients."""
624+ tsc1 = self.wc.get_timestamp_checker()
625+ wc2 = webclient.webclient_factory()
626+ tsc2 = wc2.get_timestamp_checker()
627+ # pylint: disable=E1101
628+ self.assertIs(tsc1, tsc2)
629+
630+
631 class BasicProxyTestCase(SquidTestCase):
632 """Test that the proxy works at all."""
633
634@@ -340,6 +442,31 @@
635 test_authenticated_proxy_is_used.skip = reason
636
637
638+class HeaderDictTestCase(TestCase):
639+ """Tests for the case insensitive header dictionary."""
640+
641+ def test_constructor_handles_keys(self):
642+ """The constructor handles case-insensitive keys."""
643+ hd = HeaderDict({"ClAvE": "value"})
644+ self.assertIn("clave", hd)
645+
646+ def test_can_set_get_items(self):
647+ """The item is set/getted."""
648+ hd = HeaderDict()
649+ hd["key"] = "value"
650+ hd["KEY"] = "value2"
651+ self.assertEqual(hd["key"], "value2")
652+
653+ def test_can_test_presence(self):
654+ """The presence of an item is found."""
655+ hd = HeaderDict()
656+ self.assertNotIn("cLaVe", hd)
657+ hd["CLAVE"] = "value1"
658+ self.assertIn("cLaVe", hd)
659+ del(hd["cLAVe"])
660+ self.assertNotIn("cLaVe", hd)
661+
662+
663 class OAuthTestCase(TestCase):
664 """Test for the oauth signing code."""
665
666
667=== added file 'ubuntu_sso/utils/webclient/timestamp.py'
668--- ubuntu_sso/utils/webclient/timestamp.py 1970-01-01 00:00:00 +0000
669+++ ubuntu_sso/utils/webclient/timestamp.py 2012-01-19 00:42:23 +0000
670@@ -0,0 +1,74 @@
671+# -*- coding: utf-8 -*-
672+#
673+# Copyright 2011-2012 Canonical Ltd.
674+#
675+# This program is free software: you can redistribute it and/or modify it
676+# under the terms of the GNU General Public License version 3, as published
677+# by the Free Software Foundation.
678+#
679+# This program is distributed in the hope that it will be useful, but
680+# WITHOUT ANY WARRANTY; without even the implied warranties of
681+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
682+# PURPOSE. See the GNU General Public License for more details.
683+#
684+# You should have received a copy of the GNU General Public License along
685+# with this program. If not, see <http://www.gnu.org/licenses/>.
686+"""Timestamp synchronization with the server."""
687+
688+import time
689+
690+from twisted.internet import defer
691+from twisted.web import http
692+
693+from ubuntu_sso.logger import setup_logging
694+
695+logger = setup_logging("ubuntu_sso.utils.webclient.timestamp")
696+NOCACHE_HEADERS = {"Cache-Control": "no-cache"}
697+
698+
699+class TimestampChecker(object):
700+ """A timestamp that's regularly checked with a server."""
701+
702+ CHECKING_INTERVAL = 60 * 60 # in seconds
703+ ERROR_INTERVAL = 30 # in seconds
704+ SERVER_IRI = u"http://one.ubuntu.com/api/time"
705+
706+ def __init__(self, webclient):
707+ """Initialize this instance."""
708+ self.next_check = time.time()
709+ self.skew = 0
710+ self.webclient = webclient
711+
712+ @defer.inlineCallbacks
713+ def get_server_date_header(self, server_iri):
714+ """Get the server date using twisted webclient."""
715+ response = yield self.webclient.request(server_iri, method="HEAD",
716+ extra_headers=NOCACHE_HEADERS)
717+ defer.returnValue(response.headers["Date"][0])
718+
719+ @defer.inlineCallbacks
720+ def get_server_time(self):
721+ """Get the time at the server."""
722+ date_string = yield self.get_server_date_header(self.SERVER_IRI)
723+ timestamp = http.stringToDatetime(date_string)
724+ defer.returnValue(timestamp)
725+
726+ @defer.inlineCallbacks
727+ def get_faithful_time(self):
728+ """Get an accurate timestamp."""
729+ local_time = time.time()
730+ if local_time >= self.next_check:
731+ try:
732+ server_time = yield self.get_server_time()
733+ self.next_check = local_time + self.CHECKING_INTERVAL
734+ self.skew = server_time - local_time
735+ logger.debug("Calculated server-local time skew:", self.skew)
736+ # We just log all exceptions while trying to get the server time
737+ # pylint: disable=W0703
738+ except Exception, e:
739+ logger.debug("Error while verifying the server time skew:", e)
740+ self.next_check = local_time + self.ERROR_INTERVAL
741+ logger.debug("Using corrected timestamp:",
742+ http.datetimeToString(local_time + self.skew))
743+ defer.returnValue(int(local_time + self.skew))
744+# timestamp_checker = TimestampChecker()
745
746=== modified file 'ubuntu_sso/utils/webclient/txweb.py'
747--- ubuntu_sso/utils/webclient/txweb.py 2012-01-04 21:26:27 +0000
748+++ ubuntu_sso/utils/webclient/txweb.py 2012-01-19 00:42:23 +0000
749@@ -17,25 +17,70 @@
750
751 import base64
752
753-from twisted.web import client, error, http
754-from twisted.internet import defer
755+from StringIO import StringIO
756+
757+from twisted.internet import defer, protocol, reactor
758+from twisted.web import client, http, http_headers, iweb
759+from zope.interface import implements
760
761 from ubuntu_sso.utils.webclient.common import (
762 BaseWebClient,
763+ HeaderDict,
764 Response,
765 UnauthorizedError,
766 WebClientError,
767 )
768
769
770+class StringProtocol(protocol.Protocol):
771+ """Hold the stuff received in a StringIO."""
772+
773+ # pylint: disable=C0103
774+ def __init__(self):
775+ """Initialize this instance."""
776+ self.deferred = defer.Deferred()
777+ self.content = StringIO()
778+
779+ def dataReceived(self, data):
780+ """Some more blocks received."""
781+ self.content.write(data)
782+
783+ def connectionLost(self, reason=protocol.connectionDone):
784+ """No more bytes available."""
785+ self.deferred.callback(self.content.getvalue())
786+
787+
788+class StringProducer(object):
789+ """Simple implementation of IBodyProducer."""
790+ implements(iweb.IBodyProducer)
791+
792+ def __init__(self, body):
793+ """Initialize this instance with some bytes."""
794+ self.body = body
795+ self.length = len(body)
796+
797+ # pylint: disable=C0103
798+ def startProducing(self, consumer):
799+ """Start producing to the given IConsumer provider."""
800+ consumer.write(self.body)
801+ return defer.succeed(None)
802+
803+ def pauseProducing(self):
804+ """In our case, do nothing."""
805+
806+ def stopProducing(self):
807+ """In our case, do nothing."""
808+
809+
810 class WebClient(BaseWebClient):
811 """A simple web client that does not support proxies, yet."""
812
813 @defer.inlineCallbacks
814 def request(self, iri, method="GET", extra_headers=None,
815- oauth_credentials=None):
816+ oauth_credentials=None, post_content=None):
817 """Get the page, or fail trying."""
818 uri = self.iri_to_uri(iri)
819+
820 if extra_headers:
821 headers = dict(extra_headers)
822 else:
823@@ -52,13 +97,39 @@
824 headers["Authorization"] = "Basic " + auth
825
826 try:
827- result = yield client.getPage(uri, method=method, headers=headers)
828- response = Response(result)
829- defer.returnValue(response)
830- except error.Error as e:
831- if int(e.status) == http.UNAUTHORIZED:
832- raise UnauthorizedError(e.message)
833- raise WebClientError(e.message)
834+ request_headers = http_headers.Headers()
835+ for key, value in headers.items():
836+ request_headers.addRawHeader(key, value)
837+ agent = client.Agent(reactor)
838+ if post_content:
839+ body_producer = StringProducer(post_content)
840+ else:
841+ body_producer = None
842+ agent_response = yield agent.request(method, uri,
843+ headers=request_headers,
844+ bodyProducer=body_producer)
845+ raw_headers = agent_response.headers.getAllRawHeaders()
846+ response_headers = HeaderDict(raw_headers)
847+ if method.lower() != "head":
848+ response_content = yield self.get_agent_content(agent_response)
849+ else:
850+ response_content = ""
851+ if agent_response.code == http.OK:
852+ defer.returnValue(Response(response_content, response_headers))
853+ if agent_response.code == http.UNAUTHORIZED:
854+ raise UnauthorizedError(agent_response.phrase,
855+ response_content)
856+ raise WebClientError(agent_response.phrase, response_content)
857+ except WebClientError:
858+ raise
859+ except Exception as e:
860+ raise WebClientError(e.message, e)
861+
862+ def get_agent_content(self, agent_response):
863+ """Get the contents of an agent response."""
864+ string_protocol = StringProtocol()
865+ agent_response.deliverBody(string_protocol)
866+ return string_protocol.deferred
867
868 def force_use_proxy(self, settings):
869 """Setup this webclient to use the given proxy settings."""

Subscribers

People subscribed via source and target branches