Merge lp:~cprov/canonical-identity-provider/username-edit-api into lp:canonical-identity-provider/release

Proposed by Celso Providelo
Status: Merged
Approved by: Celso Providelo
Approved revision: no longer in the source branch.
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: lp:~cprov/canonical-identity-provider/username-edit-api
Merge into: lp:canonical-identity-provider/release
Diff against target: 348 lines (+208/-21)
4 files modified
src/api/v20/handlers.py (+19/-10)
src/api/v20/tests/test_handlers.py (+112/-0)
src/identityprovider/forms.py (+46/-3)
src/identityprovider/tests/test_forms.py (+31/-8)
To merge this branch: bzr merge lp:~cprov/canonical-identity-provider/username-edit-api
Reviewer Review Type Date Requested Status
Daniel Manrique (community) Approve
Review via email: mp+365792@code.launchpad.net

Commit message

Allow username edit via privileged API.

Description of the change

Both account endpoints (by openID and email) allow username editing via PATCH, which already requests privileged permissions.

SCA will be able to update an account username across the board (SSO -> LP) when it is necessary.

This is part of the journey described in https://trello.com/c/xPMusmTk/234-bug-1790874-cant-verify-accounts-with-a-dot-in-the-store-username

To post a comment you must log in.
Revision history for this message
Daniel Manrique (roadmr) wrote :

