Merge lp:~salgado/launchpad/bug-462923 into lp:launchpad

Proposed by Guilherme Salgado
Status: Merged
Approved by: Gary Poster
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~salgado/launchpad/bug-462923
Merge into: lp:launchpad
Diff against target: 254 lines
3 files modified
lib/canonical/launchpad/browser/logintoken.py (+39/-13)
lib/canonical/launchpad/browser/tests/test_logintoken.py (+73/-0)
lib/lp/testing/factory.py (+5/-4)
To merge this branch: bzr merge lp:~salgado/launchpad/bug-462923
Reviewer Review Type Date Requested Status
Francis J. Lacoste (community) release-critical Approve
Gary Poster (community) Approve
Review via email: mp+14232@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Guilherme Salgado (salgado) wrote :

= Summary =

https://bugs.edge.launchpad.net/launchpad-foundations/+bug/462923

== Proposed fix ==

Fix the base class to not write to self.next_url, also changing sub
classes to define a default_next_url, used by the super class' new
next_url.

After fixing this I realized the Cancel actions in all views were using
the same validate() method as the continue action, so the users would
have to enter their email address before they could successfully cancel.

Oh, and I also fixed factory.makeGPGKey(), which was completely broken.

== Pre-implementation notes ==

Discussed with Gary.

== Implementation details ==

== Tests ==

== Demo and Q/A ==

= Launchpad lint =

Checking for conflicts. and issues in doctests and templates.
Running jslint, xmllint, pyflakes, and pylint.
Using normal rules.

Linting changed files:
  lib/lp/testing/factory.py
  lib/canonical/launchpad/browser/logintoken.py
  lib/canonical/launchpad/browser/tests/test_logintoken.py

Revision history for this message
Gary Poster (gary) wrote :

Hi Salgado. Great, thank you. Your knowledge of the codebase, and in particular the test harnesses, makes me remember how much I don't know. :-)

As we agreed on IRC, please add something like ``assert self.default_next_url is not None, 'The implementation of %s should provide a value for default_next_url' % (self.__class__.__name__,)`` before line 22 of the diff.

Also, please add a comment here in the MP about why we think it is OK to land this without a concurrent CIP landing.

Thanks again.

review: Approve
Revision history for this message
Guilherme Salgado (salgado) wrote :

For context, some of the logintoken views are extended on c-i-p, so we need to fix them too, or else their Cancel action will cause an OOPS like the one on bug 462923. However, we're at the end of week 3 and landing the two branches together would require some coordination with LOSAs, thus making it nearly impossible to get them through before the tree is closed.

Given that *and* the fact that this change doesn't make the situation any worse on c-i-p, we decided to go ahead and land this branch on its on, with a similar change on c-i-p following shortly afterwards.

Revision history for this message
Guilherme Salgado (salgado) wrote :

Actually, I just realized the AuthToken views don't have this problem because they don't have a 'Cancel' @action, even though their parent classes do.

That happens because the AuthToken views define a custom 'Continue' @action, and thanks to the way @action behaves, the class gets a new set of actions containing just that custom one. (I think I've encountered a similar problem in the past and might even have filed a bug; I'll see if I can find it)

Revision history for this message
Guilherme Salgado (salgado) wrote :

FYI, bug 154001 is the one I was talking about in the previous comment

