Merge lp:~canonical-isd-hackers/canonical-identity-provider/ssoclient into lp:~ubuntuone-pqm-team/canonical-identity-provider/ssoclient

Proposed by Natalia Bidart
Status: Merged
Approved by: Martin Albisetti
Approved revision: no longer in the source branch.
Merged at revision: 9
Proposed branch: lp:~canonical-isd-hackers/canonical-identity-provider/ssoclient
Merge into: lp:~ubuntuone-pqm-team/canonical-identity-provider/ssoclient
Diff against target: 548 lines (+279/-102)
3 files modified
ssoclient/tests/test_v2.py (+229/-86)
ssoclient/v2/client.py (+46/-16)
ssoclient/v2/errors.py (+4/-0)
To merge this branch: bzr merge lp:~canonical-isd-hackers/canonical-identity-provider/ssoclient
Reviewer Review Type Date Requested Status
Martin Albisetti (community) Approve
Review via email: mp+180909@code.launchpad.net

Commit message

- Bump stable branch to revno 9 from devel branch.

To post a comment you must log in.
Revision history for this message
Martin Albisetti (beuno) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'ssoclient/tests/test_v2.py'
2--- ssoclient/tests/test_v2.py 2013-05-24 12:27:05 +0000
3+++ ssoclient/tests/test_v2.py 2013-08-19 16:45:04 +0000
4@@ -96,6 +96,29 @@
5 super(V2ClientApiTestCase, self).setUp()
6 self.client = V2ApiClient('http://foo.com')
7
8+ p = patch(REQUEST)
9+ self.mock_request = p.start()
10+ self.addCleanup(p.stop)
11+
12+ p = patch('ssoclient.v2.client.OAuth1')
13+ self.mock_oauth = p.start()
14+ self.addCleanup(p.stop)
15+
16+ self.credentials = dict(
17+ consumer_key='consumer_key',
18+ consumer_secret='consumer_secret',
19+ token_key='token_key',
20+ token_secret='token_secret',
21+ )
22+
23+ def assert_unicode_credentials(self, credentials):
24+ self.mock_oauth.assert_called_once_with(
25+ credentials['consumer_key'], credentials['consumer_secret'],
26+ credentials['token_key'], credentials['token_secret'],
27+ )
28+ self.assertTrue(all(isinstance(val, unicode) for
29+ val in self.mock_oauth.call_args[0]))
30+
31
32 class RegisterV2ClientApiTestCase(V2ClientApiTestCase):
33
34@@ -106,42 +129,47 @@
35 response.json.side_effect = ValueError
36 response.text = 'some error message'
37
38- with patch(REQUEST, return_value=response):
39- with self.assertRaises(ExceptionClass) as ctx:
40- self.client.login(email='blah')
41+ self.mock_request.return_value = response
42+ with self.assertRaises(ExceptionClass) as ctx:
43+ self.client.login(email='blah')
44
45 if status_code >= 500:
46 self.assertIn('some error message', str(ctx.exception))
47
48- @patch(REQUEST, return_value=mock_response(400, code="INVALID_DATA"))
49- def test_register_invalid_data(self, mock_request):
50+ def test_register_invalid_data(self):
51+ self.mock_request.return_value = mock_response(
52+ 400, code="INVALID_DATA")
53 with self.assertRaises(errors.InvalidData):
54 self.client.register(email='blah')
55
56- @patch(REQUEST, return_value=mock_response(401, code="CAPTCHA_REQUIRED"))
57- def test_register_captcha_required(self, mock_request):
58+ def test_register_captcha_required(self):
59+ self.mock_request.return_value = mock_response(
60+ 401, code="CAPTCHA_REQUIRED")
61 with self.assertRaises(errors.CaptchaRequired):
62 self.client.register(email='blah')
63
64- @patch(REQUEST, return_value=mock_response(403, code="CAPTCHA_FAILURE"))
65- def test_register_captcha_failed(self, mock_request):
66+ def test_register_captcha_failed(self):
67+ self.mock_request.return_value = mock_response(
68+ 403, code="CAPTCHA_FAILURE")
69 with self.assertRaises(errors.CaptchaFailure):
70 self.client.register(email='blah')
71
72- @patch(REQUEST, return_value=mock_response(502, code="CAPTCHA_ERROR"))
73- def test_register_captcha_error(self, mock_request):
74+ def test_register_captcha_error(self):
75+ self.mock_request.return_value = mock_response(
76+ 502, code="CAPTCHA_ERROR")
77 with self.assertRaises(errors.CaptchaError):
78 self.client.register(email='blah')
79
80- @patch(REQUEST, return_value=mock_response(409, code="ALREADY_REGISTERED"))
81- def test_register_already_registered(self, mock_request):
82+ def test_register_already_registered(self):
83+ self.mock_request.return_value = mock_response(
84+ 409, code="ALREADY_REGISTERED")
85 with self.assertRaises(errors.AlreadyRegistered):
86 self.client.register(email='blah')
87
88- @patch(REQUEST, return_value=mock_response(201))
89- def test_register_success(self, mock_request):
90+ def test_register_success(self):
91+ self.mock_request.return_value = mock_response(201)
92 self.client.register(email='blah')
93- args = mock_request.call_args[0]
94+ args = self.mock_request.call_args[0]
95 self.assertEqual(args, ('POST', 'http://foo.com/accounts'))
96
97 def test_invalid_response_400(self):
98@@ -153,160 +181,275 @@
99
100 class LoginV2ClientApiTestCase(V2ClientApiTestCase):
101
102- @patch(REQUEST, return_value=mock_response(400, code="INVALID_DATA"))
103- def test_login_invalid_data(self, mock_request):
104+ def test_login_invalid_data(self):
105+ self.mock_request.return_value = mock_response(
106+ 400, code="INVALID_DATA")
107 with self.assertRaises(errors.InvalidData):
108 self.client.login(email='blah')
109
110- @patch(REQUEST, return_value=mock_response(401, code="ACCOUNT_SUSPENDED"))
111- def test_login_account_suspended(self, mock_request):
112+ def test_login_account_suspended(self):
113+ self.mock_request.return_value = mock_response(
114+ 401, code="ACCOUNT_SUSPENDED")
115 with self.assertRaises(errors.AccountSuspended):
116 self.client.login(email='blah')
117
118- @patch(REQUEST, return_value=mock_response(
119- 401, code="ACCOUNT_DEACTIVATED"))
120- def test_login_account_deactivated(self, mock_request):
121+ def test_login_account_deactivated(self):
122+ self.mock_request.return_value = mock_response(
123+ 401, code="ACCOUNT_DEACTIVATED")
124 with self.assertRaises(errors.AccountDeactivated):
125 self.client.login(email='blah')
126
127- @patch(REQUEST, return_value=mock_response(
128- 401, code="INVALID_CREDENTIALS"))
129- def test_login_invalid_credentials(self, mock_request):
130+ def test_login_invalid_credentials(self):
131+ self.mock_request.return_value = mock_response(
132+ 401, code="INVALID_CREDENTIALS")
133 with self.assertRaises(errors.InvalidCredentials):
134 self.client.login(email='blah')
135
136- @patch(REQUEST, return_value=mock_response(401, code="TWOFACTOR_REQUIRED"))
137- def test_login_twofactor_required(self, mock_request):
138+ def test_login_twofactor_required(self):
139+ self.mock_request.return_value = mock_response(
140+ 401, code="TWOFACTOR_REQUIRED")
141 with self.assertRaises(errors.TwoFactorRequired):
142 self.client.login(email='blah')
143
144- @patch(REQUEST, return_value=mock_response(403, code="TWOFACTOR_FAILURE"))
145- def test_login_twofactor_failure(self, mock_request):
146+ def test_login_twofactor_failure(self):
147+ self.mock_request.return_value = mock_response(
148+ 403, code="TWOFACTOR_FAILURE")
149 with self.assertRaises(errors.TwoFactorFailure):
150 self.client.login(email='blah')
151
152+ def test_login_account_locked(self):
153+ self.mock_request.return_value = mock_response(
154+ 403, code="ACCOUNT_LOCKED")
155+ with self.assertRaises(errors.AccountLocked):
156+ self.client.login(email='blah')
157+
158+ def test_login_email_invalidated(self):
159+ self.mock_request.return_value = mock_response(
160+ 403, code="EMAIL_INVALIDATED")
161+ with self.assertRaises(errors.EmailInvalidated):
162+ self.client.login(email='blah')
163+
164
165 class PasswordResetV2ClientApiTestCase(V2ClientApiTestCase):
166
167- @patch(REQUEST, return_value=mock_response(201))
168- def test_request_password_reset(self, mock_request):
169+ def test_request_password_reset(self):
170+ self.mock_request.return_value = mock_response(201)
171 response = self.client.request_password_reset('foo@foo.com')
172 self.assertEqual(response.status_code, 201)
173- self.assertEqual(mock_request.call_args, [
174+ self.assertEqual(self.mock_request.call_args, [
175 ('POST', 'http://foo.com/tokens/password'),
176 {'headers': {'Content-Type': 'application/json'},
177 'data': '{"token": null, "email": "foo@foo.com"}'},
178 ])
179
180- @patch(REQUEST, return_value=mock_response(400, code="INVALID_DATA"))
181- def test_request_password_reset_without_email(self, mock_request):
182+ def test_request_password_reset_without_email(self):
183+ self.mock_request.return_value = mock_response(
184+ 400, code="INVALID_DATA")
185 with self.assertRaises(errors.InvalidData):
186 self.client.request_password_reset(None)
187
188- @patch(REQUEST, return_value=mock_response(400, code="INVALID_DATA"))
189- def test_request_password_reset_with_empty_email(self, mock_request):
190+ def test_request_password_reset_with_empty_email(self):
191+ self.mock_request.return_value = mock_response(
192+ 400, code="INVALID_DATA")
193 with self.assertRaises(errors.InvalidData):
194 self.client.request_password_reset('')
195
196- @patch(REQUEST, return_value=mock_response(201))
197- def test_request_password_reset_with_token(self, mock_request):
198+ def test_request_password_reset_with_token(self):
199+ self.mock_request.return_value = mock_response(201)
200 response = self.client.request_password_reset('foo@foo.com',
201 'token1234')
202 self.assertEqual(response.status_code, 201)
203- self.assertEqual(mock_request.call_args, [
204+ self.assertEqual(self.mock_request.call_args, [
205 ('POST', 'http://foo.com/tokens/password'),
206 {'headers': {'Content-Type': 'application/json'},
207 'data': '{"token": "token1234", "email": "foo@foo.com"}'},
208 ])
209
210- @patch(REQUEST, return_value=mock_response(403, code="ACCOUNT_SUSPENDED"))
211- def test_request_password_reset_for_suspended_account(self, mock_request):
212+ def test_request_password_reset_for_suspended_account(self):
213+ self.mock_request.return_value = mock_response(
214+ 403, code="ACCOUNT_SUSPENDED")
215 with self.assertRaises(errors.AccountSuspended):
216 self.client.request_password_reset('foo@foo.com')
217
218- @patch(REQUEST, return_value=mock_response(
219- 403, code="ACCOUNT_DEACTIVATED"))
220- def test_request_password_reset_for_deactivated_account(self,
221- mock_request):
222+ def test_request_password_reset_for_deactivated_account(self):
223+ self.mock_request.return_value = mock_response(
224+ 403, code="ACCOUNT_DEACTIVATED")
225 with self.assertRaises(errors.AccountDeactivated):
226 self.client.request_password_reset('foo@foo.com')
227
228- @patch(REQUEST, return_value=mock_response(
229- 403, code="RESOURCE_NOT_FOUND"))
230- def test_request_password_reset_with_invalid_email(self, mock_request):
231+ def test_request_password_reset_with_invalid_email(self):
232+ self.mock_request.return_value = mock_response(
233+ 403, code="RESOURCE_NOT_FOUND")
234 with self.assertRaises(errors.ResourceNotFound):
235 self.client.request_password_reset('foo@foo.com')
236
237- @patch(REQUEST, return_value=mock_response(
238- 403, code="CAN_NOT_RESET_PASSWORD"))
239- def test_request_password_reset_not_allowed(self, mock_request):
240+ def test_request_password_reset_not_allowed(self):
241+ self.mock_request.return_value = mock_response(
242+ 403, code="CAN_NOT_RESET_PASSWORD")
243 with self.assertRaises(errors.CanNotResetPassword):
244 self.client.request_password_reset('foo@foo.com')
245
246- @patch(REQUEST, return_value=mock_response(403, code="EMAIL_INVALIDATED"))
247- def test_request_password_reset_with_invalidated_email(self,
248- mock_request):
249+ def test_request_password_reset_with_invalidated_email(self):
250+ self.mock_request.return_value = mock_response(
251+ 403, code="EMAIL_INVALIDATED")
252 with self.assertRaises(errors.EmailInvalidated):
253 self.client.request_password_reset('foo@foo.com')
254
255- @patch(REQUEST, return_value=mock_response(403, code="TOO_MANY_TOKENS"))
256- def test_request_password_reset_with_too_many_tokens(self, mock_request):
257+ def test_request_password_reset_with_too_many_tokens(self):
258+ self.mock_request.return_value = mock_response(
259+ 403, code="TOO_MANY_TOKENS")
260 with self.assertRaises(errors.TooManyTokens):
261 self.client.request_password_reset('foo@foo.com')
262
263
264 class AccountDetailsV2ClientApiTestCase(V2ClientApiTestCase):
265
266- @patch(REQUEST, return_value=mock_response(200))
267- def test_account_details(self, mock_request):
268- token = dict(
269- consumer_key='consumer_key',
270- consumer_secret='consumer_secret',
271- token_key='token_key',
272- token_secret='token_secret',
273- )
274- with patch('ssoclient.v2.client.OAuth1') as mock_oauth:
275- response = self.client.account_details('some_openid', token)
276-
277- mock_oauth.assert_called_once_with(
278- 'consumer_key', 'consumer_secret', 'token_key', 'token_secret'
279- )
280- self.assertTrue(all(isinstance(val, unicode) for
281- val in mock_oauth.call_args[0]))
282-
283- oauth1 = mock_oauth.return_value
284- mock_request.assert_called_once_with(
285+ def test_account_details(self):
286+ self.mock_request.return_value = mock_response(200)
287+ response = self.client.account_details('some_openid', self.credentials)
288+ self.assert_unicode_credentials(self.credentials)
289+
290+ oauth1 = self.mock_oauth.return_value
291+ self.mock_request.assert_called_once_with(
292 'GET', 'http://foo.com/accounts/some_openid', auth=oauth1,
293 headers={}, allow_redirects=True,
294 )
295
296 # The response is mocked - so this test just confirms that the
297 # account_details method "does the right thing" and returns our mock
298- # repsonse
299+ # response
300 self.assertEqual(response.status_code, 200)
301
302- @patch(REQUEST, return_value=mock_response(200))
303- def test_account_details_anonymous(self, mock_request):
304+ def test_account_details_anonymous(self):
305+ self.mock_request.return_value = mock_response(200)
306 response = self.client.account_details('some_openid')
307- mock_request.assert_called_once_with(
308+ self.mock_request.assert_called_once_with(
309 'GET', 'http://foo.com/accounts/some_openid', auth=None,
310 headers={}, allow_redirects=True,
311 )
312 self.assertEqual(response.status_code, 200)
313
314
315+class EmailsV2ClientApiTestCase(V2ClientApiTestCase):
316+
317+ def test_details(self):
318+ self.mock_request.return_value = mock_response(200)
319+
320+ response = self.client.email_details('email', self.credentials)
321+ self.assert_unicode_credentials(self.credentials)
322+
323+ oauth1 = self.mock_oauth.return_value
324+ self.mock_request.assert_called_once_with(
325+ 'GET', 'http://foo.com/emails/email', auth=oauth1,
326+ headers={}, allow_redirects=True,
327+ )
328+
329+ self.assertEqual(response.status_code, 200)
330+
331+ def test_details_invalid_credentials(self):
332+ self.mock_request.return_value = mock_response(
333+ 401, code="INVALID_CREDENTIALS")
334+ with self.assertRaises(errors.InvalidCredentials):
335+ self.client.email_details('blah', {})
336+
337+ def test_details_not_found(self):
338+ self.mock_request.return_value = mock_response(
339+ 404, code="RESOURCE_NOT_FOUND")
340+ with self.assertRaises(errors.ResourceNotFound):
341+ self.client.email_details('blah', {})
342+
343+ def test_delete(self):
344+ self.mock_request.return_value = mock_response(204)
345+
346+ response = self.client.email_delete('email', self.credentials)
347+ self.assert_unicode_credentials(self.credentials)
348+
349+ oauth1 = self.mock_oauth.return_value
350+ self.mock_request.assert_called_once_with(
351+ 'DELETE', 'http://foo.com/emails/email', auth=oauth1,
352+ headers={},
353+ )
354+
355+ self.assertEqual(response.status_code, 204)
356+
357+ def test_delete_invalid_credentials(self):
358+ self.mock_request.return_value = mock_response(
359+ 401, code="INVALID_CREDENTIALS")
360+ with self.assertRaises(errors.InvalidCredentials):
361+ self.client.email_delete('blah', {})
362+
363+ def test_delete_not_found(self):
364+ self.mock_request.return_value = mock_response(
365+ 404, code="RESOURCE_NOT_FOUND")
366+ with self.assertRaises(errors.ResourceNotFound):
367+ self.client.email_delete('blah', {})
368+
369+
370+class TokensV2ClientApiTestCase(V2ClientApiTestCase):
371+
372+ def test_details(self):
373+ self.mock_request.return_value = mock_response(200)
374+
375+ response = self.client.token_details('token_key', self.credentials)
376+ self.assert_unicode_credentials(self.credentials)
377+
378+ oauth1 = self.mock_oauth.return_value
379+ self.mock_request.assert_called_once_with(
380+ 'GET', 'http://foo.com/tokens/oauth/token_key', auth=oauth1,
381+ headers={}, allow_redirects=True,
382+ )
383+
384+ self.assertEqual(response.status_code, 200)
385+
386+ def test_details_invalid_credentials(self):
387+ self.mock_request.return_value = mock_response(
388+ 401, code="INVALID_CREDENTIALS")
389+ with self.assertRaises(errors.InvalidCredentials):
390+ self.client.token_details('blah', {})
391+
392+ def test_details_not_found(self):
393+ self.mock_request.return_value = mock_response(
394+ 404, code="RESOURCE_NOT_FOUND")
395+ with self.assertRaises(errors.ResourceNotFound):
396+ self.client.token_details('blah', {})
397+
398+ def test_delete(self):
399+ self.mock_request.return_value = mock_response(204)
400+
401+ response = self.client.token_delete('token_key', self.credentials)
402+ self.assert_unicode_credentials(self.credentials)
403+
404+ oauth1 = self.mock_oauth.return_value
405+ self.mock_request.assert_called_once_with(
406+ 'DELETE', 'http://foo.com/tokens/oauth/token_key', auth=oauth1,
407+ headers={},
408+ )
409+
410+ self.assertEqual(response.status_code, 204)
411+
412+ def test_delete_invalid_credentials(self):
413+ self.mock_request.return_value = mock_response(
414+ 401, code="INVALID_CREDENTIALS")
415+ with self.assertRaises(errors.InvalidCredentials):
416+ self.client.token_delete('blah', {})
417+
418+ def test_delete_not_found(self):
419+ self.mock_request.return_value = mock_response(
420+ 404, code="RESOURCE_NOT_FOUND")
421+ with self.assertRaises(errors.ResourceNotFound):
422+ self.client.token_delete('blah', {})
423+
424+
425 class ValidateRequestV2ClientApiTestCase(V2ClientApiTestCase):
426
427- @patch(REQUEST)
428- def test_valid_request(self, mock_request):
429- mock_request.return_value = mock_response(200, is_valid=True)
430+ def test_valid_request(self):
431+ self.mock_request.return_value = mock_response(200, is_valid=True)
432 result = self.client.validate_request(
433 http_url='foo', http_method='GET', authorization='123456789')
434 self.assertEqual(result.json(), {'is_valid': True})
435
436- @patch(REQUEST)
437- def test_invalid_request(self, mock_request):
438- mock_request.return_value = mock_response(200, is_valid=False)
439+ def test_invalid_request(self):
440+ self.mock_request.return_value = mock_response(200, is_valid=False)
441 result = self.client.validate_request(
442 http_url='foo', http_method='GET', authorization='123456789')
443 self.assertEqual(result.json(), {'is_valid': False})
444
445=== modified file 'ssoclient/v2/client.py'
446--- ssoclient/v2/client.py 2013-05-22 16:52:20 +0000
447+++ ssoclient/v2/client.py 2013-08-19 16:45:04 +0000
448@@ -13,6 +13,25 @@
449 def __init__(self, endpoint):
450 self.session = ApiSession(endpoint)
451
452+ def _unicode_credentials(self, credentials):
453+ # if openid and credentials come directly from a call to client.login
454+ # then whether they are unicode or byte-strings depends on which
455+ # json library is in use.
456+ # oauthlib requires them to be unicode - so we coerce to be sure.
457+ if credentials is not None:
458+ consumer_key = unicode(credentials.get('consumer_key', ''))
459+ consumer_secret = unicode(credentials.get('consumer_secret', ''))
460+ token_key = unicode(credentials.get('token_key', ''))
461+ token_secret = unicode(credentials.get('token_secret', ''))
462+ oauth = OAuth1(
463+ consumer_key,
464+ consumer_secret,
465+ token_key, token_secret,
466+ )
467+ else:
468+ oauth = None
469+ return oauth
470+
471 def _merge(self, data, extra):
472 """Allows data to passed to functions by keyword or dict"""
473 if data:
474@@ -30,7 +49,9 @@
475 return self.session.post('/accounts', data=self._merge(data, kwargs))
476
477 @api_exception(errors.AccountDeactivated)
478+ @api_exception(errors.AccountLocked)
479 @api_exception(errors.AccountSuspended)
480+ @api_exception(errors.EmailInvalidated)
481 @api_exception(errors.InvalidCredentials)
482 @api_exception(errors.InvalidData)
483 @api_exception(errors.TwoFactorFailure)
484@@ -41,25 +62,34 @@
485 @api_exception(errors.InvalidCredentials)
486 @api_exception(errors.ResourceNotFound)
487 def account_details(self, openid, token=None):
488- # if openid and token come directly from a call to client.login
489- # then whether they are unicode or byte-strings depends on which
490- # json library is in use.
491- # oauthlib requires them to be unicode - so we coerce to be sure.
492 openid = unicode(openid)
493- if token is not None:
494- consumer_key = unicode(token['consumer_key'])
495- consumer_secret = unicode(token['consumer_secret'])
496- token_key = unicode(token['token_key'])
497- token_secret = unicode(token['token_secret'])
498- oauth = OAuth1(
499- consumer_key,
500- consumer_secret,
501- token_key, token_secret,
502- )
503- else:
504- oauth = None
505+ oauth = self._unicode_credentials(token)
506 return self.session.get('/accounts/%s' % openid, auth=oauth)
507
508+ @api_exception(errors.InvalidCredentials)
509+ @api_exception(errors.ResourceNotFound)
510+ def email_delete(self, email, credentials):
511+ oauth = self._unicode_credentials(credentials)
512+ return self.session.delete('/emails/%s' % email, auth=oauth)
513+
514+ @api_exception(errors.InvalidCredentials)
515+ @api_exception(errors.ResourceNotFound)
516+ def email_details(self, email, credentials):
517+ oauth = self._unicode_credentials(credentials)
518+ return self.session.get('/emails/%s' % email, auth=oauth)
519+
520+ @api_exception(errors.InvalidCredentials)
521+ @api_exception(errors.ResourceNotFound)
522+ def token_delete(self, token_key, credentials):
523+ oauth = self._unicode_credentials(credentials)
524+ return self.session.delete('/tokens/oauth/%s' % token_key, auth=oauth)
525+
526+ @api_exception(errors.InvalidCredentials)
527+ @api_exception(errors.ResourceNotFound)
528+ def token_details(self, token_key, credentials):
529+ oauth = self._unicode_credentials(credentials)
530+ return self.session.get('/tokens/oauth/%s' % token_key, auth=oauth)
531+
532 def validate_request(self, data=None, **kwargs):
533 return self.session.post('/requests/validate',
534 data=self._merge(data, kwargs))
535
536=== modified file 'ssoclient/v2/errors.py'
537--- ssoclient/v2/errors.py 2013-05-24 12:27:05 +0000
538+++ ssoclient/v2/errors.py 2013-08-19 16:45:04 +0000
539@@ -28,6 +28,10 @@
540 error_code = "ACCOUNT_DEACTIVATED"
541
542
543+class AccountLocked(ApiException):
544+ error_code = "ACCOUNT_LOCKED"
545+
546+
547 class EmailInvalidated(ApiException):
548 error_code = "EMAIL_INVALIDATED"
549

Subscribers

People subscribed via source and target branches