LGTM, a couple of minor things below.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/api/v20/handlers.py'
2--- src/api/v20/handlers.py 2019-01-15 17:32:45 +0000
3+++ src/api/v20/handlers.py 2019-04-10 16:16:36 +0000
4@@ -34,7 +34,7 @@
5 from api.v20.whitelist import URL_WHITELIST
6 from identityprovider import auth, emailutils
7 from identityprovider.fields import FIELD_REQUIRED
8-from identityprovider.forms import AccountStatusForm
9+from identityprovider.forms import AccountManageForm
10 from identityprovider.login import (
11 AccountDeactivated,
12 AccountSuspended,
13@@ -60,7 +60,10 @@
14 from identityprovider.signals import login_failed, login_succeeded
15 from identityprovider.stats import stats
16 from identityprovider.utils import redirection_url_for_token
17-from webservices.launchpad import get_lp_ssh_keys
18+from webservices.launchpad import (
19+ LaunchpadAPIError,
20+ get_lp_ssh_keys,
21+)
22
23
24 logger = logging.getLogger(__name__)
25@@ -149,16 +152,19 @@
26 @authorization_required(edit=True)
27 @require_mime('json')
28 @add_piston_http_patch_support
29- @validate(AccountStatusForm, 'data')
30+ @validate(AccountManageForm, 'data')
31 @throttle(extra_callback=get_openid_from_args)
32 def patch(self, request, openid):
33 account = Account.objects.active_by_openid(openid)
34 if account is None:
35 return errors.RESOURCE_NOT_FOUND()
36
37- account.status = request.form.cleaned_data['status']
38- account.status_comment = request.form.cleaned_data['status_comment']
39- account.save()
40+ try:
41+ request.form.update_account(account)
42+ except LaunchpadAPIError as err:
43+ return errors.INVALID_DATA(
44+ new_style=True, username=[str(err)])
45+
46 return get_account_data(account)
47
48
49@@ -593,16 +599,19 @@
50 @authorization_required(edit=True)
51 @require_mime('json')
52 @add_piston_http_patch_support
53- @validate(AccountStatusForm, 'data')
54+ @validate(AccountManageForm, 'data')
55 @throttle(extra_callback=lambda request, email: valid_email(email))
56 def patch(self, request, email):
57 account = Account.objects.get_by_email(email)
58 if account is None:
59 return errors.RESOURCE_NOT_FOUND()
60
61- account.status = request.form.cleaned_data['status']
62- account.status_comment = request.form.cleaned_data['status_comment']
63- account.save()
64+ try:
65+ request.form.update_account(account)
66+ except LaunchpadAPIError as err:
67+ return errors.INVALID_DATA(
68+ new_style=True, username=[str(err)])
69+
70 return get_account_data(account)
71
72
73
74=== modified file 'src/api/v20/tests/test_handlers.py'
75--- src/api/v20/tests/test_handlers.py 2019-01-15 17:32:45 +0000
76+++ src/api/v20/tests/test_handlers.py 2019-04-10 16:16:36 +0000
77@@ -28,6 +28,7 @@
78 from api.v20 import handlers, whitelist
79 from api.v20.utils import install_error_format_checker
80 from identityprovider.auth import build_discharge_macaroon
81+from identityprovider.forms import INVALID_NAME
82 from identityprovider.login import PasswordPolicyError
83 from identityprovider.models import (
84 Account,
85@@ -164,6 +165,23 @@
86 self.assertEqual(json_body['code'], 'SERVER_ERROR')
87 self.assertEqual(json_body['extra'], {})
88
89+ def patch_lp_response(self, code=200, body=None):
90+
91+ class FakeResponse():
92+ def __init__(self, code, body):
93+ self.code = code
94+ self.body = body
95+
96+ def read(self):
97+ return self.body
98+
99+ if body is None:
100+ body = json.dumps('not-specified')
101+
102+ self.patch(
103+ 'webservices.launchpad.http_request_with_timeout',
104+ return_value=FakeResponse(code, body))
105+
106
107 class AccountsHandlerTestCase(BaseTestCase):
108 url_name = 'api-account'
109@@ -252,6 +270,53 @@
110 response.get('message', ''), 'Invalid request data')
111 self.assertEqual(response.get('code', ''), 'INVALID_DATA')
112
113+ def test_patch_username_invalid(self):
114+ body = self.do_patch(
115+ {'username': 'foo.bar'}, token=self.super_token, status_code=400)
116+ self.assertEqual(body['message'], 'Invalid request data')
117+ self.assertEqual(body['code'], 'INVALID_DATA')
118+ self.assertEqual(body['extra'], {'username': [INVALID_NAME]})
119+
120+ @override_settings(LP_API_URL='test.com')
121+ def test_patch_username_valid(self):
122+ old_status = self.account.status
123+ old_comment = self.account.status_comment
124+
125+ self.patch_lp_response(body=json.dumps('foo-bar'))
126+ self.do_patch({'username': 'foo-bar'}, token=self.super_token)
127+
128+ self.account.refresh_from_db()
129+ self.assertTrue(self.account.need_username_refresh)
130+ self.assertEqual(self.account.username, 'foo-bar')
131+ self.assertEqual(self.account.status, old_status)
132+ self.assertEqual(self.account.status_comment, old_comment)
133+
134+ @override_settings(LP_API_URL='test.com')
135+ def test_patch_username_unavailable(self):
136+ lp_body = 'name: foo-bar is already in use ...'
137+ self.patch_lp_response(code=400, body=lp_body)
138+ body = self.do_patch(
139+ {'username': 'foo-bar'}, token=self.super_token, status_code=400)
140+ self.assertEqual(body['message'], 'Invalid request data')
141+ self.assertEqual(body['code'], 'INVALID_DATA')
142+ self.assertEqual(body['extra'], {'username': [lp_body]})
143+
144+ @override_settings(LP_API_URL='test.com')
145+ def test_patch_username_and_status(self):
146+ assert self.account.status != AccountStatus.SUSPENDED
147+ self.patch_lp_response(body=json.dumps('foo-bar'))
148+ self.do_patch({
149+ 'username': 'foo-bar',
150+ 'status': 'SUSPENDED',
151+ 'status_comment': 'BAD!',
152+ }, token=self.super_token)
153+
154+ self.account.refresh_from_db()
155+ self.assertTrue(self.account.need_username_refresh)
156+ self.assertEqual(self.account.username, 'foo-bar')
157+ self.assertEqual(self.account.status, AccountStatus.SUSPENDED)
158+ self.assertEqual(self.account.status_comment, 'BAD!')
159+
160 def test_authenticated_openid_no_match(self):
161 assert self.account.openid_identifier != 'foo'
162 url = reverse(self.url_name, args=('foo',))
163@@ -853,6 +918,53 @@
164 response.get('message', ''), 'Invalid request data')
165 self.assertEqual(response.get('code', ''), 'INVALID_DATA')
166
167+ def test_patch_username_invalid(self):
168+ body = self.do_patch(
169+ {'username': 'foo.bar'}, token=self.super_token, status_code=400)
170+ self.assertEqual(body['message'], 'Invalid request data')
171+ self.assertEqual(body['code'], 'INVALID_DATA')
172+ self.assertEqual(body['extra'], {'username': [INVALID_NAME]})
173+
174+ @override_settings(LP_API_URL='test.com')
175+ def test_patch_username_valid(self):
176+ old_status = self.account.status
177+ old_comment = self.account.status_comment
178+
179+ self.patch_lp_response(body=json.dumps('foo-bar'))
180+ self.do_patch({'username': 'foo-bar'}, token=self.super_token)
181+
182+ self.account.refresh_from_db()
183+ self.assertTrue(self.account.need_username_refresh)
184+ self.assertEqual(self.account.username, 'foo-bar')
185+ self.assertEqual(self.account.status, old_status)
186+ self.assertEqual(self.account.status_comment, old_comment)
187+
188+ @override_settings(LP_API_URL='test.com')
189+ def test_patch_username_unavailable(self):
190+ lp_body = 'name: foo-bar is already in use ...'
191+ self.patch_lp_response(code=400, body=lp_body)
192+ body = self.do_patch(
193+ {'username': 'foo-bar'}, token=self.super_token, status_code=400)
194+ self.assertEqual(body['message'], 'Invalid request data')
195+ self.assertEqual(body['code'], 'INVALID_DATA')
196+ self.assertEqual(body['extra'], {'username': [lp_body]})
197+
198+ @override_settings(LP_API_URL='test.com')
199+ def test_patch_username_and_status(self):
200+ assert self.account.status != AccountStatus.SUSPENDED
201+ self.patch_lp_response(body=json.dumps('foo-bar'))
202+ self.do_patch({
203+ 'username': 'foo-bar',
204+ 'status': 'SUSPENDED',
205+ 'status_comment': 'BAD!',
206+ }, token=self.super_token)
207+
208+ self.account.refresh_from_db()
209+ self.assertTrue(self.account.need_username_refresh)
210+ self.assertEqual(self.account.username, 'foo-bar')
211+ self.assertEqual(self.account.status, AccountStatus.SUSPENDED)
212+ self.assertEqual(self.account.status_comment, 'BAD!')
213+
214 def test_can_get_others_if_elevated_permission(self):
215 other_email = self.factory.make_account().preferredemail
216 self.factory.add_permission_to_account(
217
218=== modified file 'src/identityprovider/forms.py'
219--- src/identityprovider/forms.py 2019-04-03 16:52:44 +0000
220+++ src/identityprovider/forms.py 2019-04-10 16:16:36 +0000
221@@ -653,15 +653,58 @@
222 name = fields.CharField()
223
224
225-class AccountStatusForm(Form):
226- status = fields.ChoiceField(choices=[(attr, attr)
227- for attr in AccountStatus.enum()])
228+class AccountManageForm(Form):
229+
230+ username = UsernameField(required=False)
231+ status = fields.ChoiceField(
232+ choices=[(attr, attr) for attr in AccountStatus.enum()])
233 status_comment = fields.CharField()
234
235+ def __init__(self, *args, **kwargs):
236+ super(AccountManageForm, self).__init__(*args, **kwargs)
237+ # Allow `username` to be modified in isolation.
238+ if 'username' in self.data.keys():
239+ self.fields['status'].required = False
240+ self.fields['status_comment'].required = False
241+ if 'status' in self.data.keys():
242+ self.fields['status_comment'].required = True
243+
244+ def clean_username(self):
245+ if 'username' not in self.cleaned_data:
246+ return None
247+ username = self.cleaned_data['username'].strip()
248+ if not username:
249+ return ''
250+ if not VALID_NAME_RE.match(username):
251+ raise forms.ValidationError(INVALID_NAME)
252+ return username
253+
254 def clean_status(self):
255 status = self.cleaned_data['status']
256+ if not status:
257+ return None
258 return getattr(AccountStatus, status)
259
260+ def update_account(self, account):
261+ """Helper for API handlers to update the account and LP.
262+
263+ Raises `LaunchpadAPIError` in case of LP error or failure.
264+ """
265+ status = self.cleaned_data.get('status')
266+ if status:
267+ account.status = status
268+
269+ status_comment = self.cleaned_data.get('status_comment')
270+ if status_comment:
271+ account.status_comment = status_comment
272+
273+ username = self.cleaned_data.get('username')
274+ if username and username != account.person_name:
275+ set_lp_username(account.openid_identifier, username)
276+ account.need_username_refresh = True
277+
278+ account.save()
279+
280
281 class MacaroonRequestForm(Form):
282 """A form object for user control over requesting discharge macaroons."""
283
284=== modified file 'src/identityprovider/tests/test_forms.py'
285--- src/identityprovider/tests/test_forms.py 2019-04-03 16:52:44 +0000
286+++ src/identityprovider/tests/test_forms.py 2019-04-10 16:16:36 +0000
287@@ -37,7 +37,7 @@
288 from identityprovider.forms import (
289 INVALID_NAME,
290 VALID_NAME_RE,
291- AccountStatusForm,
292+ AccountManageForm,
293 DeviceRenameForm,
294 EditAccountForm,
295 GenericEmailForm,
296@@ -1367,16 +1367,16 @@
297 self.assertEqual(form.cleaned_data['name'], 'bar')
298
299
300-class AccountStatusFormTestCase(FormBaseTestCase):
301- fields = ['status', 'status_comment']
302+class AccountManageFormTestCase(FormBaseTestCase):
303+ fields = ['status', 'status_comment', 'username']
304
305 def get_form(self, **kwargs):
306- return AccountStatusForm(**kwargs)
307+ return AccountManageForm(**kwargs)
308
309- def test_required_fields(self):
310- form = self.get_form()
311- for field in self.fields:
312- self.assertTrue(form.fields[field].required)
313+ def test_comment_required_when_status_given(self):
314+ data = {'status': 'ACTIVE', 'status_comment': 'Wow!'}
315+ form = self.get_form(data=data)
316+ self.assertTrue(form.fields['status_comment'].required)
317
318 def test_status_field_valid(self):
319 values = [(attr, 'Test comment.') for attr in AccountStatus.enum()]
320@@ -1396,6 +1396,29 @@
321 form = self.get_form(data=data)
322 self.assertFalse(form.is_valid())
323
324+ def test_username_field_empty(self):
325+ empty_values = (
326+ {'username': ''},
327+ {'username': ' '},
328+ {'username': None},
329+ {'status': 'ACTIVE', 'status_comment': 'Wow!'},
330+ )
331+ for data in empty_values:
332+ form = self.get_form(data=data)
333+ self.assertTrue(form.is_valid(), form.errors)
334+ self.assertEqual(form.cleaned_data['username'], '')
335+
336+ def test_username_field_valid(self):
337+ data = {'username': 'foo-bar'}
338+ form = self.get_form(data=data)
339+ self.assertTrue(form.is_valid())
340+
341+ def test_username_field_invalid(self):
342+ data = {'username': 'no+pluses'}
343+ form = self.get_form(data=data)
344+ self.assertFalse(form.is_valid())
345+ self.assertEqual(form.errors['username'], [INVALID_NAME])
346+
347
348 class MacaroonRequestFormTestCase(SSOBaseTestCase):
349