Merge lp:~wgrant/launchpad/bug-1005330 into lp:launchpad

Proposed by William Grant
Status: Merged
Merged at revision: 15316
Proposed branch: lp:~wgrant/launchpad/bug-1005330
Merge into: lp:launchpad
Diff against target: 367 lines (+179/-72)
7 files modified
lib/lp/registry/browser/tests/test_person_webservice.py (+89/-0)
lib/lp/registry/interfaces/person.py (+14/-1)
lib/lp/registry/model/person.py (+25/-0)
lib/lp/registry/stories/webservice/xx-people.txt (+0/-71)
lib/lp/registry/tests/test_personset.py (+34/-0)
lib/lp/testing/__init__.py (+3/-0)
lib/lp/testing/_login.py (+14/-0)
To merge this branch: bzr merge lp:~wgrant/launchpad/bug-1005330
Reviewer Review Type Date Requested Status
Benji York (community) code Approve
Review via email: mp+107605@code.launchpad.net

Commit message

Add and export PersonSet.getByOpenIDIdentifier to let applications map from an SSO account to the corresponding Launchpad one.

Description of the change

This branch adds and exports PersonSet.getByOpenIDIdentifier, an API method to look up the person associated with a given OpenID identifier, satisfying the needs of Summit and probably other SSO+Launchpad consumers, thereby alleviating many of the issues that make bug #881019 important.

Unlike other methods around the application that purport to deal with OpenID identifiers, this method actually does. The rest of our code thinks an identifier is the unique "4tLsDY8" part of "https://login.launchpad.net/+id/4tLsDY8", when in fact the full URL is the identifier. Since it's designed to be externally consumed, PersonSet.getByOpenIDIdentifier accepts a URL, checks that it's from one of our providers (login.launchpad.net or login.ubuntu.com), and extracts the unique suffix to look up in the DB.

I replaced the PersonSet webservice doctest with unit tests, and added new ones for this. I also added a new admin_logged_in context manager; it's less slow, less verbose, and less emaily than celebrity_logged_in('admin'). I'll be cleaning up other tests to use that in a later branch.

The + in the path was initially some cause for concern, as applications have a habit of reencoding specialish characters. But by RFC 2396 section 2.2 it is reserved and cannot be reencoded, and we know that SSO always sends it unencoded. So I've opted to be strict and only accept the canonical form sent by SSO. It shouldn't be a problem, but if it proves to be we can come up with a more lenient implementation.

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

