Merge lp:~salgado/launchpad/create-account-for-unseen-openid-id into lp:launchpad

Proposed by Guilherme Salgado
Status: Merged
Merged at revision: not available
Proposed branch: lp:~salgado/launchpad/create-account-for-unseen-openid-id
Merge into: lp:launchpad
Diff against target: 388 lines (+164/-29)
6 files modified
lib/canonical/launchpad/database/account.py (+7/-4)
lib/canonical/launchpad/webapp/login.py (+31/-3)
lib/canonical/launchpad/webapp/tests/cookie-authentication.txt (+1/-1)
lib/canonical/launchpad/webapp/tests/login.txt (+1/-1)
lib/canonical/launchpad/webapp/tests/no-anonymous-session-cookies.txt (+1/-1)
lib/canonical/launchpad/webapp/tests/test_login.py (+123/-19)
To merge this branch: bzr merge lp:~salgado/launchpad/create-account-for-unseen-openid-id
Reviewer Review Type Date Requested Status
Michael Nelson (community) code Approve
Review via email: mp+20113@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Guilherme Salgado (salgado) wrote :

= Summary =

We'll soon break the auth tables out of the replication cluster, which
means we'll have one copy of them used by login.lp.net (and
login.ubuntu.com) and a separate one used by launchpad.net.

That means accounts created after the split won't be seen by Launchpad,
so we need to automatically create them when we see a new identity URL.

This is a temporary thing as soon afterwards we'll refactor the Account
table used by launchpad.net -- at that time we'll only need to register
the identity URL and maybe associate it with an existing profile.

= Launchpad lint =

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

Linting changed files:
  lib/canonical/launchpad/database/account.py
  lib/canonical/launchpad/webapp/login.py
  lib/canonical/launchpad/webapp/tests/test_login.py
  lib/canonical/launchpad/webapp/tests/no-anonymous-session-cookies.txt
  lib/canonical/launchpad/webapp/tests/cookie-authentication.txt
  lib/canonical/launchpad/webapp/tests/login.txt

This is a lie; Browser is defined in the test globals.

== Pyflakes Doctest notices ==

lib/canonical/launchpad/webapp/tests/login.txt
    11: undefined name 'Browser'

Revision history for this message
Michael Nelson (michael.nelson) wrote :
Download full text (12.9 KiB)

Hi Guilherme,

I've just got a few small questions below, and one suggestion for the name of your context manager.

Cheers,
-Michael

