Merge lp:~wgrant/launchpad/invisible-person-api into lp:launchpad

Proposed by William Grant
Status: Merged
Merged at revision: 18019
Proposed branch: lp:~wgrant/launchpad/invisible-person-api
Merge into: lp:launchpad
Prerequisite: lp:~wgrant/launchpad/invisible-person
Diff against target: 539 lines (+365/-21)
5 files modified
lib/lp/registry/browser/tests/test_person_webservice.py (+115/-1)
lib/lp/registry/errors.py (+6/-0)
lib/lp/registry/interfaces/person.py (+45/-0)
lib/lp/registry/model/person.py (+39/-3)
lib/lp/registry/tests/test_personset.py (+160/-17)
To merge this branch: bzr merge lp:~wgrant/launchpad/invisible-person-api
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+292939@code.launchpad.net

Commit message

Add getUsernameForSSO and setUsernameFromSSO API methods.

Description of the change

Add getUsernameForSSO and setUsernameFromSSO API methods.

SSO will soon allow creation of usernames for users without Launchpad accounts. This is implemented with invisible Person rows with Account.status == PLACEHOLDER.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
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/tests/test_person_webservice.py'
2--- lib/lp/registry/browser/tests/test_person_webservice.py 2016-03-21 05:37:40 +0000
3+++ lib/lp/registry/browser/tests/test_person_webservice.py 2016-05-03 23:49:59 +0000
4@@ -13,7 +13,10 @@
5 TeamMembershipStatus,
6 )
7 from lp.registry.interfaces.teammembership import ITeamMembershipSet
8-from lp.services.identity.interfaces.account import AccountStatus
9+from lp.services.identity.interfaces.account import (
10+ AccountStatus,
11+ IAccountSet,
12+ )
13 from lp.services.openid.model.openididentifier import OpenIdIdentifier
14 from lp.services.webapp import snapshot
15 from lp.services.webapp.interfaces import OAuthPermission
16@@ -367,3 +370,114 @@
17 sca = getUtility(IPersonSet).getByName('software-center-agent')
18 response = self.getOrCreateSoftwareCenterCustomer(sca)
19 self.assertEqual(400, response.status)
20+
21+ def test_getUsernameForSSO(self):
22+ # canonical-identity-provider (SSO) can get the username for an
23+ # OpenID identifier suffix.
24+ with admin_logged_in():
25+ sso = getUtility(IPersonSet).getByName('ubuntu-sso')
26+ existing = self.factory.makePerson(name='username')
27+ taken_openid = (
28+ existing.account.openid_identifiers.any().identifier)
29+ webservice = webservice_for_person(
30+ sso, permission=OAuthPermission.READ_PUBLIC)
31+ response = webservice.named_get(
32+ '/people', 'getUsernameForSSO',
33+ openid_identifier=taken_openid, api_version='devel')
34+ self.assertEqual(200, response.status)
35+ self.assertEqual('username', response.jsonBody())
36+
37+ def test_getUsernameForSSO_nonexistent(self):
38+ with admin_logged_in():
39+ sso = getUtility(IPersonSet).getByName('ubuntu-sso')
40+ webservice = webservice_for_person(
41+ sso, permission=OAuthPermission.READ_PUBLIC)
42+ response = webservice.named_get(
43+ '/people', 'getUsernameForSSO',
44+ openid_identifier='doesnotexist', api_version='devel')
45+ self.assertEqual(200, response.status)
46+ self.assertEqual(None, response.jsonBody())
47+
48+ def setUsernameFromSSO(self, user, openid_identifier, name,
49+ dry_run=False):
50+ webservice = webservice_for_person(
51+ user, permission=OAuthPermission.WRITE_PRIVATE)
52+ response = webservice.named_post(
53+ '/people', 'setUsernameFromSSO',
54+ openid_identifier=openid_identifier, name=name, dry_run=dry_run,
55+ api_version='devel')
56+ return response
57+
58+ def test_setUsernameFromSSO(self):
59+ # canonical-identity-provider (SSO) can create a placeholder
60+ # Person to give a username to a non-LP user.
61+ with admin_logged_in():
62+ sso = getUtility(IPersonSet).getByName('ubuntu-sso')
63+ response = self.setUsernameFromSSO(sso, 'foo', 'bar')
64+ self.assertEqual(200, response.status)
65+ with admin_logged_in():
66+ by_name = getUtility(IPersonSet).getByName('bar')
67+ by_openid = getUtility(IPersonSet).getByOpenIDIdentifier(
68+ u'http://testopenid.dev/+id/foo')
69+ self.assertEqual(by_name, by_openid)
70+ self.assertEqual(
71+ AccountStatus.PLACEHOLDER, by_name.account_status)
72+
73+ def test_setUsernameFromSSO_dry_run(self):
74+ # setUsernameFromSSO provides a dry run mode that performs all
75+ # the checks but doesn't actually make changes. Useful for input
76+ # validation in SSO.
77+ with admin_logged_in():
78+ sso = getUtility(IPersonSet).getByName('ubuntu-sso')
79+ response = self.setUsernameFromSSO(sso, 'foo', 'bar', dry_run=True)
80+ self.assertEqual(200, response.status)
81+ with admin_logged_in():
82+ self.assertIs(None, getUtility(IPersonSet).getByName('bar'))
83+ self.assertRaises(
84+ LookupError,
85+ getUtility(IAccountSet).getByOpenIDIdentifier, u'foo')
86+
87+ def test_setUsernameFromSSO_is_restricted(self):
88+ # The method may only be invoked by the ~ubuntu-sso celebrity
89+ # user, as it is security-sensitive.
90+ with admin_logged_in():
91+ random = self.factory.makePerson()
92+ response = self.setUsernameFromSSO(random, 'foo', 'bar')
93+ self.assertEqual(401, response.status)
94+
95+ def test_setUsernameFromSSO_rejects_bad_input(self, dry_run=False):
96+ # The method returns meaningful errors on bad input, so SSO can
97+ # give advice to users.
98+ # Check canonical-identity-provider before changing these!
99+ with admin_logged_in():
100+ sso = getUtility(IPersonSet).getByName('ubuntu-sso')
101+ self.factory.makePerson(name='taken-name')
102+ existing = self.factory.makePerson()
103+ taken_openid = (
104+ existing.account.openid_identifiers.any().identifier)
105+
106+ response = self.setUsernameFromSSO(
107+ sso, 'foo', 'taken-name', dry_run=dry_run)
108+ self.assertEqual(400, response.status)
109+ self.assertEqual(
110+ 'name: taken-name is already in use by another person or team.',
111+ response.body)
112+
113+ response = self.setUsernameFromSSO(
114+ sso, 'foo', 'private-name', dry_run=dry_run)
115+ self.assertEqual(400, response.status)
116+ self.assertEqual(
117+ 'name: The name 'private-name' has been blocked by the '
118+ 'Launchpad administrators. Contact Launchpad Support if you want '
119+ 'to use this name.',
120+ response.body)
121+
122+ response = self.setUsernameFromSSO(
123+ sso, taken_openid, 'bar', dry_run=dry_run)
124+ self.assertEqual(400, response.status)
125+ self.assertEqual(
126+ 'An account for that OpenID identifier already exists.',
127+ response.body)
128+
129+ def test_setUsernameFromSSO_rejects_bad_input_in_dry_run(self):
130+ self.test_setUsernameFromSSO_rejects_bad_input(dry_run=True)
131
132=== modified file 'lib/lp/registry/errors.py'
133--- lib/lp/registry/errors.py 2012-11-28 21:32:06 +0000
134+++ lib/lp/registry/errors.py 2016-05-03 23:49:59 +0000
135@@ -21,6 +21,7 @@
136 'NameAlreadyTaken',
137 'NoSuchDistroSeries',
138 'NoSuchSourcePackageName',
139+ 'NotPlaceholderAccount',
140 'InclusiveTeamLinkageError',
141 'PPACreationError',
142 'PrivatePersonLinkageError',
143@@ -61,6 +62,11 @@
144
145
146 @error_status(httplib.BAD_REQUEST)
147+class NotPlaceholderAccount(Exception):
148+ """A non-placeholder account already exists for that OpenID identifier."""
149+
150+
151+@error_status(httplib.BAD_REQUEST)
152 class InvalidFilename(Exception):
153 """An invalid filename was used as an attachment filename."""
154
155
156=== modified file 'lib/lp/registry/interfaces/person.py'
157--- lib/lp/registry/interfaces/person.py 2016-04-11 06:38:48 +0000
158+++ lib/lp/registry/interfaces/person.py 2016-05-03 23:49:59 +0000
159@@ -2223,6 +2223,51 @@
160 :param full_name: the full name of the user.
161 """
162
163+ @call_with(user=REQUEST_USER)
164+ @operation_parameters(
165+ openid_identifier=TextLine(
166+ title=_("OpenID identifier suffix"), required=True))
167+ @export_read_operation()
168+ @operation_for_version("devel")
169+ def getUsernameForSSO(user, openid_identifier):
170+ """Restricted person creation API for SSO.
171+
172+ This method can only be called by the Ubuntu SSO service. It
173+ finds the username for an account by OpenID identifier.
174+
175+ :param user: the `IPerson` performing the operation. Only the
176+ ubuntu-sso celebrity is allowed.
177+ :param openid_identifier: OpenID identifier suffix for the user.
178+ This is *not* the full URL, just the unique suffix portion.
179+ """
180+
181+ @call_with(user=REQUEST_USER)
182+ @operation_parameters(
183+ openid_identifier=TextLine(
184+ title=_("OpenID identifier suffix"), required=True),
185+ name=copy_field(IPerson['name']),
186+ dry_run=Bool(_("Don't save changes")))
187+ @export_write_operation()
188+ @operation_for_version("devel")
189+ def setUsernameFromSSO(user, openid_identifier, name, dry_run=False):
190+ """Restricted person creation API for SSO.
191+
192+ This method can only be called by the Ubuntu SSO service. It
193+ reserves a username for an account by OpenID identifier, as long as
194+ the user has no Launchpad account.
195+
196+ :param user: the `IPerson` performing the operation. Only the
197+ ubuntu-sso celebrity is allowed.
198+ :param openid_identifier: OpenID identifier suffix for the user.
199+ This is *not* the full URL, just the unique suffix portion.
200+ :param name: the desired username.
201+ :raises: `InvalidName` if the username doesn't meet character
202+ constraints.
203+ :raises: `NameAlreadyTaken` if the username is already in use.
204+ :raises: `NotPlaceholderAccount` if the OpenID identifier has a
205+ non-placeholder Launchpad account.
206+ """
207+
208 @call_with(teamowner=REQUEST_USER)
209 @rename_parameters_as(
210 teamdescription='team_description',
211
212=== modified file 'lib/lp/registry/model/person.py'
213--- lib/lp/registry/model/person.py 2016-04-11 08:10:46 +0000
214+++ lib/lp/registry/model/person.py 2016-05-03 23:49:59 +0000
215@@ -154,6 +154,7 @@
216 InvalidName,
217 JoinNotAllowed,
218 NameAlreadyTaken,
219+ NotPlaceholderAccount,
220 PPACreationError,
221 TeamMembershipPolicyError,
222 )
223@@ -3429,6 +3430,38 @@
224 trust_email=False)
225 return person
226
227+ def getUsernameForSSO(self, user, openid_identifier):
228+ """See `IPersonSet`."""
229+ if user != getUtility(ILaunchpadCelebrities).ubuntu_sso:
230+ raise Unauthorized()
231+ try:
232+ account = getUtility(IAccountSet).getByOpenIDIdentifier(
233+ openid_identifier)
234+ except LookupError:
235+ return None
236+ return IPerson(account).name
237+
238+ def setUsernameFromSSO(self, user, openid_identifier, name,
239+ dry_run=False):
240+ """See `IPersonSet`."""
241+ if user != getUtility(ILaunchpadCelebrities).ubuntu_sso:
242+ raise Unauthorized()
243+ self._validateName(name)
244+ try:
245+ account = getUtility(IAccountSet).getByOpenIDIdentifier(
246+ openid_identifier)
247+ except LookupError:
248+ if not dry_run:
249+ person = self.createPlaceholderPerson(openid_identifier, name)
250+ else:
251+ if account.status != AccountStatus.PLACEHOLDER:
252+ raise NotPlaceholderAccount(
253+ "An account for that OpenID identifier already exists.")
254+ if not dry_run:
255+ account = removeSecurityProxy(account)
256+ person = IPerson(account)
257+ person.name = person.display_name = account.displayname = name
258+
259 def newTeam(self, teamowner, name, display_name, teamdescription=None,
260 membership_policy=TeamMembershipPolicy.MODERATED,
261 defaultmembershipperiod=None, defaultrenewalperiod=None,
262@@ -3507,9 +3540,7 @@
263 rationale=PersonCreationRationale.USERNAME_PLACEHOLDER,
264 comment="when setting a username in SSO", account=account)
265
266- def _newPerson(self, name, displayname, hide_email_addresses,
267- rationale, comment=None, registrant=None, account=None):
268- """Create and return a new Person with the given attributes."""
269+ def _validateName(self, name):
270 if not valid_name(name):
271 raise InvalidName(
272 "%s is not a valid name for a person." % name)
273@@ -3520,6 +3551,11 @@
274 raise NameAlreadyTaken(
275 "The name '%s' is already taken." % name)
276
277+ def _newPerson(self, name, displayname, hide_email_addresses,
278+ rationale, comment=None, registrant=None, account=None):
279+ """Create and return a new Person with the given attributes."""
280+ self._validateName(name)
281+
282 if not displayname:
283 displayname = name.capitalize()
284
285
286=== modified file 'lib/lp/registry/tests/test_personset.py'
287--- lib/lp/registry/tests/test_personset.py 2016-04-11 08:10:46 +0000
288+++ lib/lp/registry/tests/test_personset.py 2016-05-03 23:49:59 +0000
289@@ -17,6 +17,7 @@
290
291 from lp.code.tests.helpers import remove_all_sample_data_branches
292 from lp.registry.errors import (
293+ NotPlaceholderAccount,
294 InvalidName,
295 NameAlreadyTaken,
296 )
297@@ -41,6 +42,7 @@
298 AccountCreationRationale,
299 AccountStatus,
300 AccountSuspendedError,
301+ IAccountSet,
302 )
303 from lp.services.identity.interfaces.emailaddress import (
304 EmailAddressAlreadyTaken,
305@@ -67,6 +69,13 @@
306 from lp.testing.matchers import HasQueryCount
307
308
309+def make_openid_identifier(account, identifier):
310+ openid_identifier = OpenIdIdentifier()
311+ openid_identifier.identifier = identifier
312+ openid_identifier.account = account
313+ return IStore(OpenIdIdentifier).add(openid_identifier)
314+
315+
316 class TestPersonSet(TestCaseWithFactory):
317 """Test `IPersonSet`."""
318 layer = DatabaseFunctionalLayer
319@@ -248,7 +257,7 @@
320
321 # Generate some valid test data.
322 self.account = self.makeAccount()
323- self.identifier = self.makeOpenIdIdentifier(self.account, u'whatever')
324+ self.identifier = make_openid_identifier(self.account, u'whatever')
325 self.person = self.makePerson(self.account)
326 self.email = self.makeEmailAddress(
327 email='whatever@example.com', person=self.person)
328@@ -259,12 +268,6 @@
329 creation_rationale=AccountCreationRationale.UNKNOWN,
330 status=AccountStatus.ACTIVE))
331
332- def makeOpenIdIdentifier(self, account, identifier):
333- openid_identifier = OpenIdIdentifier()
334- openid_identifier.identifier = identifier
335- openid_identifier.account = account
336- return self.store.add(openid_identifier)
337-
338 def makePerson(self, account):
339 return self.store.add(Person(
340 name='acc%d' % account.id, account=account,
341@@ -627,12 +630,6 @@
342 super(TestPersonSetGetOrCreateSoftwareCenterCustomer, self).setUp()
343 self.sca = getUtility(IPersonSet).getByName('software-center-agent')
344
345- def makeOpenIdIdentifier(self, account, identifier):
346- openid_identifier = OpenIdIdentifier()
347- openid_identifier.identifier = identifier
348- openid_identifier.account = account
349- return IStore(OpenIdIdentifier).add(openid_identifier)
350-
351 def test_restricted_to_sca(self):
352 # Only the software-center-agent celebrity can invoke this
353 # privileged method.
354@@ -659,7 +656,7 @@
355 def test_finds_by_openid(self):
356 # A Person with the requested OpenID identifier is returned.
357 somebody = self.factory.makePerson()
358- self.makeOpenIdIdentifier(somebody.account, u'somebody')
359+ make_openid_identifier(somebody.account, u'somebody')
360 with person_logged_in(self.sca):
361 got = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
362 self.sca, u'somebody', 'somebody@example.com', 'Example')
363@@ -692,7 +689,7 @@
364 somebody = self.factory.makePerson(
365 email='existing@example.com',
366 account_status=AccountStatus.NOACCOUNT)
367- self.makeOpenIdIdentifier(somebody.account, u'somebody')
368+ make_openid_identifier(somebody.account, u'somebody')
369 self.assertEqual(AccountStatus.NOACCOUNT, somebody.account.status)
370 with person_logged_in(self.sca):
371 got = getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer(
372@@ -725,7 +722,7 @@
373 def test_fails_if_account_is_suspended(self):
374 # Suspended accounts cannot be returned.
375 somebody = self.factory.makePerson()
376- self.makeOpenIdIdentifier(somebody.account, u'somebody')
377+ make_openid_identifier(somebody.account, u'somebody')
378 with admin_logged_in():
379 somebody.setAccountStatus(
380 AccountStatus.SUSPENDED, None, "Go away!")
381@@ -740,7 +737,7 @@
382 # nor do we want to potentially compromise them with a bad email
383 # address.
384 somebody = self.factory.makePerson()
385- self.makeOpenIdIdentifier(somebody.account, u'somebody')
386+ make_openid_identifier(somebody.account, u'somebody')
387 with admin_logged_in():
388 somebody.setAccountStatus(
389 AccountStatus.DEACTIVATED, None, "Goodbye cruel world.")
390@@ -749,3 +746,149 @@
391 NameAlreadyTaken,
392 getUtility(IPersonSet).getOrCreateSoftwareCenterCustomer,
393 self.sca, u'somebody', 'somebody@example.com', 'Example')
394+
395+
396+class TestPersonGetUsernameForSSO(TestCaseWithFactory):
397+
398+ layer = DatabaseFunctionalLayer
399+
400+ def setUp(self):
401+ super(TestPersonGetUsernameForSSO, self).setUp()
402+ self.sso = getUtility(IPersonSet).getByName(u'ubuntu-sso')
403+
404+ def test_restricted_to_sca(self):
405+ # Only the ubuntu-sso celebrity can invoke this
406+ # privileged method.
407+ target = self.factory.makePerson(name='username')
408+ make_openid_identifier(target.account, u'openid')
409+
410+ def do_it():
411+ return getUtility(IPersonSet).getUsernameForSSO(
412+ getUtility(ILaunchBag).user, u'openid')
413+ random = self.factory.makePerson()
414+ admin = self.factory.makePerson(
415+ member_of=[getUtility(IPersonSet).getByName(u'admins')])
416+
417+ # Anonymous, random or admin users can't invoke the method.
418+ with anonymous_logged_in():
419+ self.assertRaises(Unauthorized, do_it)
420+ with person_logged_in(random):
421+ self.assertRaises(Unauthorized, do_it)
422+ with person_logged_in(admin):
423+ self.assertRaises(Unauthorized, do_it)
424+
425+ with person_logged_in(self.sso):
426+ self.assertEqual('username', do_it())
427+
428+
429+class TestPersonSetUsernameFromSSO(TestCaseWithFactory):
430+
431+ layer = DatabaseFunctionalLayer
432+
433+ def setUp(self):
434+ super(TestPersonSetUsernameFromSSO, self).setUp()
435+ self.sso = getUtility(IPersonSet).getByName(u'ubuntu-sso')
436+
437+ def test_restricted_to_sca(self):
438+ # Only the ubuntu-sso celebrity can invoke this
439+ # privileged method.
440+ def do_it():
441+ getUtility(IPersonSet).setUsernameFromSSO(
442+ getUtility(ILaunchBag).user, u'openid', u'username')
443+ random = self.factory.makePerson()
444+ admin = self.factory.makePerson(
445+ member_of=[getUtility(IPersonSet).getByName(u'admins')])
446+
447+ # Anonymous, random or admin users can't invoke the method.
448+ with anonymous_logged_in():
449+ self.assertRaises(Unauthorized, do_it)
450+ with person_logged_in(random):
451+ self.assertRaises(Unauthorized, do_it)
452+ with person_logged_in(admin):
453+ self.assertRaises(Unauthorized, do_it)
454+
455+ with person_logged_in(self.sso):
456+ do_it()
457+
458+ def test_creates_new_placeholder(self):
459+ # If an unknown OpenID identifier and email address are
460+ # provided, a new account is created with the given username and
461+ # returned.
462+ with person_logged_in(self.sso):
463+ getUtility(IPersonSet).setUsernameFromSSO(
464+ self.sso, u'openid', u'username')
465+ person = getUtility(IPersonSet).getByName(u'username')
466+ self.assertEqual(u'username', person.name)
467+ self.assertEqual(u'username', person.displayname)
468+ self.assertEqual(AccountStatus.PLACEHOLDER, person.account.status)
469+ with admin_logged_in():
470+ self.assertContentEqual(
471+ [u'openid'],
472+ [oid.identifier for oid in person.account.openid_identifiers])
473+ self.assertContentEqual([], person.validatedemails)
474+ self.assertContentEqual([], person.guessedemails)
475+
476+ def test_creates_new_placeholder_dry_run(self):
477+ with person_logged_in(self.sso):
478+ getUtility(IPersonSet).setUsernameFromSSO(
479+ self.sso, u'openid', u'username', dry_run=True)
480+ self.assertRaises(
481+ LookupError,
482+ getUtility(IAccountSet).getByOpenIDIdentifier, u'openid')
483+ self.assertIs(None, getUtility(IPersonSet).getByName(u'username'))
484+
485+ def test_updates_existing_placeholder(self):
486+ # An existing placeholder Person with the request OpenID
487+ # identifier has its name updated.
488+ getUtility(IPersonSet).setUsernameFromSSO(
489+ self.sso, u'openid', u'username')
490+ person = getUtility(IPersonSet).getByName(u'username')
491+
492+ # Another call for the same OpenID identifier updates the
493+ # existing Person.
494+ getUtility(IPersonSet).setUsernameFromSSO(
495+ self.sso, u'openid', u'newsername')
496+ self.assertEqual(u'newsername', person.name)
497+ self.assertEqual(u'newsername', person.displayname)
498+ self.assertEqual(AccountStatus.PLACEHOLDER, person.account.status)
499+ with admin_logged_in():
500+ self.assertContentEqual([], person.validatedemails)
501+ self.assertContentEqual([], person.guessedemails)
502+
503+ def test_updates_existing_placeholder_dry_run(self):
504+ getUtility(IPersonSet).setUsernameFromSSO(
505+ self.sso, u'openid', u'username')
506+ person = getUtility(IPersonSet).getByName(u'username')
507+
508+ getUtility(IPersonSet).setUsernameFromSSO(
509+ self.sso, u'openid', u'newsername', dry_run=True)
510+ self.assertEqual(u'username', person.name)
511+
512+ def test_validation(self, dry_run=False):
513+ # An invalid username is rejected with an InvalidName exception.
514+ self.assertRaises(
515+ InvalidName,
516+ getUtility(IPersonSet).setUsernameFromSSO,
517+ self.sso, u'openid', u'username!!', dry_run=dry_run)
518+ transaction.abort()
519+
520+ # A username that's already in use is rejected with a
521+ # NameAlreadyTaken exception.
522+ self.factory.makePerson(name='taken')
523+ self.assertRaises(
524+ NameAlreadyTaken,
525+ getUtility(IPersonSet).setUsernameFromSSO,
526+ self.sso, u'openid', u'taken', dry_run=dry_run)
527+ transaction.abort()
528+
529+ # setUsernameFromSSO can't be used to set an OpenID
530+ # identifier's username if a non-placeholder account exists.
531+ somebody = self.factory.makePerson()
532+ make_openid_identifier(somebody.account, u'openid-taken')
533+ self.assertRaises(
534+ NotPlaceholderAccount,
535+ getUtility(IPersonSet).setUsernameFromSSO,
536+ self.sso, u'openid-taken', u'username', dry_run=dry_run)
537+
538+ def test_validation_dry_run(self):
539+ self.test_validation(dry_run=True)