Merge lp:~james-w/canonical-identity-provider/captcha-timeline into lp:canonical-identity-provider/release

Proposed by James Westby
Status: Merged
Approved by: James Westby
Approved revision: no longer in the source branch.
Merged at revision: 1029
Proposed branch: lp:~james-w/canonical-identity-provider/captcha-timeline
Merge into: lp:canonical-identity-provider/release
Diff against target: 610 lines (+307/-32)
11 files modified
src/api/v10/handlers.py (+6/-2)
src/api/v10/tests/test_handlers.py (+35/-2)
src/api/v20/handlers.py (+7/-3)
src/api/v20/tests/test_handlers.py (+44/-2)
src/api/v20/tests/test_registration.py (+3/-2)
src/identityprovider/models/captcha.py (+12/-7)
src/identityprovider/tests/test_models_captcha.py (+30/-3)
src/identityprovider/tests/test_timeline.py (+72/-0)
src/identityprovider/timeline.py (+71/-0)
src/webui/tests/test_views_ui.py (+23/-10)
src/webui/views/ui.py (+4/-1)
To merge this branch: bzr merge lp:~james-w/canonical-identity-provider/captcha-timeline
Reviewer Review Type Date Requested Status
Ricardo Kirkner (community) Approve
Review via email: mp+181938@code.launchpad.net

Commit message

Add entries to the request timeline when creating or verifying a captcha.

These http requests made during a request can take time, so record them
in the timeline. That way if there is a slow request involving one you
can see how long it took.

Description of the change

Hi,

This makes it so that timing information about recaptcha requests
will show up in oopses. The v1 api that makes these requests sometimes
times out, and the oops has no information about what is happening during
the 10s. This will make it such that you will see how long the request
to recaptcha took.

I didn't instrument the captcha verification in the v1 api as it wasn't
easy, and it is deprecated. If we see slowness in those requests we
can revisit that.

Thanks,

James

To post a comment you must log in.
Revision history for this message
Ricardo Kirkner (ricardokirkner) wrote :

LGTM, but if you allow for nitpicking... we have certain conventions for import grouping and ordering; this is

1. group imports by origin (stdlib, 3rd party, app)
2. within each group, subgroup by 'import ... ' then 'from ... import ...'
3. within each subgroup, order asciibetically

l. 109 should go before l. 107
l. 298-299: according to the convention, l. 298 should come after the 'import ..' statements, and l.299 shoudl go into a different group
l. 347-348: these should go into a second group
l. 501: this should go after the 'import ..' lines

review: Approve
Revision history for this message
Ubuntu One Auto Pilot (otto-pilot) wrote :

