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

Proposed by Alejandro J. Cura on 2012-01-17
Status: Merged
Approved by: Alejandro J. Cura on 2012-01-19
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 Approve on 2012-01-18
Manuel de la Peña (community) 2012-01-17 Approve on 2012-01-18
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.
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
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 on 2012-01-18
842. By Alejandro J. Cura on 2012-01-18

change the assert to assertEqual

Manuel de la Peña (mandel) wrote :

+1

review: Approve
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
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.

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 on 2012-01-19
843. By Alejandro J. Cura on 2012-01-19

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