Revision history for this message
Francis J. Lacoste (flacoste) :
review: Approve (release-critical)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/canonical/launchpad/browser/logintoken.py'
--- lib/canonical/launchpad/browser/logintoken.py 2009-10-23 20:12:27 +0000
+++ lib/canonical/launchpad/browser/logintoken.py 2009-11-03 18:24:22 +0000
@@ -110,6 +110,25 @@
110110
111 expected_token_types = ()111 expected_token_types = ()
112 successfullyProcessed = False112 successfullyProcessed = False
113 # The next URL to use when the user clicks on the 'Cancel' button.
114 _next_url_for_cancel = None
115 _missing = object()
116 # To be overridden in subclasses.
117 default_next_url = _missing
118
119 @property
120 def next_url(self):
121 """The next URL to redirect to on successful form submission.
122
123 When the cancel action is used, self._next_url_for_cancel won't be
124 None so we return that. Otherwise we return self.default_next_url.
125 """
126 if self._next_url_for_cancel is not None:
127 return self._next_url_for_cancel
128 assert self.default_next_url is not self._missing, (
129 'The implementation of %s should provide a value for '
130 'default_next_url' % self.__class__.__name__)
131 return self.default_next_url
113132
114 @property133 @property
115 def page_title(self):134 def page_title(self):
@@ -147,11 +166,12 @@
147 logInPrincipal(self.request, principal, email)166 logInPrincipal(self.request, principal, email)
148167
149 def _cancel(self):168 def _cancel(self):
150 """Consume the LoginToken and set self.next_url.169 """Consume the LoginToken and set self._next_url_for_cancel.
151170
152 next_url is set to the home page of this LoginToken's requester.171 _next_url_for_cancel is set to the home page of this LoginToken's
172 requester.
153 """173 """
154 self.next_url = canonical_url(self.context.requester)174 self._next_url_for_cancel = canonical_url(self.context.requester)
155 self.context.consume()175 self.context.consume()
156176
157 def accountWasSuspended(self, account, reason):177 def accountWasSuspended(self, account, reason):
@@ -200,7 +220,7 @@
200 "you provided when requesting the password reset."))220 "you provided when requesting the password reset."))
201221
202 @property222 @property
203 def next_url(self):223 def default_next_url(self):
204 if self.context.redirection_url is not None:224 if self.context.redirection_url is not None:
205 return self.context.redirection_url225 return self.context.redirection_url
206 else:226 else:
@@ -236,7 +256,7 @@
236 self.request.response.addInfoNotification(256 self.request.response.addInfoNotification(
237 _('Your password has been reset successfully.'))257 _('Your password has been reset successfully.'))
238258
239 @action(_('Cancel'), name='cancel')259 @action(_('Cancel'), name='cancel', validator='validate_cancel')
240 def cancel_action(self, action, data):260 def cancel_action(self, action, data):
241 self._cancel()261 self._cancel()
242262
@@ -271,7 +291,7 @@
271 return {'displayname': self.claimed_profile.displayname}291 return {'displayname': self.claimed_profile.displayname}
272292
273 @property293 @property
274 def next_url(self):294 def default_next_url(self):
275 return canonical_url(self.claimed_profile)295 return canonical_url(self.claimed_profile)
276296
277 @action(_('Continue'), name='confirm')297 @action(_('Continue'), name='confirm')
@@ -336,6 +356,10 @@
336 def initial_values(self):356 def initial_values(self):
337 return {'teamowner': self.context.requester}357 return {'teamowner': self.context.requester}
338358
359 @property
360 def default_next_url(self):
361 return canonical_url(self.claimed_profile)
362
339 @action(_('Continue'), name='confirm')363 @action(_('Continue'), name='confirm')
340 def confirm_action(self, action, data):364 def confirm_action(self, action, data):
341 self.claimed_profile.convertToTeam(team_owner=self.context.requester)365 self.claimed_profile.convertToTeam(team_owner=self.context.requester)
@@ -346,11 +370,10 @@
346 # have to remove its security proxy before we update it.370 # have to remove its security proxy before we update it.
347 self.updateContextFromData(371 self.updateContextFromData(
348 data, context=removeSecurityProxy(self.claimed_profile))372 data, context=removeSecurityProxy(self.claimed_profile))
349 self.next_url = canonical_url(self.claimed_profile)
350 self.request.response.addInfoNotification(373 self.request.response.addInfoNotification(
351 _('Team claimed successfully'))374 _('Team claimed successfully'))
352375
353 @action(_('Cancel'), name='cancel')376 @action(_('Cancel'), name='cancel', validator='validate_cancel')
354 def cancel_action(self, action, data):377 def cancel_action(self, action, data):
355 self._cancel()378 self._cancel()
356379
@@ -371,6 +394,10 @@
371 'unexpected token type: %r' % self.context.tokentype)394 'unexpected token type: %r' % self.context.tokentype)
372 return 'Confirm OpenPGP key'395 return 'Confirm OpenPGP key'
373396
397 @property
398 def default_next_url(self):
399 return canonical_url(self.context.requester)
400
374 def initialize(self):401 def initialize(self):
375 if not self.redirectIfInvalidOrConsumedToken():402 if not self.redirectIfInvalidOrConsumedToken():
376 if self.context.tokentype == LoginTokenType.VALIDATESIGNONLYGPG:403 if self.context.tokentype == LoginTokenType.VALIDATESIGNONLYGPG:
@@ -382,13 +409,12 @@
382 if self.context.tokentype == LoginTokenType.VALIDATESIGNONLYGPG:409 if self.context.tokentype == LoginTokenType.VALIDATESIGNONLYGPG:
383 self._validateSignOnlyGPGKey(data)410 self._validateSignOnlyGPGKey(data)
384411
385 @action(_('Cancel'), name='cancel')412 @action(_('Cancel'), name='cancel', validator='validate_cancel')
386 def cancel_action(self, action, data):413 def cancel_action(self, action, data):
387 self._cancel()414 self._cancel()
388415
389 @action(_('Continue'), name='continue')416 @action(_('Continue'), name='continue')
390 def continue_action_gpg(self, action, data):417 def continue_action_gpg(self, action, data):
391 self.next_url = canonical_url(self.context.requester)
392 assert self.gpg_key is not None418 assert self.gpg_key is not None
393 can_encrypt = (419 can_encrypt = (
394 self.context.tokentype != LoginTokenType.VALIDATESIGNONLYGPG)420 self.context.tokentype != LoginTokenType.VALIDATESIGNONLYGPG)
@@ -638,7 +664,7 @@
638 pass664 pass
639665
640 @property666 @property
641 def next_url(self):667 def default_next_url(self):
642 if self.context.redirection_url is not None:668 if self.context.redirection_url is not None:
643 return self.context.redirection_url669 return self.context.redirection_url
644 else:670 else:
@@ -646,7 +672,7 @@
646 "LoginTokens of this type must have a requester")672 "LoginTokens of this type must have a requester")
647 return canonical_url(self.context.requester)673 return canonical_url(self.context.requester)
648674
649 @action(_('Cancel'), name='cancel')675 @action(_('Cancel'), name='cancel', validator='validate_cancel')
650 def cancel_action(self, action, data):676 def cancel_action(self, action, data):
651 self._cancel()677 self._cancel()
652678
@@ -815,7 +841,7 @@
815 super(NewUserAccountView, self).initialize()841 super(NewUserAccountView, self).initialize()
816842
817 @property843 @property
818 def next_url(self):844 def default_next_url(self):
819 if self.context.redirection_url:845 if self.context.redirection_url:
820 return self.context.redirection_url846 return self.context.redirection_url
821 elif self.created_person is not None:847 elif self.created_person is not None:
822848
=== added file 'lib/canonical/launchpad/browser/tests/test_logintoken.py'
--- lib/canonical/launchpad/browser/tests/test_logintoken.py 1970-01-01 00:00:00 +0000
+++ lib/canonical/launchpad/browser/tests/test_logintoken.py 2009-11-03 18:24:22 +0000
@@ -0,0 +1,73 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4import unittest
5
6from zope.component import getUtility
7
8from canonical.launchpad.browser.logintoken import (
9 ClaimTeamView, ResetPasswordView, ValidateEmailView, ValidateGPGKeyView)
10from canonical.launchpad.ftests import LaunchpadFormHarness
11from canonical.launchpad.interfaces.authtoken import LoginTokenType
12from canonical.launchpad.interfaces.logintoken import ILoginTokenSet
13from lp.testing import TestCaseWithFactory
14from canonical.testing import DatabaseFunctionalLayer
15
16
17class TestCancelActionOnLoginTokenViews(TestCaseWithFactory):
18 """Test the 'Cancel' action of LoginToken views.
19
20 These views have an action instead of a link to cancel because we want the
21 token to be consumed (so it can't be used again) when the user hits
22 Cancel.
23 """
24 layer = DatabaseFunctionalLayer
25
26 def setUp(self):
27 TestCaseWithFactory.setUp(self)
28 self.person = self.factory.makePerson(name='test-user')
29 self.email = self.person.preferredemail.email
30 self.expected_next_url = 'http://127.0.0.1/~test-user'
31
32 def test_ResetPasswordView(self):
33 token = getUtility(ILoginTokenSet).new(
34 self.person, self.email, self.email,
35 LoginTokenType.PASSWORDRECOVERY)
36 self._testCancelAction(ResetPasswordView, token)
37
38 def test_ClaimTeamView(self):
39 token = getUtility(ILoginTokenSet).new(
40 self.person, self.email, self.email, LoginTokenType.TEAMCLAIM)
41 self._testCancelAction(ClaimTeamView, token)
42
43 def test_ValidateGPGKeyView(self):
44 self.gpg_key = self.factory.makeGPGKey(self.person)
45 token = getUtility(ILoginTokenSet).new(
46 self.person, self.email, self.email, LoginTokenType.VALIDATEGPG,
47 fingerprint=self.gpg_key.fingerprint)
48 self._testCancelAction(ValidateGPGKeyView, token)
49
50 def test_ValidateEmailView(self):
51 token = getUtility(ILoginTokenSet).new(
52 self.person, self.email, 'foo@example.com',
53 LoginTokenType.VALIDATEEMAIL)
54 self._testCancelAction(ValidateEmailView, token)
55
56 def _testCancelAction(self, view_class, token):
57 """Test the 'Cancel' action of the given view, using the given token.
58
59 To test that the action works, we just submit the form with that
60 action, check that there are no errors and make sure that the view's
61 next_url is what we expect.
62 """
63 harness = LaunchpadFormHarness(token, view_class)
64 harness.submit('cancel', {})
65 actions = harness.view.actions.byname
66 self.assertIn('field.actions.cancel', actions)
67 self.assertEquals(actions['field.actions.cancel'].submitted(), True)
68 self.assertEquals(harness.view.errors, [])
69 self.assertEquals(harness.view.next_url, self.expected_next_url)
70
71
72def test_suite():
73 return unittest.TestLoader().loadTestsFromName(__name__)
074
=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py 2009-10-26 15:25:10 +0000
+++ lib/lp/testing/factory.py 2009-11-03 18:24:22 +0000
@@ -176,9 +176,10 @@
176 :return: A hexadecimal string, with 'a'-'f' in lower case.176 :return: A hexadecimal string, with 'a'-'f' in lower case.
177 """177 """
178 hex_number = '%x' % self.getUniqueInteger()178 hex_number = '%x' % self.getUniqueInteger()
179 if digits is not None:179 if digits is None:
180 hex_number.zfill(digits)180 return hex_number
181 return hex_number181 else:
182 return hex_number.zfill(digits)
182183
183 def getUniqueString(self, prefix=None):184 def getUniqueString(self, prefix=None):
184 """Return a string unique to this factory instance.185 """Return a string unique to this factory instance.
@@ -266,7 +267,7 @@
266 owner.id,267 owner.id,
267 keyid=self.getUniqueHexString(digits=8).upper(),268 keyid=self.getUniqueHexString(digits=8).upper(),
268 fingerprint='A' * 40,269 fingerprint='A' * 40,
269 keysize=self.factory.getUniqueInteger(),270 keysize=self.getUniqueInteger(),
270 algorithm=GPGKeyAlgorithm.R,271 algorithm=GPGKeyAlgorithm.R,
271 active=True,272 active=True,
272 can_encrypt=False)273 can_encrypt=False)