Merge lp:~rharding/launchpad/email_notice_959482 into lp:launchpad

Proposed by Richard Harding on 2012-03-29
Status: Merged
Approved by: Richard Harding on 2012-04-17
Approved revision: no longer in the source branch.
Merged at revision: 15110
Proposed branch: lp:~rharding/launchpad/email_notice_959482
Merge into: lp:launchpad
Diff against target: 664 lines (+203/-68)
21 files modified
lib/lp/registry/browser/person.py (+1/-1)
lib/lp/registry/browser/tests/test_productseries_views.py (+1/-1)
lib/lp/registry/browser/tests/test_sshkey.py (+23/-18)
lib/lp/registry/doc/person.txt (+9/-2)
lib/lp/registry/doc/sshkey.txt (+3/-0)
lib/lp/registry/emailtemplates/person-details-change.txt (+3/-0)
lib/lp/registry/interfaces/person.py (+11/-0)
lib/lp/registry/model/person.py (+37/-2)
lib/lp/registry/stories/person/xx-add-email.txt (+31/-13)
lib/lp/registry/stories/person/xx-add-sshkey.txt (+14/-0)
lib/lp/registry/stories/person/xx-validate-email.txt (+8/-4)
lib/lp/registry/stories/webservice/xx-person.txt (+14/-6)
lib/lp/registry/subscribers.py (+4/-1)
lib/lp/registry/tests/test_personnotification.py (+2/-0)
lib/lp/services/authserver/tests/test_authserver.py (+10/-6)
lib/lp/services/identity/model/emailaddress.py (+1/-0)
lib/lp/services/mail/doc/sending-mail.txt (+7/-4)
lib/lp/services/verification/browser/tests/logintoken-corner-cases.txt (+2/-0)
lib/lp/services/verification/browser/tests/test_logintoken.py (+9/-5)
lib/lp/services/verification/doc/logintoken.txt (+9/-4)
lib/lp/services/verification/model/logintoken.py (+4/-1)
To merge this branch: bzr merge lp:~rharding/launchpad/email_notice_959482
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve on 2012-04-17
Graham Binns (community) code 2012-03-29 Approve on 2012-04-03
Review via email: mp+99995@code.launchpad.net

Commit Message

Add a notification email to a user whenever significant changes to their account are taking place.

Description of the Change

= Update =
This branch has been changed dramatically from the original submission. We've ditched the event and gone straight to sending emails via simple_sendmail. Resubmitting for code review since it's so different.

= Summary =
Launchpad should make more noise when important user information is changed in case it came from a malicious user. The frequency should be low enough that the extra warnings are worth the extra email notifications sent to users.

== Proposed Fix ==
Whenever a user adds an email address, changes their preferred email address, or adds/removes ssh keys, Launchpad should send a notification to the person that the information was changed with a note on how to proceed if those changes were not intentional.

== Implementation Details ==
A new method has been added to the Person interface/model, `security_field_changed` which generates an email message to the user that the data has been changed and instructions on what to do if this was not done by the owner of the account.

== Tests ==

*Note* We had to adjust many tests that validate and work with emails and ssh
keys since any time that they wanted to check that a confirmation email or
such was sent we also had to deal with the security notification getting sent
out as well.

lib/lp/registry/browser/tests/test_productseries_views.py
lib/lp/registry/browser/tests/test_sshkey.py
lib/lp/registry/doc/person.txt
lib/lp/registry/doc/sshkey.txt
lib/lp/registry/stories/person/xx-add-email.txt
lib/lp/registry/stories/person/xx-add-sshkey.txt
lib/lp/registry/stories/person/xx-validate-email.txt
lib/lp/registry/stories/webservice/xx-person.txt
lib/lp/registry/tests/test_personnotification.py
lib/lp/services/authserver/tests/test_authserver.py
lib/lp/services/mail/doc/sending-mail.txt
lib/lp/services/verification/browser/tests/logintoken-corner-cases.txt
lib/lp/services/verification/browser/tests/test_logintoken.py

== Demo and Q/A ==
Changing any of the listed properties should schedule an email with the correct note about the change.

== LoC ==
This is a security issue I've worked with Robert on to get into place with the
smallest LoC hit possible. There's no way to add this without adding LoC and
delaying release until I can find other places to reduce code would
inadvisable. Robert is requested as a reviewer to provide an exemption at this
time for this branch.

To post a comment you must log in.
Graham Binns (gmb) wrote :

Hi Rick,

The code looks good. I have some comments about tests but they're all of a documentation / comments nature. r=me with the changes below.

[0]

General comment on review docstrings: Your docstrings are all of the form:

 """We want the frobber to go boing when pressed."""

Test docstrings should be a statement of expected behaviour. So,
docstrings need to read as follows:

 """The frobber goes boing when pressed."""