> === modified file 'lib/canonical/launchpad/webapp/login.py'
> --- lib/canonical/launchpad/webapp/login.py 2010-02-18 10:52:51 +0000
> +++ lib/canonical/launchpad/webapp/login.py 2010-02-25 11:53:51 +0000
> @@ -12,6 +12,7 @@
> from BeautifulSoup import UnicodeDammit
>
> from openid.consumer.consumer import CANCEL, Consumer, FAILURE, SUCCESS
> +from openid.extensions import sreg
> from openid.fetchers import setDefaultFetcher, Urllib2Fetcher
>
> import transaction
> @@ -27,7 +28,6 @@
>
> from z3c.ptcompat import ViewPageTemplateFile
>
> -from canonical.cachedproperty import cachedproperty
> from canonical.config import config
> from canonical.launchpad import _
> from canonical.launchpad.interfaces.account import AccountStatus, IAccountSet
> @@ -181,6 +181,8 @@
> openid_vhost = config.launchpad.openid_provider_vhost
> self.openid_request = consumer.begin(
> allvhosts.configs[openid_vhost].rooturl)
> + self.openid_request.addExtension(
> + sreg.SRegRequest(optional=['email', 'fullname']))
>
> trust_root = self.request.getApplicationURL()
> assert not self.openid_request.shouldSendRedirect(), (
> @@ -258,8 +260,34 @@
>
> def render(self):
> if self.openid_response.status == SUCCESS:
> - account = getUtility(IAccountSet).getByOpenIDIdentifier(
> - self.openid_response.identity_url.split('/')[-1])
> + identifier = self.openid_response.identity_url.split('/')[-1]
> + account_set = getUtility(IAccountSet)
> + try:
> + account = account_set.getByOpenIDIdentifier(identifier)
> + except LookupError:
> + # Here we assume the OP sent us the user's email address and
> + # full name in the response. Note we can only do that because
> + # we used a fixed OP (login.launchpad.net) that includes the
> + # user's email address in the response when asked to. Once we

s/email address/email address and full name ? Or is full name returned from
all OP's when requested?

> + # start using other OPs we won't be able to make this
> + # assumption here as they might not include what we want in
> + # the response.
> + sreg_response = sreg.SRegResponse.fromSuccessResponse(
> + self.openid_response)
> + assert sreg_response is not None, (
> + "OP didn't include an sreg extension in the response.")
> + email_address = sreg_response.get('email')
> + full_name = sreg_response.get('fullname')
> + assert email_address is not None and full_name is not None, (
> + "No email address or full name found in sreg response; "
> + "can't create a new account for this identity URL.")
> + account, email = account_set.createAccountAndEmail(
> + email_address,
> + PersonCreationRati...

review: Approve (code)
Revision history for this message
Guilherme Salgado (salgado) wrote :
Download full text (8.5 KiB)

On Thu, 2010-02-25 at 12:30 +0000, Michael Nelson wrote:
> Review: Approve code
> Hi Guilherme,
>
> I've just got a few small questions below, and one suggestion for the name of your context manager.
>
> Cheers,
> -Michael
>
> > === modified file 'lib/canonical/launchpad/webapp/login.py'
> > --- lib/canonical/launchpad/webapp/login.py 2010-02-18 10:52:51 +0000
> > +++ lib/canonical/launchpad/webapp/login.py 2010-02-25 11:53:51 +0000
> > @@ -12,6 +12,7 @@
> > from BeautifulSoup import UnicodeDammit
> >
> > from openid.consumer.consumer import CANCEL, Consumer, FAILURE, SUCCESS
> > +from openid.extensions import sreg
> > from openid.fetchers import setDefaultFetcher, Urllib2Fetcher
> >
> > import transaction
> > @@ -27,7 +28,6 @@
> >
> > from z3c.ptcompat import ViewPageTemplateFile
> >
> > -from canonical.cachedproperty import cachedproperty
> > from canonical.config import config
> > from canonical.launchpad import _
> > from canonical.launchpad.interfaces.account import AccountStatus, IAccountSet
> > @@ -181,6 +181,8 @@
> > openid_vhost = config.launchpad.openid_provider_vhost
> > self.openid_request = consumer.begin(
> > allvhosts.configs[openid_vhost].rooturl)
> > + self.openid_request.addExtension(
> > + sreg.SRegRequest(optional=['email', 'fullname']))
> >
> > trust_root = self.request.getApplicationURL()
> > assert not self.openid_request.shouldSendRedirect(), (
> > @@ -258,8 +260,34 @@
> >
> > def render(self):
> > if self.openid_response.status == SUCCESS:
> > - account = getUtility(IAccountSet).getByOpenIDIdentifier(
> > - self.openid_response.identity_url.split('/')[-1])
> > + identifier = self.openid_response.identity_url.split('/')[-1]
> > + account_set = getUtility(IAccountSet)
> > + try:
> > + account = account_set.getByOpenIDIdentifier(identifier)
> > + except LookupError:
> > + # Here we assume the OP sent us the user's email address and
> > + # full name in the response. Note we can only do that because
> > + # we used a fixed OP (login.launchpad.net) that includes the
> > + # user's email address in the response when asked to. Once we
>
> s/email address/email address and full name ? Or is full name returned from
> all OP's when requested?

No, by default they only include the identity URL in the response. I've
updated the comment.

>
> > === renamed file 'lib/canonical/launchpad/webapp/tests/test_new_login.py' => 'lib/canonical/launchpad/webapp/tests/test_login.py'
> > --- lib/canonical/launchpad/webapp/tests/test_new_login.py 2010-02-17 19:40:41 +0000
> > +++ lib/canonical/launchpad/webapp/tests/test_login.py 2010-02-25 11:53:51 +0000
[...]
> > @@ -42,25 +52,47 @@
> > self.login_called = True
> >
> >
> > +@contextmanager
> > +def stub_SRegResponse_fromSuccessResponse():
> > + def sregFromFakeSuccessResponse(cls, success_response, signed_only=True):
> > + return {'email': success_response.sreg_email,
> > + 'fullname': success_response.sreg_fullname}
> > +...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/database/account.py'
2--- lib/canonical/launchpad/database/account.py 2009-08-14 12:50:43 +0000
3+++ lib/canonical/launchpad/database/account.py 2010-02-25 13:13:19 +0000
4@@ -221,11 +221,12 @@
5 implements(IAccountSet)
6
7 def new(self, rationale, displayname, password=None,
8- password_is_encrypted=False):
9+ password_is_encrypted=False, openid_identifier=DEFAULT):
10 """See `IAccountSet`."""
11
12 account = Account(
13- displayname=displayname, creation_rationale=rationale)
14+ displayname=displayname, creation_rationale=rationale,
15+ openid_identifier=openid_identifier)
16
17 # Create the password record.
18 if password is not None:
19@@ -243,13 +244,15 @@
20 return account
21
22 def createAccountAndEmail(self, email, rationale, displayname, password,
23- password_is_encrypted=False):
24+ password_is_encrypted=False,
25+ openid_identifier=DEFAULT):
26 """See `IAccountSet`."""
27 # Convert the PersonCreationRationale to an AccountCreationRationale.
28 account_rationale = getattr(AccountCreationRationale, rationale.name)
29 account = self.new(
30 account_rationale, displayname, password=password,
31- password_is_encrypted=password_is_encrypted)
32+ password_is_encrypted=password_is_encrypted,
33+ openid_identifier=openid_identifier)
34 account.status = AccountStatus.ACTIVE
35 email = getUtility(IEmailAddressSet).new(
36 email, status=EmailAddressStatus.PREFERRED, account=account)
37
38=== modified file 'lib/canonical/launchpad/webapp/login.py'
39--- lib/canonical/launchpad/webapp/login.py 2010-02-18 10:52:51 +0000
40+++ lib/canonical/launchpad/webapp/login.py 2010-02-25 13:13:19 +0000
41@@ -12,6 +12,7 @@
42 from BeautifulSoup import UnicodeDammit
43
44 from openid.consumer.consumer import CANCEL, Consumer, FAILURE, SUCCESS
45+from openid.extensions import sreg
46 from openid.fetchers import setDefaultFetcher, Urllib2Fetcher
47
48 import transaction
49@@ -27,7 +28,6 @@
50
51 from z3c.ptcompat import ViewPageTemplateFile
52
53-from canonical.cachedproperty import cachedproperty
54 from canonical.config import config
55 from canonical.launchpad import _
56 from canonical.launchpad.interfaces.account import AccountStatus, IAccountSet
57@@ -181,6 +181,8 @@
58 openid_vhost = config.launchpad.openid_provider_vhost
59 self.openid_request = consumer.begin(
60 allvhosts.configs[openid_vhost].rooturl)
61+ self.openid_request.addExtension(
62+ sreg.SRegRequest(optional=['email', 'fullname']))
63
64 trust_root = self.request.getApplicationURL()
65 assert not self.openid_request.shouldSendRedirect(), (
66@@ -258,8 +260,34 @@
67
68 def render(self):
69 if self.openid_response.status == SUCCESS:
70- account = getUtility(IAccountSet).getByOpenIDIdentifier(
71- self.openid_response.identity_url.split('/')[-1])
72+ identifier = self.openid_response.identity_url.split('/')[-1]
73+ account_set = getUtility(IAccountSet)
74+ try:
75+ account = account_set.getByOpenIDIdentifier(identifier)
76+ except LookupError:
77+ # Here we assume the OP sent us the user's email address and
78+ # full name in the response. Note we can only do that because
79+ # we used a fixed OP (login.launchpad.net) that includes the
80+ # user's email address and full name in the response when
81+ # asked to. Once we start using other OPs we won't be able to
82+ # make this assumption here as they might not include what we
83+ # want in the response.
84+ sreg_response = sreg.SRegResponse.fromSuccessResponse(
85+ self.openid_response)
86+ assert sreg_response is not None, (
87+ "OP didn't include an sreg extension in the response.")
88+ email_address = sreg_response.get('email')
89+ full_name = sreg_response.get('fullname')
90+ assert email_address is not None and full_name is not None, (
91+ "No email address or full name found in sreg response; "
92+ "can't create a new account for this identity URL.")
93+ account, email = account_set.createAccountAndEmail(
94+ email_address,
95+ PersonCreationRationale.OWNER_CREATED_LAUNCHPAD,
96+ full_name,
97+ password=None,
98+ openid_identifier=identifier)
99+
100 if account.status == AccountStatus.SUSPENDED:
101 return self.suspended_account_template()
102 if IPerson(account, None) is None:
103
104=== modified file 'lib/canonical/launchpad/webapp/tests/cookie-authentication.txt'
105--- lib/canonical/launchpad/webapp/tests/cookie-authentication.txt 2010-02-17 19:40:41 +0000
106+++ lib/canonical/launchpad/webapp/tests/cookie-authentication.txt 2010-02-25 13:13:19 +0000
107@@ -20,7 +20,7 @@
108 <html>...<body onload="document.forms[0].submit();"...
109 >>> browser.getControl('Continue').click()
110
111- >>> from canonical.launchpad.webapp.tests.test_new_login import (
112+ >>> from canonical.launchpad.webapp.tests.test_login import (
113 ... fill_login_form_and_submit)
114 >>> fill_login_form_and_submit(browser, 'foo.bar@canonical.com', 'test')
115 >>> print extract_text(find_tag_by_id(browser.contents, 'logincontrol'))
116
117=== modified file 'lib/canonical/launchpad/webapp/tests/login.txt'
118--- lib/canonical/launchpad/webapp/tests/login.txt 2010-02-17 19:40:41 +0000
119+++ lib/canonical/launchpad/webapp/tests/login.txt 2010-02-25 13:13:19 +0000
120@@ -25,7 +25,7 @@
121
122 >>> print browser.title
123 Login
124- >>> from canonical.launchpad.webapp.tests.test_new_login import (
125+ >>> from canonical.launchpad.webapp.tests.test_login import (
126 ... fill_login_form_and_submit)
127 >>> fill_login_form_and_submit(browser, 'test@canonical.com', 'test')
128
129
130=== modified file 'lib/canonical/launchpad/webapp/tests/no-anonymous-session-cookies.txt'
131--- lib/canonical/launchpad/webapp/tests/no-anonymous-session-cookies.txt 2010-02-17 19:40:41 +0000
132+++ lib/canonical/launchpad/webapp/tests/no-anonymous-session-cookies.txt 2010-02-25 13:13:19 +0000
133@@ -25,7 +25,7 @@
134 <html>...<body onload="document.forms[0].submit();"...
135 >>> browser.getControl('Continue').click()
136
137- >>> from canonical.launchpad.webapp.tests.test_new_login import (
138+ >>> from canonical.launchpad.webapp.tests.test_login import (
139 ... fill_login_form_and_submit)
140 >>> fill_login_form_and_submit(browser, 'foo.bar@canonical.com', 'test')
141 >>> print extract_text(find_tag_by_id(browser.contents, 'logincontrol'))
142
143=== renamed file 'lib/canonical/launchpad/webapp/tests/test_new_login.py' => 'lib/canonical/launchpad/webapp/tests/test_login.py'
144--- lib/canonical/launchpad/webapp/tests/test_new_login.py 2010-02-17 19:40:41 +0000
145+++ lib/canonical/launchpad/webapp/tests/test_login.py 2010-02-25 13:13:19 +0000
146@@ -1,24 +1,31 @@
147 # Copyright 2009 Canonical Ltd. All rights reserved.
148+from __future__ import with_statement
149
150+# pylint: disable-msg=W0105
151 """Test harness for running the new-login.txt tests."""
152
153 __metaclass__ = type
154
155 __all__ = []
156
157+from contextlib import contextmanager
158 import httplib
159 import unittest
160
161 import mechanize
162
163 from openid.consumer.consumer import FAILURE, SUCCESS
164-
165-from canonical.launchpad.interfaces.account import AccountStatus
166+from openid.extensions import sreg
167+
168+from zope.component import getUtility
169+from zope.security.proxy import removeSecurityProxy
170+
171+from canonical.launchpad.interfaces.account import AccountStatus, IAccountSet
172 from canonical.launchpad.testing.pages import (
173 extract_text, find_main_content, find_tag_by_id, find_tags_by_class)
174 from canonical.launchpad.testing.systemdocs import LayeredDocFileSuite
175 from canonical.launchpad.testing.browser import Browser, setUp, tearDown
176-from canonical.launchpad.webapp.login import OpenIDCallbackView
177+from canonical.launchpad.webapp.login import OpenIDCallbackView, OpenIDLogin
178 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
179 from canonical.testing.layers import AppServerLayer, DatabaseFunctionalLayer
180
181@@ -29,10 +36,13 @@
182
183 class FakeOpenIDResponse:
184
185- def __init__(self, identity_url, status=SUCCESS, message=''):
186+ def __init__(self, identity_url, status=SUCCESS, message='', email=None,
187+ full_name=None):
188 self.message = message
189 self.status = status
190 self.identity_url = identity_url
191+ self.sreg_email = email
192+ self.sreg_fullname = full_name
193
194
195 class StubbedOpenIDCallbackView(OpenIDCallbackView):
196@@ -42,25 +52,47 @@
197 self.login_called = True
198
199
200+@contextmanager
201+def SRegResponse_fromSuccessResponse_stubbed():
202+ def sregFromFakeSuccessResponse(cls, success_response, signed_only=True):
203+ return {'email': success_response.sreg_email,
204+ 'fullname': success_response.sreg_fullname}
205+
206+ orig_method = sreg.SRegResponse.fromSuccessResponse
207+ # Use a stub SRegResponse.fromSuccessResponse that works with
208+ # FakeOpenIDResponses instead of real ones.
209+ sreg.SRegResponse.fromSuccessResponse = classmethod(
210+ sregFromFakeSuccessResponse)
211+
212+ yield
213+
214+ sreg.SRegResponse.fromSuccessResponse = orig_method
215+
216+
217 class TestOpenIDCallbackView(TestCaseWithFactory):
218 layer = DatabaseFunctionalLayer
219
220- def _createView(self, account, response_status=SUCCESS, response_msg=''):
221+ def _createViewWithResponse(
222+ self, account, response_status=SUCCESS, response_msg=''):
223+ openid_response = FakeOpenIDResponse(
224+ ITestOpenIDPersistentIdentity(account).openid_identity_url,
225+ status=response_status, message=response_msg)
226+ return self._createView(openid_response)
227+
228+ def _createView(self, response):
229 request = LaunchpadTestRequest(
230 form={'starting_url': 'http://launchpad.dev/after-login'},
231 environ={'PATH_INFO': '/'})
232 view = StubbedOpenIDCallbackView(object(), request)
233 view.initialize()
234- view.openid_response = FakeOpenIDResponse(
235- ITestOpenIDPersistentIdentity(account).openid_identity_url,
236- status=response_status, message=response_msg)
237+ view.openid_response = response
238 return view
239
240 def test_full_fledged_account(self):
241 # In the common case we just login and redirect to the URL specified
242 # in the 'starting_url' query arg.
243 person = self.factory.makePerson()
244- view = self._createView(person.account)
245+ view = self._createViewWithResponse(person.account)
246 view.render()
247 self.assertTrue(view.login_called)
248 response = view.request.response
249@@ -73,7 +105,7 @@
250 # create one.
251 account = self.factory.makeAccount('Test account')
252 self.assertIs(None, IPerson(account, None))
253- view = self._createView(account)
254+ view = self._createViewWithResponse(account)
255 view.render()
256 self.assertIsNot(None, IPerson(account, None))
257 self.assertTrue(view.login_called)
258@@ -82,13 +114,38 @@
259 self.assertEquals(view.request.form['starting_url'],
260 response.getHeader('Location'))
261
262+ def test_unseen_identity(self):
263+ # When we get a positive assertion about an identity URL we've never
264+ # seen, we automatically register an account with that identity
265+ # because someone who registered on login.lp.net or login.u.c should
266+ # be able to login here without any further steps.
267+ identifier = '4w7kmzU'
268+ account_set = getUtility(IAccountSet)
269+ self.assertRaises(
270+ LookupError, account_set.getByOpenIDIdentifier, identifier)
271+ openid_response = FakeOpenIDResponse(
272+ 'http://testopenid.dev/+id/%s' % identifier, status=SUCCESS,
273+ email='non-existent@example.com', full_name='Foo User')
274+ view = self._createView(openid_response)
275+ with SRegResponse_fromSuccessResponse_stubbed():
276+ view.render()
277+ self.assertTrue(view.login_called)
278+ account = account_set.getByOpenIDIdentifier(identifier)
279+ self.assertIsNot(None, account)
280+ self.assertEquals(AccountStatus.ACTIVE, account.status)
281+ self.assertEquals('non-existent@example.com',
282+ removeSecurityProxy(account.preferredemail).email)
283+ person = IPerson(account, None)
284+ self.assertIsNot(None, person)
285+ self.assertEquals('Foo User', person.displayname)
286+
287 def test_deactivated_account(self):
288 # The user has the account's password and is trying to login, so we'll
289 # just re-activate their account.
290 account = self.factory.makeAccount(
291 'Test account', status=AccountStatus.DEACTIVATED)
292 self.assertIs(None, IPerson(account, None))
293- view = self._createView(account)
294+ view = self._createViewWithResponse(account)
295 view.render()
296 self.assertIsNot(None, IPerson(account, None))
297 self.assertTrue(view.login_called)
298@@ -102,7 +159,7 @@
299 # login, but we must not allow that.
300 account = self.factory.makeAccount(
301 'Test account', status=AccountStatus.SUSPENDED)
302- view = self._createView(account)
303+ view = self._createViewWithResponse(account)
304 html = view.render()
305 self.assertFalse(view.login_called)
306 main_content = extract_text(find_main_content(html))
307@@ -112,7 +169,7 @@
308 # The OpenID provider responded with a negative assertion, so the
309 # login error page is shown.
310 account = self.factory.makeAccount('Test account')
311- view = self._createView(
312+ view = self._createViewWithResponse(
313 account, response_status=FAILURE,
314 response_msg='Server denied check_authentication')
315 html = view.render()
316@@ -139,6 +196,14 @@
317 handler_classes['_redirect'] = MyHTTPRedirectHandler
318
319
320+def fill_login_form_and_submit(browser, email_address, password):
321+ assert browser.getControl(name='field.email') is not None, (
322+ "We don't seem to be looking at a login form.")
323+ browser.getControl(name='field.email').value = email_address
324+ browser.getControl(name='field.password').value = password
325+ browser.getControl('Continue').click()
326+
327+
328 class TestOpenIDReplayAttack(TestCaseWithFactory):
329 layer = AppServerLayer
330
331@@ -172,12 +237,51 @@
332 extract_text(error_msg))
333
334
335-def fill_login_form_and_submit(browser, email_address, password):
336- assert browser.getControl(name='field.email') is not None, (
337- "We don't seem to be looking at a login form.")
338- browser.getControl(name='field.email').value = email_address
339- browser.getControl(name='field.password').value = password
340- browser.getControl('Continue').click()
341+class FakeOpenIDRequest:
342+ extensions = None
343+
344+ def addExtension(self, extension):
345+ if self.extensions is None:
346+ self.extensions = [extension]
347+ else:
348+ self.extensions.append(extension)
349+
350+ def shouldSendRedirect(self):
351+ return False
352+
353+ def htmlMarkup(self, trust_root, return_to):
354+ return None
355+
356+
357+class FakeOpenIDConsumer:
358+ def begin(self, url):
359+ return FakeOpenIDRequest()
360+
361+
362+class StubbedOpenIDLogin(OpenIDLogin):
363+ def _getConsumer(self):
364+ return FakeOpenIDConsumer()
365+
366+
367+class TestOpenIDLogin(TestCaseWithFactory):
368+ layer = DatabaseFunctionalLayer
369+
370+ def test_sreg_fields(self):
371+ # We request the user's email address and Full Name (through the SREG
372+ # extension) to the OpenID provider so that we can automatically
373+ # register unseen OpenID identities.
374+ request = LaunchpadTestRequest()
375+ # This is a hack to make the request.getURL(1) call issued by the view
376+ # not raise an IndexError.
377+ request._app_names = ['foo']
378+ view = StubbedOpenIDLogin(object(), request)
379+ view()
380+ extensions = view.openid_request.extensions
381+ self.assertIsNot(None, extensions)
382+ sreg_extension = extensions[0]
383+ self.assertIsInstance(sreg_extension, sreg.SRegRequest)
384+ self.assertEquals(['email', 'fullname'],
385+ sorted(sreg_extension.allRequestedFields()))
386
387
388 def test_suite():