This branch looks good. The translation of doctests to unittests was well done (the prose was carried over well and resulted in nicely readable tests).

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/browser/tests/test_person_webservice.py'
2--- lib/lp/registry/browser/tests/test_person_webservice.py 2012-01-30 04:17:27 +0000
3+++ lib/lp/registry/browser/tests/test_person_webservice.py 2012-05-28 10:50:39 +0000
4@@ -7,8 +7,10 @@
5 from zope.security.proxy import removeSecurityProxy
6
7 from lp.testing import (
8+ admin_logged_in,
9 launchpadlib_for,
10 login,
11+ logout,
12 TestCaseWithFactory,
13 )
14 from lp.testing.layers import DatabaseFunctionalLayer
15@@ -74,3 +76,90 @@
16 self.assertEquals(
17 rendered_comment,
18 '<a href="/~test-person" class="sprite person">Test Person</a>')
19+
20+
21+class PersonSetWebServiceTests(TestCaseWithFactory):
22+
23+ layer = DatabaseFunctionalLayer
24+
25+ def setUp(self):
26+ super(PersonSetWebServiceTests, self).setUp()
27+ self.webservice = LaunchpadWebServiceCaller('test', None)
28+ logout()
29+
30+ def assertReturnsPeople(self, expected_names, path):
31+ self.assertEqual(
32+ expected_names,
33+ [person['name'] for person in
34+ self.webservice.get(path).jsonBody()['entries']])
35+
36+ def test_default_content(self):
37+ # /people lists the 50 people with the most karma, excluding
38+ # those with no karma at all.
39+ self.assertEqual(
40+ 4, len(self.webservice.get('/people').jsonBody()['entries']))
41+
42+ def test_find(self):
43+ # It's possible to find people by name.
44+ with admin_logged_in():
45+ person_name = self.factory.makePerson().name
46+ self.assertReturnsPeople(
47+ [person_name], '/people?ws.op=find&text=%s' % person_name)
48+
49+ def test_findTeam(self):
50+ # The search can be restricted to teams.
51+ with admin_logged_in():
52+ person_name = self.factory.makePerson().name
53+ team_name = self.factory.makeTeam(
54+ name='%s-team' % person_name).name
55+ self.assertReturnsPeople(
56+ [team_name], '/people?ws.op=findTeam&text=%s' % person_name)
57+
58+ def test_findPerson(self):
59+ # The search can be restricted to people.
60+ with admin_logged_in():
61+ person_name = self.factory.makePerson().name
62+ self.factory.makeTeam(name='%s-team' % person_name)
63+ self.assertReturnsPeople(
64+ [person_name], '/people?ws.op=findPerson&text=%s' % person_name)
65+
66+ def test_find_by_date(self):
67+ # Creation date filtering is supported.
68+ self.assertReturnsPeople(
69+ [u'bac'],
70+ '/people?ws.op=findPerson&text='
71+ '&created_after=2008-06-27&created_before=2008-07-01')
72+
73+ def test_getByEmail(self):
74+ # You can get a person by their email address.
75+ with admin_logged_in():
76+ person = self.factory.makePerson()
77+ person_name = person.name
78+ person_email = person.preferredemail.email
79+ self.assertEqual(
80+ person_name,
81+ self.webservice.get(
82+ '/people?ws.op=getByEmail&email=%s' % person_email
83+ ).jsonBody()['name'])
84+
85+ def test_getByEmail_checks_format(self):
86+ # A malformed email address is rejected.
87+ e = self.assertRaises(
88+ ValueError,
89+ self.webservice.get(
90+ '/people?ws.op=getByEmail&email=foo@').jsonBody)
91+ self.assertEqual("email: Invalid email 'foo@'.", e[0])
92+
93+ def test_getByOpenIDIdentifier(self):
94+ # You can get a person by their OpenID identifier URL.
95+ with admin_logged_in():
96+ person = self.factory.makePerson()
97+ person_name = person.name
98+ person_openid = person.account.openid_identifiers.one().identifier
99+ self.assertEqual(
100+ person_name,
101+ self.webservice.get(
102+ '/people?ws.op=getByOpenIDIdentifier&'
103+ 'identifier=http://openid.launchpad.dev/%%2Bid/%s'
104+ % person_openid,
105+ api_version='devel').jsonBody()['name'])
106
107=== modified file 'lib/lp/registry/interfaces/person.py'
108--- lib/lp/registry/interfaces/person.py 2012-05-04 14:52:44 +0000
109+++ lib/lp/registry/interfaces/person.py 2012-05-28 10:50:39 +0000
110@@ -2152,6 +2152,18 @@
111 on the displayname or other arguments.
112 """
113
114+ @operation_parameters(identifier=TextLine(required=True))
115+ @operation_returns_entry(IPerson)
116+ @export_read_operation()
117+ @operation_for_version("devel")
118+ def getByOpenIDIdentifier(identifier):
119+ """Get the person for a given OpenID identifier.
120+
121+ :param openid_identifier: full OpenID identifier URL for the user.
122+ :return: the corresponding `IPerson` or None if the identifier is
123+ unknown
124+ """
125+
126 def getOrCreateByOpenIDIdentifier(openid_identifier, email,
127 full_name, creation_rationale, comment):
128 """Get or create a person for a given OpenID identifier.
129@@ -2166,7 +2178,8 @@
130 If there is no existing Launchpad person for the account, we
131 create it.
132
133- :param openid_identifier: representing the authenticated user.
134+ :param openid_identifier: OpenID identifier suffix for the user.
135+ This is *not* the full URL, just the unique suffix portion.
136 :param email_address: the email address of the user.
137 :param full_name: the full name of the user.
138 :param creation_rationale: When an account or person needs to
139
140=== modified file 'lib/lp/registry/model/person.py'
141--- lib/lp/registry/model/person.py 2012-05-14 10:56:16 +0000
142+++ lib/lp/registry/model/person.py 2012-05-28 10:50:39 +0000
143@@ -304,6 +304,7 @@
144 from lp.services.verification.model.logintoken import LoginToken
145 from lp.services.webapp.dbpolicy import MasterDatabasePolicy
146 from lp.services.webapp.interfaces import ILaunchBag
147+from lp.services.webapp.vhosts import allvhosts
148 from lp.services.worlddata.model.language import Language
149 from lp.soyuz.enums import (
150 ArchivePurpose,
151@@ -3166,6 +3167,30 @@
152 key=lambda obj: (obj.karma, obj.displayname, obj.id),
153 reverse=True)
154
155+ def getByOpenIDIdentifier(self, identifier):
156+ """See `IPersonSet`."""
157+ # We accept a full OpenID identifier URL from either the
158+ # Launchpad- or Ubuntu-branded OpenID services. But we only
159+ # store the unique suffix of the identifier, so we need to strip
160+ # the rest of the URL.
161+ # + is reserved, so is not allowed to be reencoded in transit, so
162+ # should never appear as its percent-encoded equivalent.
163+ identifier_suffix = None
164+ for vhost in ('openid', 'ubuntu_openid'):
165+ root = '%s+id/' % allvhosts.configs[vhost].rooturl
166+ if identifier.startswith(root):
167+ identifier_suffix = identifier.replace(root, '', 1)
168+ break
169+ if identifier_suffix is None:
170+ return None
171+
172+ try:
173+ account = getUtility(IAccountSet).getByOpenIDIdentifier(
174+ identifier_suffix)
175+ except LookupError:
176+ return None
177+ return IPerson(account)
178+
179 def getOrCreateByOpenIDIdentifier(
180 self, openid_identifier, email_address, full_name,
181 creation_rationale, comment):
182
183=== removed file 'lib/lp/registry/stories/webservice/xx-people.txt'
184--- lib/lp/registry/stories/webservice/xx-people.txt 2010-01-05 15:59:03 +0000
185+++ lib/lp/registry/stories/webservice/xx-people.txt 1970-01-01 00:00:00 +0000
186@@ -1,71 +0,0 @@
187-= The set of people and teams =
188-
189-The set of people and teams in Launchpad is represented by the
190-collection found at /people. By default it lists the (up to) 50
191-people with the most karma, not including people with no karma.
192-
193- >>> people = webservice.get("/people").jsonBody()
194- >>> len(people['entries'])
195- 4
196-
197-
198-== Custom operations ==
199-
200-But it also offer operations to filter that list. For example, the
201-find operation will search for people and teams matching the given
202-search text.
203-
204- >>> search = webservice.get(
205- ... "/people?ws.op=find&text=salgado").jsonBody()
206- >>> sorted(person['name'] for person in search['entries'])
207- [u'salgado']
208-
209-There are also operations to search only for people or only for teams.
210-
211- >>> search = webservice.get(
212- ... "/people?ws.op=findTeam&text=salgado").jsonBody()
213- >>> sorted(person['name'] for person in search['entries'])
214- []
215-
216- >>> search = webservice.get(
217- ... "/people?ws.op=findPerson&text=salgado").jsonBody()
218- >>> sorted(person['name'] for person in search['entries'])
219- [u'salgado']
220-
221- >>> results = webservice.get(
222- ... "/people?ws.op=findPerson&text="
223- ... "&created_after=2008-06-27&created_before=2008-07-01").jsonBody()
224- >>> sorted(person['name'] for person in results['entries'])
225- [u'bac']
226-
227-It's possible to get a list of teams by virtue of which a person
228-belongs to a particular team.
229-
230- # XXX: salgado, 2008-08-01: Commented because method has been Unexported;
231- # it should be re-enabled after the operation is exported again.
232- # >>> response = webservice.named_get(
233- # ... '/~salgado', 'findPathToTeam',
234- # ... team=webservice.getAbsoluteUrl('/~mailing-list-experts'))
235-
236- # >>> path = response.jsonBody()
237- # >>> path['total_size']
238- # 2
239- # >>> [person['name'] for person in path['entries']]
240- # [u'admins', u'mailing-list-experts']
241-
242-If you know the email address of a person/team, it's possible to
243-retrieve it using that email.
244-
245- >>> daf = webservice.get(
246- ... "/people?ws.op=getByEmail&email=daf@canonical.com").jsonBody()
247- >>> daf['name']
248- u'daf'
249-
250-When looking up a person by email address, you have to provide a valid
251-email address.
252-
253- >>> print webservice.get(
254- ... "/people?ws.op=getByEmail&email=daf@")
255- HTTP/1.1 400 Bad Request
256- ...
257- email: Invalid email 'daf@'.
258
259=== modified file 'lib/lp/registry/tests/test_personset.py'
260--- lib/lp/registry/tests/test_personset.py 2012-03-02 23:32:28 +0000
261+++ lib/lp/registry/tests/test_personset.py 2012-05-28 10:50:39 +0000
262@@ -140,6 +140,40 @@
263 person.preferredemail
264 self.assertThat(recorder, HasQueryCount(LessThan(1)))
265
266+ def test_getByOpenIDIdentifier_returns_person(self):
267+ # getByOpenIDIdentifier takes a full OpenID identifier and
268+ # returns the corresponding person.
269+ person = self.factory.makePerson()
270+ with person_logged_in(person):
271+ identifier = person.account.openid_identifiers.one().identifier
272+ self.assertEqual(
273+ person,
274+ self.person_set.getByOpenIDIdentifier(
275+ u'http://openid.launchpad.dev/+id/%s' % identifier))
276+ self.assertEqual(
277+ person,
278+ self.person_set.getByOpenIDIdentifier(
279+ u'http://ubuntu-openid.launchpad.dev/+id/%s' % identifier))
280+
281+ def test_getByOpenIDIdentifier_for_nonexistent_identifier_is_none(self):
282+ # None is returned if there's no matching person.
283+ self.assertIs(
284+ None,
285+ self.person_set.getByOpenIDIdentifier(
286+ u'http://openid.launchpad.dev/+id/notanid'))
287+
288+ def test_getByOpenIDIdentifier_for_bad_domain_is_none(self):
289+ # Even though the OpenIDIdentifier table doesn't store the
290+ # domain, we verify it against our known OpenID faux-vhosts.
291+ # If it doesn't match, we don't even try to check the identifier.
292+ person = self.factory.makePerson()
293+ with person_logged_in(person):
294+ identifier = person.account.openid_identifiers.one().identifier
295+ self.assertIs(
296+ None,
297+ self.person_set.getByOpenIDIdentifier(
298+ u'http://not.launchpad.dev/+id/%s' % identifier))
299+
300
301 class TestPersonSetMergeMailingListSubscriptions(TestCaseWithFactory):
302
303
304=== modified file 'lib/lp/testing/__init__.py'
305--- lib/lp/testing/__init__.py 2012-05-03 14:06:03 +0000
306+++ lib/lp/testing/__init__.py 2012-05-28 10:50:39 +0000
307@@ -10,6 +10,7 @@
308 __all__ = [
309 'AbstractYUITestCase',
310 'ANONYMOUS',
311+ 'admin_logged_in',
312 'anonymous_logged_in',
313 'api_url',
314 'BrowserTestCase',
315@@ -157,6 +158,7 @@
316 # Import the login helper functions here as it is a much better
317 # place to import them from in tests.
318 from lp.testing._login import (
319+ admin_logged_in,
320 anonymous_logged_in,
321 celebrity_logged_in,
322 login,
323@@ -183,6 +185,7 @@
324
325 # The following names have been imported for the purpose of being
326 # exported. They are referred to here to silence lint warnings.
327+admin_logged_in
328 anonymous_logged_in
329 api_url
330 celebrity_logged_in
331
332=== modified file 'lib/lp/testing/_login.py'
333--- lib/lp/testing/_login.py 2012-05-03 20:27:12 +0000
334+++ lib/lp/testing/_login.py 2012-05-28 10:50:39 +0000
335@@ -6,6 +6,7 @@
336 __metaclass__ = type
337
338 __all__ = [
339+ 'admin_logged_in',
340 'anonymous_logged_in',
341 'celebrity_logged_in',
342 'login',
343@@ -129,6 +130,13 @@
344 return login_as(celeb, participation=participation)
345
346
347+def login_admin(ignored, participation=None):
348+ """Log in as an admin."""
349+ login(ANONYMOUS)
350+ admin = getUtility(ILaunchpadCelebrities).admin.teamowner
351+ return login_as(admin, participation=participation)
352+
353+
354 def logout():
355 """Tear down after login(...), ending the current interaction.
356
357@@ -180,6 +188,12 @@
358 return _with_login(login_celebrity, celebrity_name)
359
360
361+@contextmanager
362+def admin_logged_in():
363+ # Use teamowner to avoid expensive and noisy team member additions.
364+ return _with_login(login_admin, None)
365+
366+
367 with_anonymous_login = decorate_with(person_logged_in, None)
368
369