I know this seems like mindless pedantry, but it saves time when
scanning through tests, and I'm a firm believer in being both a) a
strong TDD advocate and b) lazy.

[1]

366 + def test_change_preferredemail(self):
367 + # The project_reviewed property is not reset, if the new licenses
368 + # are identical to the current licenses.

I'm guessing this is a C-P error :).

[2]

375 + # Assert form within the context manager to get access to the
376 + # email values.

s/form/from.

This occurrs a couple of times in the diff. I think you can actually get rid of the comments, since asserts-within-context-managers are a pretty common pattern now.

[3]

426 + We want to send the notification when the new email address is
427 + requested. This doesn't actually create an email address yet. It
428 + builds a LoginToken that the user must ack before the email address is
429 + added. The issue is security in alerting the owner of the account that
430 + a new address is being requested, so we need to notify at the time of
431 + request and not wait for the user to ack the new email address.
432 + """

This bit of documentation is useful. So it should be either next to the
code in question or in a doctest. Putting it in the docstring of the
unit test looks sensible, but you have to know which test to look for to
be able to find it.

review: Approve (code)
Robert Collins (lifeless) wrote :

This is an interesting branch.

I note that no discussion took place about the 400 odd additional LoC. Thats a shame - we really need to enforce that.

Here are some initial thoughts:
296 + >>> found = False
297 + >>> for from_addr, to_addrs, raw_msg in stub.test_emails:
298 + ... if 'token' in raw_msg:
299 + ... found = True
300 + ... token_url = get_token_url_from_email(raw_msg)
301 + ... token_url
302 + ... to_addrs

This is duplicated in the diff. I hear that functions can allow code reuse.

PersonAlterationSecurityNotification is spectacularly fat. It wants to be a function. It has one caller, and no direct tests, so just inline it into the call site.

The call site spends most of its time reconstructing what changed. I think the branch will shrink substantially if you just call the notifier when an appropriate change has happened.

tl;dr - I think you can halve the length of this branch without affecting clarity, test coverage or reusability.

review: Needs Fixing
Robert Collins (lifeless) wrote :

More notes while going through this in detail:
 - teams - how are they handled? the code is user specific but nothing in person.py prevents a team having ssh/gpg keys. Probably a separate issue, but it would be good not to add new OOPSes :).

 - oauth keys (adding only)
 - gpg keys (adding only)

We can notify on removals but additions are the things that matter from a security perspective.

 - LoginToken.new isn't quite the right place to trap outbound email validations; logintokens are used for a wide range of things including remote bugtracker logins, team merges and so forth. This is a bit unfortunate, because we have to choose between maximum safety (always notify) and user friendliness (don't double notify someone when they try to merge a team / claim an account). I've moved it to the code path relevant for additional-email-validation only.

I've pushed a non-event based branch to lp:~lifeless/launchpad/email_notice_959482 which you're welcome to pull from if you want to. It is missing 4 small and obvious tweaks to *existing* tests to note that an additional mail is sent as well (rather than the complex action-at-a-distant tests involving subscribers that you had). The resulting branch should be about +200 -60 lines. Still an increase, but much more pithy, and I think you'll agree that the email content people receive will be more useful.

Robert Collins (lifeless) wrote :

This looks pretty good and nice and compact :). it might be an idea - to check coverage - to stub out security_field_changed, run the whole test suite, and make sure there is one failure for each call site. If not, we're still missing coverage.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/browser/person.py'
2--- lib/lp/registry/browser/person.py 2012-04-11 11:34:02 +0000
3+++ lib/lp/registry/browser/person.py 2012-04-17 12:02:19 +0000
4@@ -2657,7 +2657,7 @@
5
6 found = []
7 notfound = []
8- # verify if we have multiple entries to deactive
9+ # Verify if we have multiple entries to activate.
10 if not isinstance(key_ids, list):
11 key_ids = [key_ids]
12
13
14=== modified file 'lib/lp/registry/browser/tests/test_productseries_views.py'
15--- lib/lp/registry/browser/tests/test_productseries_views.py 2012-04-11 11:34:02 +0000
16+++ lib/lp/registry/browser/tests/test_productseries_views.py 2012-04-17 12:02:19 +0000
17@@ -29,9 +29,9 @@
18 product = self.factory.makeProduct()
19 series = self.factory.makeProductSeries(product=product)
20 person = product.owner
21- self.factory.makeSSHKey(person=person)
22 branch_url = "lp:~%s/%s/%s" % (person.name, product.name, series.name)
23 with person_logged_in(person):
24+ self.factory.makeSSHKey(person=person)
25 view = create_initialized_view(series, '+code-summary')
26 self.assertThat(view(), Contains(branch_url))
27
28
29=== modified file 'lib/lp/registry/browser/tests/test_sshkey.py'
30--- lib/lp/registry/browser/tests/test_sshkey.py 2012-04-11 11:34:02 +0000
31+++ lib/lp/registry/browser/tests/test_sshkey.py 2012-04-17 12:02:19 +0000
32@@ -9,7 +9,10 @@
33
34 from lp.registry.interfaces.ssh import ISSHKeySet
35 from lp.services.webapp import canonical_url
36-from lp.testing import TestCaseWithFactory
37+from lp.testing import (
38+ TestCaseWithFactory,
39+ person_logged_in,
40+ )
41 from lp.testing.layers import DatabaseFunctionalLayer
42 from lp.testing.pages import (
43 extract_text,
44@@ -24,11 +27,12 @@
45 def test_canonical_url(self):
46 # The canonical URL of a GPG key is ssh-keys
47 person = self.factory.makePerson()
48- sshkey = self.factory.makeSSHKey(person)
49- self.assertEqual(
50- '%s/+ssh-keys/%s' % (
51- canonical_url(person, rootsite='api'), sshkey.id),
52- canonical_url(sshkey))
53+ with person_logged_in(person):
54+ sshkey = self.factory.makeSSHKey(person)
55+ self.assertEqual(
56+ '%s/+ssh-keys/%s' % (
57+ canonical_url(person, rootsite='api'), sshkey.id),
58+ canonical_url(sshkey))
59
60
61 class TestSSHKeyView(TestCaseWithFactory):
62@@ -38,15 +42,16 @@
63 def test_escaped_message_when_removing_key(self):
64 """Confirm that messages are escaped when removing keys."""
65 person = self.factory.makePerson()
66- public_key = "ssh-rsa %s x<script>alert()</script>example.com" % (
67- self.getUniqueString())
68- # Add the key for the user here,
69- # since we only care about testing removal.
70- getUtility(ISSHKeySet).new(person, public_key)
71- browser = self.getUserBrowser(
72- canonical_url(person) + '/+editsshkeys', user=person)
73- browser.getControl('Remove').click()
74- msg = 'Key "x&lt;script&gt;alert()&lt;/script&gt;example.com" removed'
75- self.assertEqual(
76- extract_text(find_tags_by_class(browser.contents, 'message')[0]),
77- msg)
78+ with person_logged_in(person):
79+ public_key = "ssh-rsa %s x<script>alert()</script>example.com" % (
80+ self.getUniqueString())
81+ # Add the key for the user here,
82+ # since we only care about testing removal.
83+ getUtility(ISSHKeySet).new(person, public_key)
84+ browser = self.getUserBrowser(
85+ canonical_url(person) + '/+editsshkeys', user=person)
86+ browser.getControl('Remove').click()
87+ msg = 'Key "x&lt;script&gt;alert()&lt;/script&gt;example.com" removed'
88+ self.assertEqual(
89+ extract_text(find_tags_by_class(browser.contents, 'message')[0]),
90+ msg)
91
92=== modified file 'lib/lp/registry/doc/person.txt'
93--- lib/lp/registry/doc/person.txt 2012-04-10 14:01:17 +0000
94+++ lib/lp/registry/doc/person.txt 2012-04-17 12:02:19 +0000
95@@ -5,6 +5,8 @@
96 some people which have done work on the free software community but are
97 not Launchpad users.
98
99+ >>> from lp.services.mail import stub
100+ >>> import transaction
101 >>> from zope.component import getUtility
102 >>> from lp.services.identity.interfaces.emailaddress import (
103 ... IEmailAddressSet)
104@@ -162,16 +164,22 @@
105 <DBItem EmailAddressStatus.VALIDATED...
106
107 The user can add a new address and set it as the preferred address. The
108-setPreferredEmail() method updated the address's status.
109+setPreferredEmail() method updated the address's status. This will generate a
110+security email notification to the original preferred email address.
111
112 >>> preferred_email = emailset.new(
113 ... 'preferred@canonical.com', p)
114 >>> preferred_email.status
115 <DBItem EmailAddressStatus.NEW...
116
117+ >>> login('validated@canonical.com')
118 >>> p.setPreferredEmail(preferred_email)
119 >>> preferred_email.status
120 <DBItem EmailAddressStatus.PREFERRED...
121+ >>> transaction.commit()
122+ >>> efrom, eto, emsg = stub.test_emails.pop()
123+ >>> eto
124+ ['randomuser@randomhost.com']
125
126 >>> login(ANONYMOUS)
127
128@@ -472,7 +480,6 @@
129 # notifications are sent when we add the owner as a member of
130 # the team.
131
132- >>> from lp.services.mail import stub
133 >>> stub.test_emails = []
134
135 >>> not_a_person.convertToTeam(team_owner=ddaa)
136
137=== modified file 'lib/lp/registry/doc/sshkey.txt'
138--- lib/lp/registry/doc/sshkey.txt 2012-04-11 11:34:02 +0000
139+++ lib/lp/registry/doc/sshkey.txt 2012-04-17 12:02:19 +0000
140@@ -3,11 +3,13 @@
141
142 Launchpad models SSH keys in a SSHKey class.
143
144+ >>> from lp.testing import login_person
145 >>> from zope.component import getUtility
146 >>> from lp.registry.interfaces.person import IPersonSet
147 >>> from lp.registry.interfaces.ssh import ISSHKeySet
148 >>> personset = getUtility(IPersonSet)
149 >>> name12 = personset.getByName('name12')
150+ >>> ignored = login_person(name12)
151 >>> [(key.keytype, key.comment) for key in name12.sshkeys]
152 [(<DBItem SSHKeyType.DSA, (2) DSA>, u'andrew@trogdor')]
153
154@@ -21,6 +23,7 @@
155 Adding new keys is pretty easy:
156
157 >>> foobar = personset.getByName('name16')
158+ >>> ignored = login_person(foobar)
159 >>> key = sshkeyset.new(
160 ... foobar, "ssh-rsa zzzNOT-REALLY This is just a test key")
161 >>> key, key.keytext
162
163=== added file 'lib/lp/registry/emailtemplates/person-details-change.txt'
164--- lib/lp/registry/emailtemplates/person-details-change.txt 1970-01-01 00:00:00 +0000
165+++ lib/lp/registry/emailtemplates/person-details-change.txt 2012-04-17 12:02:19 +0000
166@@ -0,0 +1,3 @@
167+%(field_changed)s
168+
169+If you did not make this change, please open a new Question on Launchpad <https://answers.launchpad.net/launchpad/+addquestion> or visit #launchpad in IRC at freenode to alert staff of the issue.
170
171=== modified file 'lib/lp/registry/interfaces/person.py'
172--- lib/lp/registry/interfaces/person.py 2012-04-03 15:34:50 +0000
173+++ lib/lp/registry/interfaces/person.py 2012-04-17 12:02:19 +0000
174@@ -1811,6 +1811,17 @@
175 DAYS_BEFORE_EXPIRATION_WARNING_IS_SENT days.
176 """
177
178+ def security_field_changed(subject, change_description,
179+ recipient_emails=None):
180+ """Trigger email when a secured field like preferredemail changes.
181+
182+ :param recipient_emails: If supplied custom email addresses to notify.
183+ This is used when a new preferred email address is set.
184+ :param subject: The subject to use.
185+ :param change_description: A textual description to use when notifying
186+ about the change.
187+ """
188+
189
190 class IPersonSpecialRestricted(Interface):
191 """IPerson methods that require launchpad.Special permission to use."""
192
193=== modified file 'lib/lp/registry/model/person.py'
194--- lib/lp/registry/model/person.py 2012-04-11 11:34:02 +0000
195+++ lib/lp/registry/model/person.py 2012-04-17 12:02:19 +0000
196@@ -283,6 +283,7 @@
197 get_contact_email_addresses,
198 get_email_template,
199 )
200+from lp.services.mail.sendmail import simple_sendmail
201 from lp.services.oauth.model import (
202 OAuthAccessToken,
203 OAuthRequestToken,
204@@ -2671,13 +2672,14 @@
205 "Any person's email address must provide the IEmailAddress "
206 "interface. %s doesn't." % email)
207 assert email.personID == self.id
208-
209 existing_preferred_email = IMasterStore(EmailAddress).find(
210 EmailAddress, personID=self.id,
211 status=EmailAddressStatus.PREFERRED).one()
212-
213 if existing_preferred_email is not None:
214+ original_recipients = existing_preferred_email.email
215 existing_preferred_email.status = EmailAddressStatus.VALIDATED
216+ else:
217+ original_recipients = None
218
219 email = removeSecurityProxy(email)
220 IMasterObject(email).status = EmailAddressStatus.PREFERRED
221@@ -2685,6 +2687,11 @@
222
223 # Now we update our cache of the preferredemail.
224 get_property_cache(self).preferredemail = email
225+ if original_recipients:
226+ self.security_field_changed(
227+ "Preferred email address changed on Launchpad.",
228+ "Your preferred email address is now <%s>." % email.email,
229+ original_recipients)
230
231 @cachedproperty
232 def preferredemail(self):
233@@ -3097,6 +3104,22 @@
234 return True
235 return False
236
237+ def security_field_changed(self, subject, change_description,
238+ recipient_emails=None):
239+ """See `IPerson`."""
240+ tpl_substitutions = dict(
241+ field_changed=change_description,
242+ )
243+ template = get_email_template(
244+ 'person-details-change.txt', app='registry')
245+ body = template % tpl_substitutions
246+ from_addr = config.canonical.bounce_address
247+ if not recipient_emails:
248+ to_addrs = self.preferredemail.email
249+ else:
250+ to_addrs = recipient_emails
251+ simple_sendmail(from_addr, to_addrs, subject, body)
252+
253 def transitionVisibility(self, visibility, user):
254 if self.visibility == visibility:
255 return
256@@ -4625,6 +4648,14 @@
257 keytext = StringCol(dbName='keytext', notNull=True)
258 comment = StringCol(dbName='comment', notNull=True)
259
260+ def destroySelf(self):
261+ # For security reasons we want to notify the preferred email address
262+ # that this sshkey has been removed.
263+ self.person.security_field_changed(
264+ "SSH Key removed from your Launchpad account.",
265+ "The SSH Key %s was removed from your account." % self.comment)
266+ super(SSHKey, self).destroySelf()
267+
268
269 class SSHKeySet:
270 implements(ISSHKeySet)
271@@ -4652,6 +4683,10 @@
272 else:
273 raise SSHKeyAdditionError
274
275+ person.security_field_changed(
276+ "New SSH key added to your account.",
277+ "The SSH key '%s' has been added to your account." % comment)
278+
279 return SSHKey(person=person, keytype=keytype, keytext=keytext,
280 comment=comment)
281
282
283=== modified file 'lib/lp/registry/stories/person/xx-add-email.txt'
284--- lib/lp/registry/stories/person/xx-add-email.txt 2012-04-11 11:34:02 +0000
285+++ lib/lp/registry/stories/person/xx-add-email.txt 2012-04-17 12:02:19 +0000
286@@ -9,13 +9,15 @@
287
288 >>> from lp.services.mail import stub
289
290- >>> browser = setupBrowser(auth='Basic test@canonical.com:test')
291+ >>> orig_email = 'test@canonical.com'
292+ >>> browser = setupBrowser(auth='Basic %s:test' % orig_email)
293 >>> browser.open('http://launchpad.dev/~name12')
294 >>> browser.getLink(url='+editemails').click()
295 >>> browser.url
296 'http://launchpad.dev/~name12/+editemails'
297
298- >>> browser.getControl('Add a new address').value = 'test2@canonical.com'
299+ >>> new_email = 'test2@canonical.com'
300+ >>> browser.getControl('Add a new address').value = new_email
301 >>> browser.getControl('Add', index=1).click()
302
303 >>> browser.url
304@@ -24,8 +26,18 @@
305 ... print msg
306 A confirmation message has been sent to...
307
308- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
309- >>> assert not stub.test_emails
310+There should be an email for the new address and one for the original
311+preferred email address that the change was made to their account. Order gets
312+mixed up so you have to check both spots.
313+
314+ >>> to_addrs = [to_addr for from_addr, to_addr, msg in stub.test_emails]
315+ >>> assert len(to_addrs) == 2
316+ >>> if orig_email in to_addrs[0][0]:
317+ ... assert new_email in to_addrs[1][0]
318+ ... else:
319+ ... assert new_email in to_addrs[0][0]
320+ ... assert orig_email in to_addrs[1][0]
321+ >>> stub.test_emails = []
322
323 Trying to add the same email again (while it's unconfirmed) will only cause
324 a new email to be sent with a new link. The links in the old and new email are
325@@ -42,16 +54,24 @@
326 A confirmation message has been sent to...
327
328 # Extract the link (from the email we just sent) the user will have to
329- # use to finish the registration process.
330+ # use to finish the registration process. There will be two emails so we
331+ # must check them both in order to make sure it's sent.
332 >>> from lp.services.verification.tests.logintoken import (
333 ... get_token_url_from_email)
334- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
335- >>> token_url = get_token_url_from_email(raw_msg)
336+ >>> def check_tokenemail_and_reset():
337+ ... found, url, toaddr = False, None, None
338+ ... for from_addr, to_addrs, raw_msg in stub.test_emails:
339+ ... if 'token' in raw_msg:
340+ ... found = True
341+ ... url = get_token_url_from_email(raw_msg)
342+ ... toaddr = to_addrs
343+ ... assert found
344+ ... return url, toaddr
345+ >>> token_url, toaddrs = check_tokenemail_and_reset()
346 >>> token_url
347 'http://launchpad.dev/token/...'
348- >>> to_addrs
349+ >>> toaddrs
350 ['test2@canonical.com']
351- >>> assert not stub.test_emails
352
353 Follow the token link, to confirm the new email address.
354
355@@ -103,13 +123,11 @@
356
357 # Extract the link (from the email we just sent) the user will have to
358 # use to finish the registration process.
359- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
360- >>> token_url = get_token_url_from_email(raw_msg)
361+ >>> token_url, toaddrs = check_tokenemail_and_reset()
362 >>> token_url
363 'http://launchpad.dev/token/...'
364- >>> to_addrs
365+ >>> toaddrs
366 ['sample@ubuntu.com']
367- >>> assert not stub.test_emails
368
369 Follow the token link, to confirm the new email address.
370
371
372=== modified file 'lib/lp/registry/stories/person/xx-add-sshkey.txt'
373--- lib/lp/registry/stories/person/xx-add-sshkey.txt 2012-01-15 11:06:57 +0000
374+++ lib/lp/registry/stories/person/xx-add-sshkey.txt 2012-04-17 12:02:19 +0000
375@@ -148,6 +148,20 @@
376 ...
377 UnexpectedFormData: ...
378
379+If he removes a key then he will get a security warning email notification
380+that the key has been removed.
381+
382+ >>> import email
383+ >>> from lp.services.mail import stub
384+ >>> stub.test_emails = []
385+ >>> browser.open('http://launchpad.dev/~salgado/+editsshkeys')
386+ >>> browser.getControl('Remove', index=0).click()
387+ >>> from_addr, to_addr, msg = stub.test_emails.pop()
388+ >>> to_addr
389+ ['guilherme.salgado@canonical.com']
390+ >>> payload = email.message_from_string(msg).get_payload()
391+ >>> assert payload.startswith('The SSH Key')
392+
393
394 == Blacklisted keys ==
395
396
397=== modified file 'lib/lp/registry/stories/person/xx-validate-email.txt'
398--- lib/lp/registry/stories/person/xx-validate-email.txt 2012-04-11 11:34:02 +0000
399+++ lib/lp/registry/stories/person/xx-validate-email.txt 2012-04-17 12:02:19 +0000
400@@ -24,10 +24,14 @@
401 Retrieve the email and make sure it was sent to the right address.
402
403 >>> import email
404- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
405- >>> msg = email.message_from_string(raw_msg)
406- >>> print msg.get('to')
407- salgado@ubuntu.com
408+ >>> found = False
409+ >>> raw_msg = None
410+ >>> for from_addr, to_addrs, orig_msg in stub.test_emails:
411+ ... msg = email.message_from_string(orig_msg)
412+ ... if msg.get('to') == 'salgado@ubuntu.com':
413+ ... raw_msg = orig_msg
414+ ... found = True
415+ >>> assert found
416
417 Visit the token URL mentioned in the email, and get redirected to
418 +validateemail.
419
420=== modified file 'lib/lp/registry/stories/webservice/xx-person.txt'
421--- lib/lp/registry/stories/webservice/xx-person.txt 2012-04-11 11:34:02 +0000
422+++ lib/lp/registry/stories/webservice/xx-person.txt 2012-04-17 12:02:19 +0000
423@@ -184,7 +184,7 @@
424 The sample person "ssh-user" doesn't have any keys to begin with:
425
426 >>> login('test@canonical.com')
427- >>> person = factory.makePerson(name="ssh-user")
428+ >>> person = factory.makePerson(name="ssh-user", email='ssh@launchpad.net')
429 >>> logout()
430 >>> sample_person = webservice.get("/~ssh-user").jsonBody()
431 >>> sshkeys = sample_person['sshkeys_collection_link']
432@@ -192,13 +192,21 @@
433 http://.../~ssh-user/sshkeys
434 >>> print_self_link_of_entries(webservice.get(sshkeys).jsonBody())
435
436-Let's give "ssh-user" a key via the back door of our internal Python APIs:
437+Let's give "ssh-user" a key via the back door of our internal Python APIs.
438+This setting of the ssh key should trigger a notice that the key has been
439+added.
440
441 >>> from zope.component import getUtility
442- >>> from lp.registry.interfaces.person import IPersonSet
443- >>> login(ANONYMOUS)
444- >>> ssh_user = getUtility(IPersonSet).getByName('ssh-user')
445- >>> ssh_key = factory.makeSSHKey(ssh_user)
446+ >>> from lp.services.mail import stub
447+ >>> import transaction
448+ >>> from lp.testing import person_logged_in
449+ >>> with person_logged_in(person):
450+ ... ssh_key = factory.makeSSHKey(person)
451+ ... transaction.commit()
452+ ... efrom, eto, emsg = stub.test_emails.pop()
453+ ... eto
454+ ['ssh@launchpad.net']
455+
456 >>> logout()
457
458 Now when we get the sshkey collection for 'sssh-user' again, the key should
459
460=== modified file 'lib/lp/registry/subscribers.py'
461--- lib/lp/registry/subscribers.py 2012-04-11 11:34:02 +0000
462+++ lib/lp/registry/subscribers.py 2012-04-17 12:02:19 +0000
463@@ -14,7 +14,10 @@
464 import pytz
465 from zope.security.proxy import removeSecurityProxy
466
467-from lp.registry.interfaces.person import IPerson
468+from lp.registry.interfaces.person import (
469+ IPerson,
470+ IPersonViewRestricted,
471+ )
472 from lp.registry.interfaces.product import License
473 from lp.services.config import config
474 from lp.services.mail.helpers import get_email_template
475
476=== modified file 'lib/lp/registry/tests/test_personnotification.py'
477--- lib/lp/registry/tests/test_personnotification.py 2012-04-11 11:34:02 +0000
478+++ lib/lp/registry/tests/test_personnotification.py 2012-04-17 12:02:19 +0000
479@@ -18,6 +18,7 @@
480 from zope.security.proxy import removeSecurityProxy
481
482 from lp.registry.interfaces.personnotification import IPersonNotificationSet
483+from lp.registry.model.personnotification import PersonNotification
484 from lp.registry.scripts.personnotification import PersonNotificationManager
485 from lp.services.config import config
486 from lp.testing import (
487@@ -25,6 +26,7 @@
488 TestCaseWithFactory,
489 )
490 from lp.testing.layers import DatabaseFunctionalLayer
491+from lp.testing.mail_helpers import pop_notifications
492
493
494 class TestPersonNotification(TestCaseWithFactory):
495
496=== modified file 'lib/lp/services/authserver/tests/test_authserver.py'
497--- lib/lp/services/authserver/tests/test_authserver.py 2012-04-11 11:34:02 +0000
498+++ lib/lp/services/authserver/tests/test_authserver.py 2012-04-17 12:02:19 +0000
499@@ -9,7 +9,10 @@
500 from zope.publisher.xmlrpc import TestRequest
501
502 from lp.services.authserver.xmlrpc import AuthServerAPIView
503-from lp.testing import TestCaseWithFactory
504+from lp.testing import (
505+ TestCaseWithFactory,
506+ person_logged_in,
507+ )
508 from lp.testing.layers import DatabaseFunctionalLayer
509 from lp.xmlrpc import faults
510 from lp.xmlrpc.interfaces import IPrivateApplication
511@@ -48,8 +51,9 @@
512 # name of the key type (RSA or DSA) and the text of the keys under
513 # 'keys' in the dict.
514 new_person = self.factory.makePerson()
515- key = self.factory.makeSSHKey(person=new_person)
516- self.assertEqual(
517- dict(id=new_person.id, name=new_person.name,
518- keys=[(key.keytype.title, key.keytext)]),
519- self.authserver.getUserAndSSHKeys(new_person.name))
520+ with person_logged_in(new_person):
521+ key = self.factory.makeSSHKey(person=new_person)
522+ self.assertEqual(
523+ dict(id=new_person.id, name=new_person.name,
524+ keys=[(key.keytype.title, key.keytext)]),
525+ self.authserver.getUserAndSSHKeys(new_person.name))
526
527=== modified file 'lib/lp/services/identity/model/emailaddress.py'
528--- lib/lp/services/identity/model/emailaddress.py 2012-04-11 11:34:02 +0000
529+++ lib/lp/services/identity/model/emailaddress.py 2012-04-17 12:02:19 +0000
530@@ -19,6 +19,7 @@
531 ForeignKey,
532 StringCol,
533 )
534+
535 from zope.interface import implements
536
537 from lp.app.validators.email import valid_email
538
539=== modified file 'lib/lp/services/mail/doc/sending-mail.txt'
540--- lib/lp/services/mail/doc/sending-mail.txt 2012-04-11 11:34:02 +0000
541+++ lib/lp/services/mail/doc/sending-mail.txt 2012-04-17 12:02:19 +0000
542@@ -82,10 +82,13 @@
543 ... body=u'Content')
544
545 >>> transaction.commit()
546- >>> from_addr, to_addr, raw_message = stub.test_emails.pop()
547- >>> msg = email.message_from_string(raw_message)
548- >>> msg['From']
549- 'Sample Person <testing@canonical.com>'
550+ >>> found = False
551+ >>> for from_addr, to_addr, raw_message in stub.test_emails:
552+ ... msg = email.message_from_string(raw_message)
553+ ... if msg['From'] == 'Sample Person <testing@canonical.com>':
554+ ... found = True
555+ >>> assert found
556+ >>> stub.test_emails = []
557
558
559 To make a header appear more than once in the sent message (e.g.
560
561=== modified file 'lib/lp/services/verification/browser/tests/logintoken-corner-cases.txt'
562--- lib/lp/services/verification/browser/tests/logintoken-corner-cases.txt 2012-04-11 11:34:02 +0000
563+++ lib/lp/services/verification/browser/tests/logintoken-corner-cases.txt 2012-04-17 12:02:19 +0000
564@@ -14,6 +14,7 @@
565 redirect to the default token view. This would happen if for example the
566 user tried to re-post the form after validating one of their email addresses.
567
568+ >>> from lp.testing import login_person
569 >>> from lp.services.verification.browser.logintoken import (
570 ... ValidateEmailView)
571 >>> from lp.services.verification.interfaces.authtoken import (
572@@ -24,6 +25,7 @@
573 >>> from lp.registry.interfaces.person import IPersonSet
574
575 >>> foo_bar = getUtility(IPersonSet).getByName('name16')
576+ >>> ignored = login_person(foo_bar)
577 >>> token = getUtility(ILoginTokenSet).new(
578 ... requester=foo_bar, requesteremail='foo.bar@canonical.com',
579 ... email='foo@barino.com', tokentype=LoginTokenType.VALIDATEEMAIL)
580
581=== modified file 'lib/lp/services/verification/browser/tests/test_logintoken.py'
582--- lib/lp/services/verification/browser/tests/test_logintoken.py 2012-04-11 11:34:02 +0000
583+++ lib/lp/services/verification/browser/tests/test_logintoken.py 2012-04-17 12:02:19 +0000
584@@ -12,7 +12,10 @@
585 )
586 from lp.services.verification.interfaces.authtoken import LoginTokenType
587 from lp.services.verification.interfaces.logintoken import ILoginTokenSet
588-from lp.testing import TestCaseWithFactory
589+from lp.testing import (
590+ TestCaseWithFactory,
591+ person_logged_in,
592+ )
593 from lp.testing.deprecated import LaunchpadFormHarness
594 from lp.testing.layers import DatabaseFunctionalLayer
595
596@@ -45,10 +48,11 @@
597 self._testCancelAction(ValidateGPGKeyView, token)
598
599 def test_ValidateEmailView(self):
600- token = getUtility(ILoginTokenSet).new(
601- self.person, self.email, 'foo@example.com',
602- LoginTokenType.VALIDATEEMAIL)
603- self._testCancelAction(ValidateEmailView, token)
604+ with person_logged_in(self.person):
605+ token = getUtility(ILoginTokenSet).new(
606+ self.person, self.email, 'foo@example.com',
607+ LoginTokenType.VALIDATEEMAIL)
608+ self._testCancelAction(ValidateEmailView, token)
609
610 def _testCancelAction(self, view_class, token):
611 """Test the 'Cancel' action of the given view, using the given token.
612
613=== modified file 'lib/lp/services/verification/doc/logintoken.txt'
614--- lib/lp/services/verification/doc/logintoken.txt 2011-12-30 06:14:56 +0000
615+++ lib/lp/services/verification/doc/logintoken.txt 2012-04-17 12:02:19 +0000
616@@ -34,16 +34,21 @@
617 ... LoginTokenType.VALIDATEEMAIL)
618 >>> token.sendEmailValidationRequest()
619 >>> transaction.commit()
620- >>> from_addr, to_addrs, raw_msg = stub.test_emails.pop()
621- >>> to_addrs
622- ['foo.bar2@canonical.com']
623+ >>> found = False
624+ >>> found_msg = None
625+ >>> for from_addr, to_addrs, raw_msg in stub.test_emails:
626+ ... if to_addrs == ['foo.bar2@canonical.com']:
627+ ... found = True
628+ ... found_msg = raw_msg
629+ >>> assert found
630+ >>> stub.test_emails = []
631
632 The email does not have a precedence header because the user implicitly
633 requested it to complete his task.
634
635 >>> import email
636
637- >>> msg = email.message_from_string(raw_msg)
638+ >>> msg = email.message_from_string(found_msg)
639 >>> print msg['Precedence']
640 None
641
642
643=== modified file 'lib/lp/services/verification/model/logintoken.py'
644--- lib/lp/services/verification/model/logintoken.py 2012-04-11 11:34:02 +0000
645+++ lib/lp/services/verification/model/logintoken.py 2012-04-17 12:02:19 +0000
646@@ -114,6 +114,10 @@
647 message = template % replacements
648 subject = "Launchpad: Validate your email address"
649 self._send_email("Launchpad Email Validator", subject, message)
650+ self.requester.security_field_changed(
651+ "A new email address is being added to your Launchpad account.",
652+ "<%s> will be activated for your account when you follow the "
653+ "instructions that were sent to <%s>." % (self.email, self.email))
654
655 def sendGPGValidationRequest(self, key):
656 """See ILoginToken."""
657@@ -391,7 +395,6 @@
658 # Aha! According to our policy, we shouldn't raise ValueError.
659 raise ValueError(
660 "tokentype is not an item of LoginTokenType: %s" % tokentype)
661-
662 token = create_unique_token_for_table(20, LoginToken.token)
663 return LoginToken(requester=requester, requesteremail=requesteremail,
664 email=email, token=token, tokentype=tokentype,