There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/api/v10/handlers.py'
2--- src/api/v10/handlers.py 2013-08-16 15:16:58 +0000
3+++ src/api/v10/handlers.py 2013-08-26 13:12:23 +0000
4@@ -38,6 +38,9 @@
5 application_token_created,
6 application_token_invalidated,
7 )
8+from identityprovider.timeline import (
9+ get_request_timing_function,
10+)
11
12 from oauth_backend.models import Token
13
14@@ -163,12 +166,13 @@
15
16 @named_operation
17 def new(self, request):
18+ timer = get_request_timing_function(request)
19 try:
20- return Captcha.new().serialize()
21+ return Captcha.new(timer=timer).serialize()
22 except NewCaptchaError as e:
23 logger.exception("Failed to connect to reCaptcha server")
24 # TODO: Some better return here?
25- return e.dummy
26+ return e.dummy.serialize()
27
28
29 class RegistrationHandler(LazrRestfulHandler):
30
31=== modified file 'src/api/v10/tests/test_handlers.py'
32--- src/api/v10/tests/test_handlers.py 2013-08-20 17:47:58 +0000
33+++ src/api/v10/tests/test_handlers.py 2013-08-26 13:12:23 +0000
34@@ -6,10 +6,14 @@
35 from django.utils.http import urlencode
36 from django.core.urlresolvers import reverse
37
38-from mock import patch
39+from mock import (
40+ Mock,
41+ patch,
42+)
43 from lazr.restfulclient.errors import HTTPError
44
45 from gargoyle import gargoyle
46+from timeline import Timeline
47
48 from identityprovider.tests.utils import SSOBaseTestCase
49 from identityprovider.models import (
50@@ -17,6 +21,9 @@
51 APIUser,
52 EmailAddress,
53 )
54+from identityprovider.models.captcha import (
55+ Captcha,
56+)
57 from identityprovider.models.const import (
58 AccountStatus,
59 EmailStatus,
60@@ -29,7 +36,10 @@
61 from identityprovider.tests.utils import authorization_header_from_token
62 from identityprovider.validators import PASSWORD_POLICY_ERROR
63
64-from api.v10.handlers import AuthenticationHandler
65+from api.v10.handlers import (
66+ AuthenticationHandler,
67+ CaptchaHandler,
68+)
69 from api.v10.tests.utils import (
70 AnonAPITestCase,
71 MockRequest,
72@@ -548,3 +558,26 @@
73 self.assertContains(response, email.email)
74 updatedemail = EmailAddress.objects.get(email=email.email)
75 self.assertEqual(EmailStatus.VALIDATED, updatedemail.status)
76+
77+
78+class CaptchasTestCase(SSOBaseTestCase):
79+
80+ url = reverse('api-10-captchas')
81+
82+ @patch.object(Captcha, '_open')
83+ def test_returns_dummy(self, mock_open):
84+ resp = self.client.post(self.url, {'ws.op': 'new'},
85+ HTTP_ACCEPT='application/json')
86+ self.assertEqual(None, json.loads(resp.content)['image_url'])
87+
88+ @patch.object(Captcha, '_open')
89+ def test_puts_entry_in_timeline(self, mock_open):
90+ handler = CaptchaHandler()
91+ request = Mock()
92+ timeline = Timeline()
93+ request.META = {'timeline.timeline': timeline}
94+ handler.new(request)
95+ self.assertEqual(1, len(timeline.actions))
96+ self.assertEqual('captcha-new', timeline.actions[0].category)
97+ self.assertEqual(mock_open.call_args[0][0],
98+ timeline.actions[0].detail)
99
100=== modified file 'src/api/v20/handlers.py'
101--- src/api/v20/handlers.py 2013-08-20 17:47:58 +0000
102+++ src/api/v20/handlers.py 2013-08-26 13:12:23 +0000
103@@ -43,8 +43,9 @@
104 AccountStatus,
105 TokenType,
106 )
107+from identityprovider.store import SSODataStore
108+from identityprovider.timeline import get_request_timing_function
109 from identityprovider.utils import redirection_url_for_token
110-from identityprovider.store import SSODataStore
111 from webui.decorators import limitlogin
112
113
114@@ -164,8 +165,9 @@
115 gargoyle.is_active('CAPTCHA')):
116 extra = {}
117 if data.get('create_captcha', True):
118+ timer = get_request_timing_function(request)
119 try:
120- extra = Captcha.new().serialize()
121+ extra = Captcha.new(timer=timer).serialize()
122 except Exception:
123 logger.exception('failed to create reCaptcha')
124 return errors.CAPTCHA_REQUIRED(**extra)
125@@ -175,8 +177,10 @@
126 captcha = Captcha(captcha_id)
127 verified = False
128 try:
129+ timer = get_request_timing_function(request)
130 verified = captcha.verify(
131- captcha_solution, remote_addr, data['email'])
132+ captcha_solution, remote_addr, data['email'],
133+ timer=timer)
134 except VerifyCaptchaError as e:
135 # only expose HTTP error codes to client
136 code = e.response.code if e.response.code > 200 else None
137
138=== modified file 'src/api/v20/tests/test_handlers.py'
139--- src/api/v20/tests/test_handlers.py 2013-08-20 18:02:58 +0000
140+++ src/api/v20/tests/test_handlers.py 2013-08-26 13:12:23 +0000
141@@ -1,20 +1,22 @@
142 import json
143-
144+from StringIO import StringIO
145 from urllib import urlencode
146
147 from django.core import mail
148 from django.core.serializers.json import DjangoJSONEncoder
149 from django.core.urlresolvers import NoReverseMatch, reverse
150 from gargoyle.testutils import switches
151-from mock import ANY, patch
152+from mock import ANY, Mock, patch
153 from oauth_backend.models import Token
154 from oauth.oauth import OAuthRequest
155+from timeline import Timeline
156
157 from identityprovider.models import (
158 Account,
159 AuthToken,
160 EmailAddress,
161 )
162+from identityprovider.models.captcha import Captcha, CaptchaResponse
163 from identityprovider.models.const import (
164 AccountStatus,
165 EmailStatus,
166@@ -812,3 +814,43 @@
167 url=url, token=self.token, method='DELETE')
168
169 self.assertEqual(response.status_code, 404)
170+
171+
172+class AccountRegistrationHandlerTests(BaseTestCase):
173+
174+ @patch.object(Captcha, '_open')
175+ @switches(CAPTCHA=True)
176+ def test_timeline_for_captcha_generation(self, mock_open):
177+ handler = handlers.AccountRegistrationHandler()
178+ request = Mock()
179+ timeline = Timeline()
180+ request.META = {
181+ 'timeline.timeline': timeline,
182+ 'CONTENT_TYPE': 'application/json',
183+ }
184+ request.data = dict(create_captcha=True)
185+ handler.create(request)
186+ self.assertEqual(1, len(timeline.actions))
187+ self.assertEqual('captcha-new', timeline.actions[0].category)
188+ self.assertEqual(mock_open.call_args[0][0],
189+ timeline.actions[0].detail)
190+
191+ @patch.object(Captcha, '_open')
192+ @switches(CAPTCHA=True)
193+ def test_timeline_for_captcha_verification(self, mock_open):
194+ mock_open.response = CaptchaResponse(500, StringIO(''))
195+ handler = handlers.AccountRegistrationHandler()
196+ request = Mock()
197+ timeline = Timeline()
198+ request.META = {
199+ 'timeline.timeline': timeline,
200+ 'CONTENT_TYPE': 'application/json',
201+ }
202+ request.environ = {'REMOTE_ADDR': '127.0.0.1'}
203+ request.data = dict(captcha_id='captcha-id',
204+ captcha_solution='captcha_solution',
205+ email='test@test.com')
206+ with patch_settings(DISABLE_CAPTCHA_VERIFICATION=False):
207+ handler.create(request)
208+ self.assertEqual(1, len(timeline.actions))
209+ self.assertEqual('captcha-verify', timeline.actions[0].category)
210
211=== modified file 'src/api/v20/tests/test_registration.py'
212--- src/api/v20/tests/test_registration.py 2013-08-08 20:17:55 +0000
213+++ src/api/v20/tests/test_registration.py 2013-08-26 13:12:23 +0000
214@@ -1,4 +1,5 @@
215 from mock import (
216+ ANY,
217 patch,
218 call as mock_call,
219 )
220@@ -177,7 +178,7 @@
221
222 self.assertEqual(response.status_code, 201)
223 expected_calls = mock_call('999').verify(
224- 'foo bar', '127.0.0.1', self.data['email']).call_list()
225+ 'foo bar', '127.0.0.1', self.data['email'], timer=ANY).call_list()
226 self.assertEqual(mock_captcha.mock_calls, expected_calls)
227
228 self.assertIn('openid', json_body)
229@@ -197,7 +198,7 @@
230 response, json_body = call(self.handler.create, self.url, self.data)
231 self.assertEqual(response.status_code, 403)
232 expected_calls = mock_call('999').verify(
233- 'foo bar', '127.0.0.1', self.data['email']).call_list()
234+ 'foo bar', '127.0.0.1', self.data['email'], timer=ANY).call_list()
235 self.assertEqual(mock_captcha.mock_calls, expected_calls)
236
237 self.assertEqual(json_body['code'], "CAPTCHA_FAILURE")
238
239=== modified file 'src/identityprovider/models/captcha.py'
240--- src/identityprovider/models/captcha.py 2013-05-21 13:57:39 +0000
241+++ src/identityprovider/models/captcha.py 2013-08-26 13:12:23 +0000
242@@ -9,6 +9,8 @@
243
244 from django.conf import settings
245
246+from identityprovider.timeline import maybe_contextmanager
247+
248
249 class CaptchaError(Exception):
250 """All instances of CaptchaError have a traceback member."""
251@@ -122,15 +124,16 @@
252 }
253
254 @classmethod
255- def new(cls):
256+ def new(cls, timer=None):
257 cls._setup_opener()
258 url = (settings.CAPTCHA_API_URL +
259 '/challenge?k=%s' % settings.CAPTCHA_PUBLIC_KEY)
260- response = cls._open(url)
261- if response.is_error:
262- raise NewCaptchaError(response.traceback, cls(None, None))
263+ with maybe_contextmanager(timer, 'captcha-new', url):
264+ response = cls._open(url)
265+ if response.is_error:
266+ raise NewCaptchaError(response.traceback, cls(None, None))
267
268- data = response.data()
269+ data = response.data()
270 m = re.search(r"challenge\s*:\s*'(.+?)'", data, re.M | re.S)
271 if m:
272 captcha_id = m.group(1)
273@@ -173,7 +176,7 @@
274
275 return CaptchaResponse(response.code, response, None)
276
277- def verify(self, captcha_solution, remote_ip, email):
278+ def verify(self, captcha_solution, remote_ip, email, timer=None):
279 if self._verified is not None:
280 return self._verified
281
282@@ -194,7 +197,9 @@
283 'response': captcha_solution,
284 })
285 request = urllib2.Request(settings.CAPTCHA_VERIFY_URL, request_data)
286- self.response = self._open(request)
287+ with maybe_contextmanager(timer, 'captcha-verify',
288+ settings.CAPTCHA_VERIFY_URL):
289+ self.response = self._open(request)
290
291 if not self.response.is_error:
292 response_data = self.response.data()
293
294=== modified file 'src/identityprovider/tests/test_models_captcha.py'
295--- src/identityprovider/tests/test_models_captcha.py 2013-03-01 20:26:19 +0000
296+++ src/identityprovider/tests/test_models_captcha.py 2013-08-26 13:12:23 +0000
297@@ -3,21 +3,21 @@
298
299 import unittest
300 import urllib2
301+from contextlib import contextmanager
302+from cStringIO import StringIO
303
304+from django.conf import settings
305 from mock import (
306 Mock,
307 patch,
308 )
309
310-from cStringIO import StringIO
311-
312 from identityprovider.models.captcha import (
313 Captcha,
314 CaptchaResponse,
315 NewCaptchaError,
316 VerifyCaptchaError,
317 )
318-
319 from identityprovider.tests.utils import patch_settings
320
321
322@@ -66,6 +66,20 @@
323
324 self.assertRaises(urllib2.URLError, Captcha.new)
325
326+ @patch.object(Captcha, '_open')
327+ def test_new_captcha_uses_timer(self, mock_open):
328+ passed_info = []
329+
330+ @contextmanager
331+ def timer_fn(category, detail):
332+ passed_info.append((category, detail))
333+ yield
334+ mock_open.return_value = CaptchaResponse(200, StringIO(''))
335+ Captcha.new(timer=timer_fn)
336+ self.assertEqual(1, len(passed_info))
337+ self.assertEqual('captcha-new', passed_info[0][0])
338+ self.assertEqual(mock_open.call_args[0][0], passed_info[0][1])
339+
340
341 class CaptchaVerifyTestCase(unittest.TestCase):
342
343@@ -157,3 +171,16 @@
344 self.assertRaises(
345 VerifyCaptchaError, c.verify, None, 'localhost', ''
346 )
347+
348+ @patch.object(Captcha, '_open')
349+ def test_verify_uses_timer(self, mock_open):
350+ passed_info = []
351+
352+ @contextmanager
353+ def timer_fn(category, detail):
354+ passed_info.append((category, detail))
355+ yield
356+ c = Captcha(None)
357+ c.verify(None, 'localhost', '', timer=timer_fn)
358+ self.assertEqual('captcha-verify', passed_info[0][0])
359+ self.assertEqual(settings.CAPTCHA_VERIFY_URL, passed_info[0][1])
360
361=== added file 'src/identityprovider/tests/test_timeline.py'
362--- src/identityprovider/tests/test_timeline.py 1970-01-01 00:00:00 +0000
363+++ src/identityprovider/tests/test_timeline.py 2013-08-26 13:12:23 +0000
364@@ -0,0 +1,72 @@
365+from contextlib import contextmanager
366+from unittest import TestCase
367+
368+from mock import Mock
369+from timeline import Timeline
370+
371+from identityprovider.timeline import (
372+ get_request_timeline,
373+ get_timing_function,
374+ maybe_contextmanager,
375+)
376+
377+
378+class GetRequestTimelineTests(TestCase):
379+
380+ def test_no_timeline(self):
381+ request = Mock()
382+ request.META = dict()
383+ self.assertEqual(None, get_request_timeline(request))
384+
385+ def test_timeline(self):
386+ request = Mock()
387+ timeline = object()
388+ request.META = {'timeline.timeline': timeline}
389+ self.assertEqual(timeline, get_request_timeline(request))
390+
391+
392+class GetTimingFunctionTests(TestCase):
393+
394+ def test_no_timeline(self):
395+ timer_fn = get_timing_function(None)
396+ with timer_fn('category', 'detail'):
397+ # No crash
398+ pass
399+
400+ def test_timeline(self):
401+ timeline = Timeline()
402+ timer_fn = get_timing_function(timeline)
403+ with timer_fn('category', 'detail'):
404+ pass
405+ self.assertEqual(1, len(timeline.actions))
406+ self.assertEqual('category', timeline.actions[0].category)
407+ self.assertEqual('detail', timeline.actions[0].detail)
408+
409+
410+class MaybeContextManagerTests(TestCase):
411+
412+ def test_None(self):
413+ with maybe_contextmanager(None, 'a'):
414+ # no crash
415+ pass
416+
417+ def test_passes_args(self):
418+ passed_args = []
419+ passed_kwargs = []
420+
421+ @contextmanager
422+ def cm(*args, **kwargs):
423+ passed_args.append(args)
424+ passed_kwargs.append(kwargs)
425+ yield
426+ with maybe_contextmanager(cm, 'a', b='c'):
427+ pass
428+ self.assertEqual([('a',)], passed_args)
429+ self.assertEqual([dict(b='c')], passed_kwargs)
430+
431+ def test_yields_value(self):
432+ @contextmanager
433+ def cm(*args, **kwargs):
434+ yield 'the value'
435+ with maybe_contextmanager(cm) as the_value:
436+ self.assertEqual('the value', the_value)
437
438=== added file 'src/identityprovider/timeline.py'
439--- src/identityprovider/timeline.py 1970-01-01 00:00:00 +0000
440+++ src/identityprovider/timeline.py 2013-08-26 13:12:23 +0000
441@@ -0,0 +1,71 @@
442+from contextlib import contextmanager
443+
444+
445+def get_request_timeline(request):
446+ """Get the timeline for a request, if any.
447+
448+ :return: a Timeline instance if the request has one, or None if not.
449+ """
450+ return request.META.get('timeline.timeline', None)
451+
452+
453+def get_timing_function(timeline):
454+ """Get a context manager that will put timing info in to a timeline.
455+
456+ Given
457+
458+ timer_fn = get_timing_function(timeline, detail_fn)
459+
460+ def request(method, url):
461+ with timer_fn('http-{}'.format(method), url):
462+ <..make request..>
463+
464+ calling request('GET', 'http://google.com/') will lead to an action
465+ being inserted in to the timeline with
466+
467+ start: time at which "with timer_fn(..." was exectuted.
468+ duration: duration until the with block was left, i.e. duration of
469+ the request.
470+ category: 'http-GET'
471+ detail: 'http://google.com/'
472+
473+ :param timeline: the Timeline instance in which to create the action,
474+ or None to take no timing information.
475+ :return: a context manager that can be called with info about the
476+ operation, and will record an action on the timeline for the duration
477+ between __enter__ and __exit__.
478+ """
479+ @contextmanager
480+ def timer_fn(category, detail):
481+ if not timeline:
482+ yield
483+ else:
484+ action = timeline.start(category, detail)
485+ yield
486+ action.finish()
487+ return timer_fn
488+
489+
490+def get_request_timing_function(request):
491+ return get_timing_function(get_request_timeline(request))
492+
493+
494+@contextmanager
495+def maybe_contextmanager(cm, *args, **kwargs):
496+ """Allow an optional context manager to be used.
497+
498+ When you take an optional context manager as an argument, it can be
499+ tricky to use it, as you can't do "with None:".
500+
501+ This function allows for you to call
502+
503+ with maybe_contextmanager(cm, args, ...):
504+ pass
505+
506+ and it will work whether cm is a contextmanager or None.
507+ """
508+ if cm is None:
509+ yield
510+ else:
511+ with cm(*args, **kwargs) as info:
512+ yield info
513
514=== modified file 'src/webui/tests/test_views_ui.py'
515--- src/webui/tests/test_views_ui.py 2013-08-12 14:33:47 +0000
516+++ src/webui/tests/test_views_ui.py 2013-08-26 13:12:23 +0000
517@@ -6,24 +6,22 @@
518 import re
519 import urllib
520 import urllib2
521-
522 from functools import partial
523+from StringIO import StringIO
524+from unittest import skipUnless
525 from urlparse import urlsplit
526
527-from mock import patch, Mock
528-from pyquery import PyQuery
529-from unittest import skipUnless
530-
531 from django.conf import settings
532 from django.contrib.sessions.models import Session
533 from django.core import mail
534 from django.core.urlresolvers import reverse
535 from django.http import QueryDict, HttpResponse
536-
537+from gargoyle import gargoyle
538 from gargoyle.testutils import switches
539-from gargoyle import gargoyle
540-
541+from mock import patch, Mock
542 from openid.message import IDENTIFIER_SELECT
543+from pyquery import PyQuery
544+from timeline import Timeline
545 from u1testutils.logging import LogHandlerTestCase
546
547 from identityprovider import signed
548@@ -41,7 +39,7 @@
549 twofactor,
550 )
551 from identityprovider.models.authtoken import create_token
552-from identityprovider.models.captcha import Captcha
553+from identityprovider.models.captcha import Captcha, CaptchaResponse
554 from identityprovider.models.const import AccountStatus, EmailStatus, TokenType
555 from identityprovider.readonly import ReadOnlyManager
556 from identityprovider.tests import DEFAULT_USER_PASSWORD
557@@ -52,7 +50,6 @@
558 )
559 from identityprovider.utils import validate_launchpad_password
560 from identityprovider.views import server
561-
562 from webui import decorators
563 from webui.views import ui
564
565@@ -138,6 +135,22 @@
566 return r
567
568
569+class VerifyCaptureResponseTests(SSOBaseTestCase):
570+
571+ @patch.object(Captcha, '_open')
572+ def test_uses_timeline_from_request(self, mock_open):
573+ mock_open.return_value = CaptchaResponse(200, StringIO('true\na'))
574+ request = Mock()
575+ timeline = Timeline()
576+ request.META = {'timeline.timeline': timeline}
577+ request.POST = {'recaptcha_challenge_field': 'captcha-id'}
578+ request.environ = {'REMOTE_ADDR': '127.0.0.1'}
579+ with patch.multiple(settings, DISABLE_CAPTCHA_VERIFICATION=False):
580+ ui._verify_captcha_response(None, request, None)
581+ self.assertEqual(1, len(timeline.actions))
582+ self.assertEqual('captcha-verify', timeline.actions[0].category)
583+
584+
585 class SpanishUIViewsTestCase(BaseTestCase):
586
587 def setUp(self):
588
589=== modified file 'src/webui/views/ui.py'
590--- src/webui/views/ui.py 2013-08-21 15:13:22 +0000
591+++ src/webui/views/ui.py 2013-08-26 13:12:23 +0000
592@@ -74,6 +74,7 @@
593 redirection_url_for_token,
594 )
595 from identityprovider.stats import stats
596+from identityprovider.timeline import get_request_timing_function
597 from identityprovider.views.utils import (
598 get_rpconfig,
599 get_rpconfig_from_request,
600@@ -679,7 +680,9 @@
601 email = request.POST.get('email', '')
602 ip_addr = request.environ["REMOTE_ADDR"]
603 try:
604- verified = captcha.verify(captcha_solution, ip_addr, email)
605+ timer_fn = get_request_timing_function(request)
606+ verified = captcha.verify(captcha_solution, ip_addr, email,
607+ timer=timer_fn)
608 if verified:
609 return None
610 except VerifyCaptchaError: