Merge lp:~salgado/launchpad/bug-462923 into lp:launchpad
- bug-462923
- Merge into devel
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 |
Related bugs: |
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 |
Commit message
Description of the change
Guilherme Salgado (salgado) wrote : | # |
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_
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.
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.
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)
Guilherme Salgado (salgado) wrote : | # |
FYI, bug 154001 is the one I was talking about in the previous comment
Francis J. Lacoste (flacoste) : | # |
Preview Diff
1 | === modified file 'lib/canonical/launchpad/browser/logintoken.py' | |||
2 | --- lib/canonical/launchpad/browser/logintoken.py 2009-10-23 20:12:27 +0000 | |||
3 | +++ lib/canonical/launchpad/browser/logintoken.py 2009-11-03 18:24:22 +0000 | |||
4 | @@ -110,6 +110,25 @@ | |||
5 | 110 | 110 | ||
6 | 111 | expected_token_types = () | 111 | expected_token_types = () |
7 | 112 | successfullyProcessed = False | 112 | successfullyProcessed = False |
8 | 113 | # The next URL to use when the user clicks on the 'Cancel' button. | ||
9 | 114 | _next_url_for_cancel = None | ||
10 | 115 | _missing = object() | ||
11 | 116 | # To be overridden in subclasses. | ||
12 | 117 | default_next_url = _missing | ||
13 | 118 | |||
14 | 119 | @property | ||
15 | 120 | def next_url(self): | ||
16 | 121 | """The next URL to redirect to on successful form submission. | ||
17 | 122 | |||
18 | 123 | When the cancel action is used, self._next_url_for_cancel won't be | ||
19 | 124 | None so we return that. Otherwise we return self.default_next_url. | ||
20 | 125 | """ | ||
21 | 126 | if self._next_url_for_cancel is not None: | ||
22 | 127 | return self._next_url_for_cancel | ||
23 | 128 | assert self.default_next_url is not self._missing, ( | ||
24 | 129 | 'The implementation of %s should provide a value for ' | ||
25 | 130 | 'default_next_url' % self.__class__.__name__) | ||
26 | 131 | return self.default_next_url | ||
27 | 113 | 132 | ||
28 | 114 | @property | 133 | @property |
29 | 115 | def page_title(self): | 134 | def page_title(self): |
30 | @@ -147,11 +166,12 @@ | |||
31 | 147 | logInPrincipal(self.request, principal, email) | 166 | logInPrincipal(self.request, principal, email) |
32 | 148 | 167 | ||
33 | 149 | def _cancel(self): | 168 | def _cancel(self): |
35 | 150 | """Consume the LoginToken and set self.next_url. | 169 | """Consume the LoginToken and set self._next_url_for_cancel. |
36 | 151 | 170 | ||
38 | 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 |
39 | 172 | requester. | ||
40 | 153 | """ | 173 | """ |
42 | 154 | self.next_url = canonical_url(self.context.requester) | 174 | self._next_url_for_cancel = canonical_url(self.context.requester) |
43 | 155 | self.context.consume() | 175 | self.context.consume() |
44 | 156 | 176 | ||
45 | 157 | def accountWasSuspended(self, account, reason): | 177 | def accountWasSuspended(self, account, reason): |
46 | @@ -200,7 +220,7 @@ | |||
47 | 200 | "you provided when requesting the password reset.")) | 220 | "you provided when requesting the password reset.")) |
48 | 201 | 221 | ||
49 | 202 | @property | 222 | @property |
51 | 203 | def next_url(self): | 223 | def default_next_url(self): |
52 | 204 | if self.context.redirection_url is not None: | 224 | if self.context.redirection_url is not None: |
53 | 205 | return self.context.redirection_url | 225 | return self.context.redirection_url |
54 | 206 | else: | 226 | else: |
55 | @@ -236,7 +256,7 @@ | |||
56 | 236 | self.request.response.addInfoNotification( | 256 | self.request.response.addInfoNotification( |
57 | 237 | _('Your password has been reset successfully.')) | 257 | _('Your password has been reset successfully.')) |
58 | 238 | 258 | ||
60 | 239 | @action(_('Cancel'), name='cancel') | 259 | @action(_('Cancel'), name='cancel', validator='validate_cancel') |
61 | 240 | def cancel_action(self, action, data): | 260 | def cancel_action(self, action, data): |
62 | 241 | self._cancel() | 261 | self._cancel() |
63 | 242 | 262 | ||
64 | @@ -271,7 +291,7 @@ | |||
65 | 271 | return {'displayname': self.claimed_profile.displayname} | 291 | return {'displayname': self.claimed_profile.displayname} |
66 | 272 | 292 | ||
67 | 273 | @property | 293 | @property |
69 | 274 | def next_url(self): | 294 | def default_next_url(self): |
70 | 275 | return canonical_url(self.claimed_profile) | 295 | return canonical_url(self.claimed_profile) |
71 | 276 | 296 | ||
72 | 277 | @action(_('Continue'), name='confirm') | 297 | @action(_('Continue'), name='confirm') |
73 | @@ -336,6 +356,10 @@ | |||
74 | 336 | def initial_values(self): | 356 | def initial_values(self): |
75 | 337 | return {'teamowner': self.context.requester} | 357 | return {'teamowner': self.context.requester} |
76 | 338 | 358 | ||
77 | 359 | @property | ||
78 | 360 | def default_next_url(self): | ||
79 | 361 | return canonical_url(self.claimed_profile) | ||
80 | 362 | |||
81 | 339 | @action(_('Continue'), name='confirm') | 363 | @action(_('Continue'), name='confirm') |
82 | 340 | def confirm_action(self, action, data): | 364 | def confirm_action(self, action, data): |
83 | 341 | self.claimed_profile.convertToTeam(team_owner=self.context.requester) | 365 | self.claimed_profile.convertToTeam(team_owner=self.context.requester) |
84 | @@ -346,11 +370,10 @@ | |||
85 | 346 | # have to remove its security proxy before we update it. | 370 | # have to remove its security proxy before we update it. |
86 | 347 | self.updateContextFromData( | 371 | self.updateContextFromData( |
87 | 348 | data, context=removeSecurityProxy(self.claimed_profile)) | 372 | data, context=removeSecurityProxy(self.claimed_profile)) |
88 | 349 | self.next_url = canonical_url(self.claimed_profile) | ||
89 | 350 | self.request.response.addInfoNotification( | 373 | self.request.response.addInfoNotification( |
90 | 351 | _('Team claimed successfully')) | 374 | _('Team claimed successfully')) |
91 | 352 | 375 | ||
93 | 353 | @action(_('Cancel'), name='cancel') | 376 | @action(_('Cancel'), name='cancel', validator='validate_cancel') |
94 | 354 | def cancel_action(self, action, data): | 377 | def cancel_action(self, action, data): |
95 | 355 | self._cancel() | 378 | self._cancel() |
96 | 356 | 379 | ||
97 | @@ -371,6 +394,10 @@ | |||
98 | 371 | 'unexpected token type: %r' % self.context.tokentype) | 394 | 'unexpected token type: %r' % self.context.tokentype) |
99 | 372 | return 'Confirm OpenPGP key' | 395 | return 'Confirm OpenPGP key' |
100 | 373 | 396 | ||
101 | 397 | @property | ||
102 | 398 | def default_next_url(self): | ||
103 | 399 | return canonical_url(self.context.requester) | ||
104 | 400 | |||
105 | 374 | def initialize(self): | 401 | def initialize(self): |
106 | 375 | if not self.redirectIfInvalidOrConsumedToken(): | 402 | if not self.redirectIfInvalidOrConsumedToken(): |
107 | 376 | if self.context.tokentype == LoginTokenType.VALIDATESIGNONLYGPG: | 403 | if self.context.tokentype == LoginTokenType.VALIDATESIGNONLYGPG: |
108 | @@ -382,13 +409,12 @@ | |||
109 | 382 | if self.context.tokentype == LoginTokenType.VALIDATESIGNONLYGPG: | 409 | if self.context.tokentype == LoginTokenType.VALIDATESIGNONLYGPG: |
110 | 383 | self._validateSignOnlyGPGKey(data) | 410 | self._validateSignOnlyGPGKey(data) |
111 | 384 | 411 | ||
113 | 385 | @action(_('Cancel'), name='cancel') | 412 | @action(_('Cancel'), name='cancel', validator='validate_cancel') |
114 | 386 | def cancel_action(self, action, data): | 413 | def cancel_action(self, action, data): |
115 | 387 | self._cancel() | 414 | self._cancel() |
116 | 388 | 415 | ||
117 | 389 | @action(_('Continue'), name='continue') | 416 | @action(_('Continue'), name='continue') |
118 | 390 | def continue_action_gpg(self, action, data): | 417 | def continue_action_gpg(self, action, data): |
119 | 391 | self.next_url = canonical_url(self.context.requester) | ||
120 | 392 | assert self.gpg_key is not None | 418 | assert self.gpg_key is not None |
121 | 393 | can_encrypt = ( | 419 | can_encrypt = ( |
122 | 394 | self.context.tokentype != LoginTokenType.VALIDATESIGNONLYGPG) | 420 | self.context.tokentype != LoginTokenType.VALIDATESIGNONLYGPG) |
123 | @@ -638,7 +664,7 @@ | |||
124 | 638 | pass | 664 | pass |
125 | 639 | 665 | ||
126 | 640 | @property | 666 | @property |
128 | 641 | def next_url(self): | 667 | def default_next_url(self): |
129 | 642 | if self.context.redirection_url is not None: | 668 | if self.context.redirection_url is not None: |
130 | 643 | return self.context.redirection_url | 669 | return self.context.redirection_url |
131 | 644 | else: | 670 | else: |
132 | @@ -646,7 +672,7 @@ | |||
133 | 646 | "LoginTokens of this type must have a requester") | 672 | "LoginTokens of this type must have a requester") |
134 | 647 | return canonical_url(self.context.requester) | 673 | return canonical_url(self.context.requester) |
135 | 648 | 674 | ||
137 | 649 | @action(_('Cancel'), name='cancel') | 675 | @action(_('Cancel'), name='cancel', validator='validate_cancel') |
138 | 650 | def cancel_action(self, action, data): | 676 | def cancel_action(self, action, data): |
139 | 651 | self._cancel() | 677 | self._cancel() |
140 | 652 | 678 | ||
141 | @@ -815,7 +841,7 @@ | |||
142 | 815 | super(NewUserAccountView, self).initialize() | 841 | super(NewUserAccountView, self).initialize() |
143 | 816 | 842 | ||
144 | 817 | @property | 843 | @property |
146 | 818 | def next_url(self): | 844 | def default_next_url(self): |
147 | 819 | if self.context.redirection_url: | 845 | if self.context.redirection_url: |
148 | 820 | return self.context.redirection_url | 846 | return self.context.redirection_url |
149 | 821 | elif self.created_person is not None: | 847 | elif self.created_person is not None: |
150 | 822 | 848 | ||
151 | === added file 'lib/canonical/launchpad/browser/tests/test_logintoken.py' | |||
152 | --- lib/canonical/launchpad/browser/tests/test_logintoken.py 1970-01-01 00:00:00 +0000 | |||
153 | +++ lib/canonical/launchpad/browser/tests/test_logintoken.py 2009-11-03 18:24:22 +0000 | |||
154 | @@ -0,0 +1,73 @@ | |||
155 | 1 | # Copyright 2009 Canonical Ltd. This software is licensed under the | ||
156 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
157 | 3 | |||
158 | 4 | import unittest | ||
159 | 5 | |||
160 | 6 | from zope.component import getUtility | ||
161 | 7 | |||
162 | 8 | from canonical.launchpad.browser.logintoken import ( | ||
163 | 9 | ClaimTeamView, ResetPasswordView, ValidateEmailView, ValidateGPGKeyView) | ||
164 | 10 | from canonical.launchpad.ftests import LaunchpadFormHarness | ||
165 | 11 | from canonical.launchpad.interfaces.authtoken import LoginTokenType | ||
166 | 12 | from canonical.launchpad.interfaces.logintoken import ILoginTokenSet | ||
167 | 13 | from lp.testing import TestCaseWithFactory | ||
168 | 14 | from canonical.testing import DatabaseFunctionalLayer | ||
169 | 15 | |||
170 | 16 | |||
171 | 17 | class TestCancelActionOnLoginTokenViews(TestCaseWithFactory): | ||
172 | 18 | """Test the 'Cancel' action of LoginToken views. | ||
173 | 19 | |||
174 | 20 | These views have an action instead of a link to cancel because we want the | ||
175 | 21 | token to be consumed (so it can't be used again) when the user hits | ||
176 | 22 | Cancel. | ||
177 | 23 | """ | ||
178 | 24 | layer = DatabaseFunctionalLayer | ||
179 | 25 | |||
180 | 26 | def setUp(self): | ||
181 | 27 | TestCaseWithFactory.setUp(self) | ||
182 | 28 | self.person = self.factory.makePerson(name='test-user') | ||
183 | 29 | self.email = self.person.preferredemail.email | ||
184 | 30 | self.expected_next_url = 'http://127.0.0.1/~test-user' | ||
185 | 31 | |||
186 | 32 | def test_ResetPasswordView(self): | ||
187 | 33 | token = getUtility(ILoginTokenSet).new( | ||
188 | 34 | self.person, self.email, self.email, | ||
189 | 35 | LoginTokenType.PASSWORDRECOVERY) | ||
190 | 36 | self._testCancelAction(ResetPasswordView, token) | ||
191 | 37 | |||
192 | 38 | def test_ClaimTeamView(self): | ||
193 | 39 | token = getUtility(ILoginTokenSet).new( | ||
194 | 40 | self.person, self.email, self.email, LoginTokenType.TEAMCLAIM) | ||
195 | 41 | self._testCancelAction(ClaimTeamView, token) | ||
196 | 42 | |||
197 | 43 | def test_ValidateGPGKeyView(self): | ||
198 | 44 | self.gpg_key = self.factory.makeGPGKey(self.person) | ||
199 | 45 | token = getUtility(ILoginTokenSet).new( | ||
200 | 46 | self.person, self.email, self.email, LoginTokenType.VALIDATEGPG, | ||
201 | 47 | fingerprint=self.gpg_key.fingerprint) | ||
202 | 48 | self._testCancelAction(ValidateGPGKeyView, token) | ||
203 | 49 | |||
204 | 50 | def test_ValidateEmailView(self): | ||
205 | 51 | token = getUtility(ILoginTokenSet).new( | ||
206 | 52 | self.person, self.email, 'foo@example.com', | ||
207 | 53 | LoginTokenType.VALIDATEEMAIL) | ||
208 | 54 | self._testCancelAction(ValidateEmailView, token) | ||
209 | 55 | |||
210 | 56 | def _testCancelAction(self, view_class, token): | ||
211 | 57 | """Test the 'Cancel' action of the given view, using the given token. | ||
212 | 58 | |||
213 | 59 | To test that the action works, we just submit the form with that | ||
214 | 60 | action, check that there are no errors and make sure that the view's | ||
215 | 61 | next_url is what we expect. | ||
216 | 62 | """ | ||
217 | 63 | harness = LaunchpadFormHarness(token, view_class) | ||
218 | 64 | harness.submit('cancel', {}) | ||
219 | 65 | actions = harness.view.actions.byname | ||
220 | 66 | self.assertIn('field.actions.cancel', actions) | ||
221 | 67 | self.assertEquals(actions['field.actions.cancel'].submitted(), True) | ||
222 | 68 | self.assertEquals(harness.view.errors, []) | ||
223 | 69 | self.assertEquals(harness.view.next_url, self.expected_next_url) | ||
224 | 70 | |||
225 | 71 | |||
226 | 72 | def test_suite(): | ||
227 | 73 | return unittest.TestLoader().loadTestsFromName(__name__) | ||
228 | 0 | 74 | ||
229 | === modified file 'lib/lp/testing/factory.py' | |||
230 | --- lib/lp/testing/factory.py 2009-10-26 15:25:10 +0000 | |||
231 | +++ lib/lp/testing/factory.py 2009-11-03 18:24:22 +0000 | |||
232 | @@ -176,9 +176,10 @@ | |||
233 | 176 | :return: A hexadecimal string, with 'a'-'f' in lower case. | 176 | :return: A hexadecimal string, with 'a'-'f' in lower case. |
234 | 177 | """ | 177 | """ |
235 | 178 | hex_number = '%x' % self.getUniqueInteger() | 178 | hex_number = '%x' % self.getUniqueInteger() |
239 | 179 | if digits is not None: | 179 | if digits is None: |
240 | 180 | hex_number.zfill(digits) | 180 | return hex_number |
241 | 181 | return hex_number | 181 | else: |
242 | 182 | return hex_number.zfill(digits) | ||
243 | 182 | 183 | ||
244 | 183 | def getUniqueString(self, prefix=None): | 184 | def getUniqueString(self, prefix=None): |
245 | 184 | """Return a string unique to this factory instance. | 185 | """Return a string unique to this factory instance. |
246 | @@ -266,7 +267,7 @@ | |||
247 | 266 | owner.id, | 267 | owner.id, |
248 | 267 | keyid=self.getUniqueHexString(digits=8).upper(), | 268 | keyid=self.getUniqueHexString(digits=8).upper(), |
249 | 268 | fingerprint='A' * 40, | 269 | fingerprint='A' * 40, |
251 | 269 | keysize=self.factory.getUniqueInteger(), | 270 | keysize=self.getUniqueInteger(), |
252 | 270 | algorithm=GPGKeyAlgorithm.R, | 271 | algorithm=GPGKeyAlgorithm.R, |
253 | 271 | active=True, | 272 | active=True, |
254 | 272 | can_encrypt=False) | 273 | can_encrypt=False) |
= 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: testing/ factory. py /launchpad/ browser/ logintoken. py /launchpad/ browser/ tests/test_ logintoken. py
lib/lp/
lib/canonical
lib/canonical