Merge lp:~alecu/ubuntu-sso-client/and-post into lp:ubuntu-sso-client
- and-post
- Merge into trunk
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 | ||||
Related bugs: |
|
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)
Alejandro J. Cura (alecu) wrote : | # |
> The header dictionary looks very similar to the InsensitiveDict found in
> twisted.
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.
- 842. By Alejandro J. Cura
-
change the assert to assertEqual
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.
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.
> 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?
- 843. By Alejandro J. Cura
-
fix copyright date on files
Preview Diff
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.""" |
The header dictionary looks very similar to the InsensitiveDict found in twisted. python. util. Can we reuse the twisted implementation?