diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6378f49..72c40dbe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Unreleased -- +- [added] Added the new `firebase_admin.exceptions` module containing the + base exception types and global error codes. +- [changed] Updated the `firebase_admin.instance_id` module to use the new + shared exception types. The type `instance_id.ApiCallError` was removed. # v2.18.0 diff --git a/README.md b/README.md index 80adc0583..757a3f8cd 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,15 @@ pip install firebase-admin Please refer to the [CONTRIBUTING page](./CONTRIBUTING.md) for more information about how you can contribute to this project. We welcome bug reports, feature -requests, code review feedback, and also pull requests. +requests, code review feedback, and also pull requests. ## Supported Python Versions -We support Python 2.7 and Python 3.3+. Firebase Admin Python SDK is also tested -on PyPy and [Google App Engine](https://cloud.google.com/appengine/) environments. +We currently support Python 2.7 and Python 3.4+. However, Python 2.7 support is +being phased out, and the developers are advised to use latest Python 3. +Firebase Admin Python SDK is also tested on PyPy and +[Google App Engine](https://cloud.google.com/appengine/) environments. ## Documentation diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index b6788355c..d90b494f5 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -20,6 +20,9 @@ import six from six.moves import urllib +from firebase_admin import exceptions +from firebase_admin import _utils + MAX_CLAIMS_PAYLOAD_SIZE = 1000 RESERVED_CLAIMS = set([ @@ -188,3 +191,121 @@ def validate_action_type(action_type): raise ValueError('Invalid action type provided action_type: {0}. \ Valid values are {1}'.format(action_type, ', '.join(VALID_EMAIL_ACTION_TYPES))) return action_type + + +class UidAlreadyExistsError(exceptions.AlreadyExistsError): + """The user with the provided uid already exists.""" + + default_message = 'The user with the provided uid already exists' + + def __init__(self, message, cause, http_response): + exceptions.AlreadyExistsError.__init__(self, message, cause, http_response) + + +class EmailAlreadyExistsError(exceptions.AlreadyExistsError): + """The user with the provided email already exists.""" + + default_message = 'The user with the provided email already exists' + + def __init__(self, message, cause, http_response): + exceptions.AlreadyExistsError.__init__(self, message, cause, http_response) + + +class InvalidDynamicLinkDomainError(exceptions.InvalidArgumentError): + """Dynamic link domain in ActionCodeSettings is not authorized.""" + + default_message = 'Dynamic link domain specified in ActionCodeSettings is not authorized' + + def __init__(self, message, cause, http_response): + exceptions.InvalidArgumentError.__init__(self, message, cause, http_response) + + +class InvalidIdTokenError(exceptions.InvalidArgumentError): + """The provided ID token is not a valid Firebase ID token.""" + + default_message = 'The provided ID token is invalid' + + def __init__(self, message, cause=None, http_response=None): + exceptions.InvalidArgumentError.__init__(self, message, cause, http_response) + + +class PhoneNumberAlreadyExistsError(exceptions.AlreadyExistsError): + """The user with the provided phone number already exists.""" + + default_message = 'The user with the provided phone number already exists' + + def __init__(self, message, cause, http_response): + exceptions.AlreadyExistsError.__init__(self, message, cause, http_response) + + +class UnexpectedResponseError(exceptions.UnknownError): + """Backend service responded with an unexpected or malformed response.""" + + def __init__(self, message, cause=None, http_response=None): + exceptions.UnknownError.__init__(self, message, cause, http_response) + + +class UserNotFoundError(exceptions.NotFoundError): + """No user record found for the specified identifier.""" + + default_message = 'No user record found for the given identifier' + + def __init__(self, message, cause=None, http_response=None): + exceptions.NotFoundError.__init__(self, message, cause, http_response) + + +_CODE_TO_EXC_TYPE = { + 'DUPLICATE_EMAIL': EmailAlreadyExistsError, + 'DUPLICATE_LOCAL_ID': UidAlreadyExistsError, + 'INVALID_DYNAMIC_LINK_DOMAIN': InvalidDynamicLinkDomainError, + 'INVALID_ID_TOKEN': InvalidIdTokenError, + 'PHONE_NUMBER_EXISTS': PhoneNumberAlreadyExistsError, + 'USER_NOT_FOUND': UserNotFoundError, +} + + +def handle_auth_backend_error(error): + """Converts a requests error received from the Firebase Auth service into a FirebaseError.""" + if error.response is None: + raise _utils.handle_requests_error(error) + + code, custom_message = _parse_error_body(error.response) + if not code: + msg = 'Unexpected error response: {0}'.format(error.response.content.decode()) + raise _utils.handle_requests_error(error, message=msg) + + exc_type = _CODE_TO_EXC_TYPE.get(code) + msg = _build_error_message(code, exc_type, custom_message) + if not exc_type: + return _utils.handle_requests_error(error, message=msg) + + return exc_type(msg, cause=error, http_response=error.response) + + +def _parse_error_body(response): + """Parses the given error response to extract Auth error code and message.""" + error_dict = {} + try: + parsed_body = response.json() + if isinstance(parsed_body, dict): + error_dict = parsed_body.get('error', {}) + except ValueError: + pass + + # Auth error response format: {"error": {"message": "AUTH_ERROR_CODE: Optional text"}} + code = error_dict.get('message') if isinstance(error_dict, dict) else None + custom_message = None + if code: + separator = code.find(':') + if separator != -1: + custom_message = code[separator + 1:].strip() + code = code[:separator] + + return code, custom_message + + +def _build_error_message(code, exc_type, custom_message): + default_message = exc_type.default_message if ( + exc_type and hasattr(exc_type, 'default_message')) else 'Error while calling Auth service' + ext = ' {0}'.format(custom_message) if custom_message else '' + return '{0} ({1}).{2}'.format(default_message, code, ext) diff --git a/firebase_admin/_http_client.py b/firebase_admin/_http_client.py index 73028f833..eb8c4027a 100644 --- a/firebase_admin/_http_client.py +++ b/firebase_admin/_http_client.py @@ -109,6 +109,10 @@ def headers(self, method, url, **kwargs): resp = self.request(method, url, **kwargs) return resp.headers + def body_and_response(self, method, url, **kwargs): + resp = self.request(method, url, **kwargs) + return self.parse_body(resp), resp + def body(self, method, url, **kwargs): resp = self.request(method, url, **kwargs) return self.parse_body(resp) diff --git a/firebase_admin/_messaging_utils.py b/firebase_admin/_messaging_utils.py index 34738b168..5c99cb8ef 100644 --- a/firebase_admin/_messaging_utils.py +++ b/firebase_admin/_messaging_utils.py @@ -22,6 +22,8 @@ import six +from firebase_admin import exceptions + class Message(object): """A message that can be sent via Firebase Cloud Messaging. @@ -921,3 +923,33 @@ def encode_fcm_options(cls, fcm_options): } result = cls.remove_null_values(result) return result + + +class ThirdPartyAuthError(exceptions.UnauthenticatedError): + """APNs certificate or web push auth key was invalid or missing.""" + + def __init__(self, message, cause=None, http_response=None): + exceptions.UnauthenticatedError.__init__(self, message, cause, http_response) + + +class QuotaExceededError(exceptions.ResourceExhaustedError): + """Sending limit exceeded for the message target.""" + + def __init__(self, message, cause=None, http_response=None): + exceptions.ResourceExhaustedError.__init__(self, message, cause, http_response) + + +class SenderIdMismatchError(exceptions.PermissionDeniedError): + """The authenticated sender ID is different from the sender ID for the registration token.""" + + def __init__(self, message, cause=None, http_response=None): + exceptions.PermissionDeniedError.__init__(self, message, cause, http_response) + + +class UnregisteredError(exceptions.NotFoundError): + """App instance was unregistered from FCM. + + This usually means that the token used is no longer valid and a new one must be used.""" + + def __init__(self, message, cause=None, http_response=None): + exceptions.NotFoundError.__init__(self, message, cause, http_response) diff --git a/firebase_admin/_token_gen.py b/firebase_admin/_token_gen.py index 7af7b73b7..339714dcd 100644 --- a/firebase_admin/_token_gen.py +++ b/firebase_admin/_token_gen.py @@ -21,13 +21,16 @@ import requests import six from google.auth import credentials -from google.auth import exceptions from google.auth import iam from google.auth import jwt from google.auth import transport +import google.auth.exceptions import google.oauth2.id_token import google.oauth2.service_account +from firebase_admin import exceptions +from firebase_admin import _auth_utils + # ID token constants ID_TOKEN_ISSUER_PREFIX = 'https://securetoken.google.com/' @@ -51,19 +54,6 @@ METADATA_SERVICE_URL = ('http://metadata.google.internal/computeMetadata/v1/instance/' 'service-accounts/default/email') -# Error codes -COOKIE_CREATE_ERROR = 'COOKIE_CREATE_ERROR' -TOKEN_SIGN_ERROR = 'TOKEN_SIGN_ERROR' - - -class ApiCallError(Exception): - """Represents an Exception encountered while invoking the ID toolkit API.""" - - def __init__(self, code, message, error=None): - Exception.__init__(self, message) - self.code = code - self.detail = error - class _SigningProvider(object): """Stores a reference to a google.auth.crypto.Signer.""" @@ -177,9 +167,9 @@ def create_custom_token(self, uid, developer_claims=None): payload['claims'] = developer_claims try: return jwt.encode(signing_provider.signer, payload) - except exceptions.TransportError as error: + except google.auth.exceptions.TransportError as error: msg = 'Failed to sign custom token. {0}'.format(error) - raise ApiCallError(TOKEN_SIGN_ERROR, msg, error) + raise TokenSignError(msg, error) def create_session_cookie(self, id_token, expires_in): @@ -206,20 +196,15 @@ def create_session_cookie(self, id_token, expires_in): 'validDuration': expires_in, } try: - response = self.client.body('post', ':createSessionCookie', json=payload) + body, http_resp = self.client.body_and_response( + 'post', ':createSessionCookie', json=payload) except requests.exceptions.RequestException as error: - self._handle_http_error(COOKIE_CREATE_ERROR, 'Failed to create session cookie', error) + raise _auth_utils.handle_auth_backend_error(error) else: - if not response or not response.get('sessionCookie'): - raise ApiCallError(COOKIE_CREATE_ERROR, 'Failed to create session cookie.') - return response.get('sessionCookie') - - def _handle_http_error(self, code, msg, error): - if error.response is not None: - msg += '\nServer response: {0}'.format(error.response.content.decode()) - else: - msg += '\nReason: {0}'.format(error) - raise ApiCallError(code, msg, error) + if not body or not body.get('sessionCookie'): + raise _auth_utils.UnexpectedResponseError( + 'Failed to create session cookie.', http_response=http_resp) + return body.get('sessionCookie') class TokenVerifier(object): @@ -232,12 +217,18 @@ def __init__(self, app): project_id=app.project_id, short_name='ID token', operation='verify_id_token()', doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens', - cert_url=ID_TOKEN_CERT_URI, issuer=ID_TOKEN_ISSUER_PREFIX) + cert_url=ID_TOKEN_CERT_URI, + issuer=ID_TOKEN_ISSUER_PREFIX, + invalid_token_error=_auth_utils.InvalidIdTokenError, + expired_token_error=ExpiredIdTokenError) self.cookie_verifier = _JWTVerifier( project_id=app.project_id, short_name='session cookie', operation='verify_session_cookie()', doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens', - cert_url=COOKIE_CERT_URI, issuer=COOKIE_ISSUER_PREFIX) + cert_url=COOKIE_CERT_URI, + issuer=COOKIE_ISSUER_PREFIX, + invalid_token_error=InvalidSessionCookieError, + expired_token_error=ExpiredSessionCookieError) def verify_id_token(self, id_token): return self.id_token_verifier.verify(id_token, self.request) @@ -260,6 +251,8 @@ def __init__(self, **kwargs): self.articled_short_name = 'an {0}'.format(self.short_name) else: self.articled_short_name = 'a {0}'.format(self.short_name) + self._invalid_token_error = kwargs.pop('invalid_token_error') + self._expired_token_error = kwargs.pop('expired_token_error') def verify(self, token, request): """Verifies the signature and data for the provided JWT.""" @@ -276,8 +269,7 @@ def verify(self, token, request): 'or set your Firebase project ID as an app option. Alternatively set the ' 'GOOGLE_CLOUD_PROJECT environment variable.'.format(self.operation)) - header = jwt.decode_header(token) - payload = jwt.decode(token, verify=False) + header, payload = self._decode_unverified(token) issuer = payload.get('iss') audience = payload.get('aud') subject = payload.get('sub') @@ -290,12 +282,12 @@ def verify(self, token, request): 'See {0} for details on how to retrieve {1}.'.format(self.url, self.short_name)) error_message = None - if not header.get('kid'): - if audience == FIREBASE_AUDIENCE: - error_message = ( - '{0} expects {1}, but was given a custom ' - 'token.'.format(self.operation, self.articled_short_name)) - elif header.get('alg') == 'HS256' and payload.get( + if audience == FIREBASE_AUDIENCE: + error_message = ( + '{0} expects {1}, but was given a custom ' + 'token.'.format(self.operation, self.articled_short_name)) + elif not header.get('kid'): + if header.get('alg') == 'HS256' and payload.get( 'v') is 0 and 'uid' in payload.get('d', {}): error_message = ( '{0} expects {1}, but was given a legacy custom ' @@ -330,12 +322,76 @@ def verify(self, token, request): '{1}'.format(self.short_name, verify_id_token_msg)) if error_message: - raise ValueError(error_message) - - verified_claims = google.oauth2.id_token.verify_token( - token, - request=request, - audience=self.project_id, - certs_url=self.cert_url) - verified_claims['uid'] = verified_claims['sub'] - return verified_claims + raise self._invalid_token_error(error_message) + + try: + verified_claims = google.oauth2.id_token.verify_token( + token, + request=request, + audience=self.project_id, + certs_url=self.cert_url) + verified_claims['uid'] = verified_claims['sub'] + return verified_claims + except google.auth.exceptions.TransportError as error: + raise CertificateFetchError(str(error), cause=error) + except ValueError as error: + if 'Token expired' in str(error): + raise self._expired_token_error(str(error), cause=error) + raise self._invalid_token_error(str(error), cause=error) + + def _decode_unverified(self, token): + try: + header = jwt.decode_header(token) + payload = jwt.decode(token, verify=False) + return header, payload + except ValueError as error: + raise self._invalid_token_error(str(error), cause=error) + + +class TokenSignError(exceptions.UnknownError): + """Unexpected error while signing a Firebase custom token.""" + + def __init__(self, message, cause): + exceptions.UnknownError.__init__(self, message, cause) + + +class CertificateFetchError(exceptions.UnknownError): + """Failed to fetch some public key certificates required to verify a token.""" + + def __init__(self, message, cause): + exceptions.UnknownError.__init__(self, message, cause) + + +class ExpiredIdTokenError(_auth_utils.InvalidIdTokenError): + """The provided ID token is expired.""" + + def __init__(self, message, cause): + _auth_utils.InvalidIdTokenError.__init__(self, message, cause) + + +class RevokedIdTokenError(_auth_utils.InvalidIdTokenError): + """The provided ID token has been revoked.""" + + def __init__(self, message): + _auth_utils.InvalidIdTokenError.__init__(self, message) + + +class InvalidSessionCookieError(exceptions.InvalidArgumentError): + """The provided string is not a valid Firebase session cookie.""" + + def __init__(self, message, cause=None): + exceptions.InvalidArgumentError.__init__(self, message, cause) + + +class ExpiredSessionCookieError(InvalidSessionCookieError): + """The provided session cookie is expired.""" + + def __init__(self, message, cause): + InvalidSessionCookieError.__init__(self, message, cause) + + +class RevokedSessionCookieError(InvalidSessionCookieError): + """The provided session cookie has been revoked.""" + + def __init__(self, message): + InvalidSessionCookieError.__init__(self, message) diff --git a/firebase_admin/_user_mgt.py b/firebase_admin/_user_mgt.py index 24bb2bdb6..867b6dd89 100644 --- a/firebase_admin/_user_mgt.py +++ b/firebase_admin/_user_mgt.py @@ -24,15 +24,6 @@ from firebase_admin import _user_import -INTERNAL_ERROR = 'INTERNAL_ERROR' -USER_NOT_FOUND_ERROR = 'USER_NOT_FOUND_ERROR' -USER_CREATE_ERROR = 'USER_CREATE_ERROR' -USER_UPDATE_ERROR = 'USER_UPDATE_ERROR' -USER_DELETE_ERROR = 'USER_DELETE_ERROR' -USER_IMPORT_ERROR = 'USER_IMPORT_ERROR' -USER_DOWNLOAD_ERROR = 'LIST_USERS_ERROR' -GENERATE_EMAIL_ACTION_LINK_ERROR = 'GENERATE_EMAIL_ACTION_LINK_ERROR' - MAX_LIST_USERS_RESULTS = 1000 MAX_IMPORT_USERS_SIZE = 1000 @@ -43,22 +34,9 @@ def __init__(self, description): self.description = description -# Use this internally, until sentinels are available in the public API. -_UNSPECIFIED = Sentinel('No value specified') - - DELETE_ATTRIBUTE = Sentinel('Value used to delete an attribute from a user profile') -class ApiCallError(Exception): - """Represents an Exception encountered while invoking the Firebase user management API.""" - - def __init__(self, code, message, error=None): - Exception.__init__(self, message) - self.code = code - self.detail = error - - class UserMetadata(object): """Contains additional metadata associated with a user account.""" @@ -381,6 +359,7 @@ def photo_url(self): def provider_id(self): return self._data.get('providerId') + class ActionCodeSettings(object): """Contains required continue/state URL with optional Android and iOS settings. Used when invoking the email action link generation APIs. @@ -396,6 +375,7 @@ def __init__(self, url, handle_code_in_app=None, dynamic_link_domain=None, ios_b self.android_install_app = android_install_app self.android_minimum_version = android_minimum_version + def encode_action_code_settings(settings): """ Validates the provided action code settings for email link generation and populates the REST api parameters. @@ -463,6 +443,7 @@ def encode_action_code_settings(settings): return parameters + class UserManager(object): """Provides methods for interacting with the Google Identity Toolkit.""" @@ -484,16 +465,16 @@ def get_user(self, **kwargs): raise TypeError('Unsupported keyword arguments: {0}.'.format(kwargs)) try: - response = self._client.body('post', '/accounts:lookup', json=payload) + body, http_resp = self._client.body_and_response( + 'post', '/accounts:lookup', json=payload) except requests.exceptions.RequestException as error: - msg = 'Failed to get user by {0}: {1}.'.format(key_type, key) - self._handle_http_error(INTERNAL_ERROR, msg, error) + raise _auth_utils.handle_auth_backend_error(error) else: - if not response or not response.get('users'): - raise ApiCallError( - USER_NOT_FOUND_ERROR, - 'No user record found for the provided {0}: {1}.'.format(key_type, key)) - return response['users'][0] + if not body or not body.get('users'): + raise _auth_utils.UserNotFoundError( + 'No user record found for the provided {0}: {1}.'.format(key_type, key), + http_response=http_resp) + return body['users'][0] def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS): """Retrieves a batch of users.""" @@ -513,7 +494,7 @@ def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS): try: return self._client.body('get', '/accounts:batchGet', params=payload) except requests.exceptions.RequestException as error: - self._handle_http_error(USER_DOWNLOAD_ERROR, 'Failed to download user accounts.', error) + raise _auth_utils.handle_auth_backend_error(error) def create_user(self, uid=None, display_name=None, email=None, phone_number=None, photo_url=None, password=None, disabled=None, email_verified=None): @@ -530,17 +511,18 @@ def create_user(self, uid=None, display_name=None, email=None, phone_number=None } payload = {k: v for k, v in payload.items() if v is not None} try: - response = self._client.body('post', '/accounts', json=payload) + body, http_resp = self._client.body_and_response('post', '/accounts', json=payload) except requests.exceptions.RequestException as error: - self._handle_http_error(USER_CREATE_ERROR, 'Failed to create new user.', error) + raise _auth_utils.handle_auth_backend_error(error) else: - if not response or not response.get('localId'): - raise ApiCallError(USER_CREATE_ERROR, 'Failed to create new user.') - return response.get('localId') - - def update_user(self, uid, display_name=_UNSPECIFIED, email=None, phone_number=_UNSPECIFIED, - photo_url=_UNSPECIFIED, password=None, disabled=None, email_verified=None, - valid_since=None, custom_claims=_UNSPECIFIED): + if not body or not body.get('localId'): + raise _auth_utils.UnexpectedResponseError( + 'Failed to create new user.', http_response=http_resp) + return body.get('localId') + + def update_user(self, uid, display_name=None, email=None, phone_number=None, + photo_url=None, password=None, disabled=None, email_verified=None, + valid_since=None, custom_claims=None): """Updates an existing user account with the specified properties""" payload = { 'localId': _auth_utils.validate_uid(uid, required=True), @@ -552,27 +534,27 @@ def update_user(self, uid, display_name=_UNSPECIFIED, email=None, phone_number=_ } remove = [] - if display_name is not _UNSPECIFIED: - if display_name is None or display_name is DELETE_ATTRIBUTE: + if display_name is not None: + if display_name is DELETE_ATTRIBUTE: remove.append('DISPLAY_NAME') else: payload['displayName'] = _auth_utils.validate_display_name(display_name) - if photo_url is not _UNSPECIFIED: - if photo_url is None or photo_url is DELETE_ATTRIBUTE: + if photo_url is not None: + if photo_url is DELETE_ATTRIBUTE: remove.append('PHOTO_URL') else: payload['photoUrl'] = _auth_utils.validate_photo_url(photo_url) if remove: payload['deleteAttribute'] = remove - if phone_number is not _UNSPECIFIED: - if phone_number is None or phone_number is DELETE_ATTRIBUTE: + if phone_number is not None: + if phone_number is DELETE_ATTRIBUTE: payload['deleteProvider'] = ['phone'] else: payload['phoneNumber'] = _auth_utils.validate_phone(phone_number) - if custom_claims is not _UNSPECIFIED: - if custom_claims is None or custom_claims is DELETE_ATTRIBUTE: + if custom_claims is not None: + if custom_claims is DELETE_ATTRIBUTE: custom_claims = {} json_claims = json.dumps(custom_claims) if isinstance( custom_claims, dict) else custom_claims @@ -580,26 +562,28 @@ def update_user(self, uid, display_name=_UNSPECIFIED, email=None, phone_number=_ payload = {k: v for k, v in payload.items() if v is not None} try: - response = self._client.body('post', '/accounts:update', json=payload) + body, http_resp = self._client.body_and_response( + 'post', '/accounts:update', json=payload) except requests.exceptions.RequestException as error: - self._handle_http_error( - USER_UPDATE_ERROR, 'Failed to update user: {0}.'.format(uid), error) + raise _auth_utils.handle_auth_backend_error(error) else: - if not response or not response.get('localId'): - raise ApiCallError(USER_UPDATE_ERROR, 'Failed to update user: {0}.'.format(uid)) - return response.get('localId') + if not body or not body.get('localId'): + raise _auth_utils.UnexpectedResponseError( + 'Failed to update user: {0}.'.format(uid), http_response=http_resp) + return body.get('localId') def delete_user(self, uid): """Deletes the user identified by the specified user ID.""" _auth_utils.validate_uid(uid, required=True) try: - response = self._client.body('post', '/accounts:delete', json={'localId' : uid}) + body, http_resp = self._client.body_and_response( + 'post', '/accounts:delete', json={'localId' : uid}) except requests.exceptions.RequestException as error: - self._handle_http_error( - USER_DELETE_ERROR, 'Failed to delete user: {0}.'.format(uid), error) + raise _auth_utils.handle_auth_backend_error(error) else: - if not response or not response.get('kind'): - raise ApiCallError(USER_DELETE_ERROR, 'Failed to delete user: {0}.'.format(uid)) + if not body or not body.get('kind'): + raise _auth_utils.UnexpectedResponseError( + 'Failed to delete user: {0}.'.format(uid), http_response=http_resp) def import_users(self, users, hash_alg=None): """Imports the given list of users to Firebase Auth.""" @@ -619,13 +603,15 @@ def import_users(self, users, hash_alg=None): raise ValueError('A UserImportHash is required to import users with passwords.') payload.update(hash_alg.to_dict()) try: - response = self._client.body('post', '/accounts:batchCreate', json=payload) + body, http_resp = self._client.body_and_response( + 'post', '/accounts:batchCreate', json=payload) except requests.exceptions.RequestException as error: - self._handle_http_error(USER_IMPORT_ERROR, 'Failed to import users.', error) + raise _auth_utils.handle_auth_backend_error(error) else: - if not isinstance(response, dict): - raise ApiCallError(USER_IMPORT_ERROR, 'Failed to import users.') - return response + if not isinstance(body, dict): + raise _auth_utils.UnexpectedResponseError( + 'Failed to import users.', http_response=http_resp) + return body def generate_email_action_link(self, action_type, email, action_code_settings=None): """Fetches the email action links for types @@ -640,7 +626,7 @@ def generate_email_action_link(self, action_type, email, action_code_settings=No link_url: action url to be emailed to the user Raises: - ApiCallError: If an error occurs while generating the link + FirebaseError: If an error occurs while generating the link ValueError: If the provided arguments are invalid """ payload = { @@ -653,21 +639,15 @@ def generate_email_action_link(self, action_type, email, action_code_settings=No payload.update(encode_action_code_settings(action_code_settings)) try: - response = self._client.body('post', '/accounts:sendOobCode', json=payload) + body, http_resp = self._client.body_and_response( + 'post', '/accounts:sendOobCode', json=payload) except requests.exceptions.RequestException as error: - self._handle_http_error(GENERATE_EMAIL_ACTION_LINK_ERROR, 'Failed to generate link.', - error) - else: - if not response or not response.get('oobLink'): - raise ApiCallError(GENERATE_EMAIL_ACTION_LINK_ERROR, 'Failed to generate link.') - return response.get('oobLink') - - def _handle_http_error(self, code, msg, error): - if error.response is not None: - msg += '\nServer response: {0}'.format(error.response.content.decode()) + raise _auth_utils.handle_auth_backend_error(error) else: - msg += '\nReason: {0}'.format(error) - raise ApiCallError(code, msg, error) + if not body or not body.get('oobLink'): + raise _auth_utils.UnexpectedResponseError( + 'Failed to generate email action link.', http_response=http_resp) + return body.get('oobLink') class _UserIterator(object): diff --git a/firebase_admin/_utils.py b/firebase_admin/_utils.py index b28853868..95ed2c414 100644 --- a/firebase_admin/_utils.py +++ b/firebase_admin/_utils.py @@ -14,7 +14,49 @@ """Internal utilities common to all modules.""" +import json +import socket + +import googleapiclient +import httplib2 +import requests +import six + import firebase_admin +from firebase_admin import exceptions + + +_ERROR_CODE_TO_EXCEPTION_TYPE = { + exceptions.INVALID_ARGUMENT: exceptions.InvalidArgumentError, + exceptions.FAILED_PRECONDITION: exceptions.FailedPreconditionError, + exceptions.OUT_OF_RANGE: exceptions.OutOfRangeError, + exceptions.UNAUTHENTICATED: exceptions.UnauthenticatedError, + exceptions.PERMISSION_DENIED: exceptions.PermissionDeniedError, + exceptions.NOT_FOUND: exceptions.NotFoundError, + exceptions.ABORTED: exceptions.AbortedError, + exceptions.ALREADY_EXISTS: exceptions.AlreadyExistsError, + exceptions.CONFLICT: exceptions.ConflictError, + exceptions.RESOURCE_EXHAUSTED: exceptions.ResourceExhaustedError, + exceptions.CANCELLED: exceptions.CancelledError, + exceptions.DATA_LOSS: exceptions.DataLossError, + exceptions.UNKNOWN: exceptions.UnknownError, + exceptions.INTERNAL: exceptions.InternalError, + exceptions.UNAVAILABLE: exceptions.UnavailableError, + exceptions.DEADLINE_EXCEEDED: exceptions.DeadlineExceededError, +} + + +_HTTP_STATUS_TO_ERROR_CODE = { + 400: exceptions.INVALID_ARGUMENT, + 401: exceptions.UNAUTHENTICATED, + 403: exceptions.PERMISSION_DENIED, + 404: exceptions.NOT_FOUND, + 409: exceptions.CONFLICT, + 412: exceptions.FAILED_PRECONDITION, + 429: exceptions.RESOURCE_EXHAUSTED, + 500: exceptions.INTERNAL, + 503: exceptions.UNAVAILABLE, +} def _get_initialized_app(app): @@ -30,6 +72,223 @@ def _get_initialized_app(app): raise ValueError('Illegal app argument. Argument must be of type ' ' firebase_admin.App, but given "{0}".'.format(type(app))) + def get_app_service(app, name, initializer): app = _get_initialized_app(app) return app._get_service(name, initializer) # pylint: disable=protected-access + + +def handle_platform_error_from_requests(error, handle_func=None): + """Constructs a ``FirebaseError`` from the given requests error. + + This can be used to handle errors returned by Google Cloud Platform (GCP) APIs. + + Args: + error: An error raised by the requests module while making an HTTP call to a GCP API. + handle_func: A function that can be used to handle platform errors in a custom way. When + specified, this function will be called with three arguments. It has the same + signature as ```_handle_func_requests``, but may return ``None``. + + Returns: + FirebaseError: A ``FirebaseError`` that can be raised to the user code. + """ + if error.response is None: + return handle_requests_error(error) + + response = error.response + content = response.content.decode() + status_code = response.status_code + error_dict, message = _parse_platform_error(content, status_code) + exc = None + if handle_func: + exc = handle_func(error, message, error_dict) + + return exc if exc else _handle_func_requests(error, message, error_dict) + + +def _handle_func_requests(error, message, error_dict): + """Constructs a ``FirebaseError`` from the given GCP error. + + Args: + error: An error raised by the requests module while making an HTTP call. + message: A message to be included in the resulting ``FirebaseError``. + error_dict: Parsed GCP error response. + + Returns: + FirebaseError: A ``FirebaseError`` that can be raised to the user code or None. + """ + code = error_dict.get('status') + return handle_requests_error(error, message, code) + + +def handle_requests_error(error, message=None, code=None): + """Constructs a ``FirebaseError`` from the given requests error. + + This method is agnostic of the remote service that produced the error, whether it is a GCP + service or otherwise. Therefore, this method does not attempt to parse the error response in + any way. + + Args: + error: An error raised by the requests module while making an HTTP call. + message: A message to be included in the resulting ``FirebaseError`` (optional). If not + specified the string representation of the ``error`` argument is used as the message. + code: A GCP error code that will be used to determine the resulting error type (optional). + If not specified the HTTP status code on the error response is used to determine a + suitable error code. + + Returns: + FirebaseError: A ``FirebaseError`` that can be raised to the user code. + """ + if isinstance(error, requests.exceptions.Timeout): + return exceptions.DeadlineExceededError( + message='Timed out while making an API call: {0}'.format(error), + cause=error) + elif isinstance(error, requests.exceptions.ConnectionError): + return exceptions.UnavailableError( + message='Failed to establish a connection: {0}'.format(error), + cause=error) + elif error.response is None: + return exceptions.UnknownError( + message='Unknown error while making a remote service call: {0}'.format(error), + cause=error) + + if not code: + code = _http_status_to_error_code(error.response.status_code) + if not message: + message = str(error) + + err_type = _error_code_to_exception_type(code) + return err_type(message=message, cause=error, http_response=error.response) + + +def handle_platform_error_from_googleapiclient(error, handle_func=None): + """Constructs a ``FirebaseError`` from the given googleapiclient error. + + This can be used to handle errors returned by Google Cloud Platform (GCP) APIs. + + Args: + error: An error raised by the googleapiclient while making an HTTP call to a GCP API. + handle_func: A function that can be used to handle platform errors in a custom way. When + specified, this function will be called with three arguments. It has the same + signature as ```_handle_func_googleapiclient``, but may return ``None``. + + Returns: + FirebaseError: A ``FirebaseError`` that can be raised to the user code. + """ + if not isinstance(error, googleapiclient.errors.HttpError): + return handle_googleapiclient_error(error) + + content = error.content.decode() + status_code = error.resp.status + error_dict, message = _parse_platform_error(content, status_code) + http_response = _http_response_from_googleapiclient_error(error) + exc = None + if handle_func: + exc = handle_func(error, message, error_dict, http_response) + + return exc if exc else _handle_func_googleapiclient(error, message, error_dict, http_response) + + +def _handle_func_googleapiclient(error, message, error_dict, http_response): + """Constructs a ``FirebaseError`` from the given GCP error. + + Args: + error: An error raised by the googleapiclient module while making an HTTP call. + message: A message to be included in the resulting ``FirebaseError``. + error_dict: Parsed GCP error response. + http_response: A requests HTTP response object to associate with the exception. + + Returns: + FirebaseError: A ``FirebaseError`` that can be raised to the user code or None. + """ + code = error_dict.get('status') + return handle_googleapiclient_error(error, message, code, http_response) + + +def handle_googleapiclient_error(error, message=None, code=None, http_response=None): + """Constructs a ``FirebaseError`` from the given googleapiclient error. + + This method is agnostic of the remote service that produced the error, whether it is a GCP + service or otherwise. Therefore, this method does not attempt to parse the error response in + any way. + + Args: + error: An error raised by the googleapiclient module while making an HTTP call. + message: A message to be included in the resulting ``FirebaseError`` (optional). If not + specified the string representation of the ``error`` argument is used as the message. + code: A GCP error code that will be used to determine the resulting error type (optional). + If not specified the HTTP status code on the error response is used to determine a + suitable error code. + http_response: A requests HTTP response object to associate with the exception (optional). + If not specified, one will be created from the ``error``. + + Returns: + FirebaseError: A ``FirebaseError`` that can be raised to the user code. + """ + if isinstance(error, socket.timeout) or ( + isinstance(error, socket.error) and 'timed out' in str(error)): + return exceptions.DeadlineExceededError( + message='Timed out while making an API call: {0}'.format(error), + cause=error) + elif isinstance(error, httplib2.ServerNotFoundError): + return exceptions.UnavailableError( + message='Failed to establish a connection: {0}'.format(error), + cause=error) + elif not isinstance(error, googleapiclient.errors.HttpError): + return exceptions.UnknownError( + message='Unknown error while making a remote service call: {0}'.format(error), + cause=error) + + if not code: + code = _http_status_to_error_code(error.resp.status) + if not message: + message = str(error) + if not http_response: + http_response = _http_response_from_googleapiclient_error(error) + + err_type = _error_code_to_exception_type(code) + return err_type(message=message, cause=error, http_response=http_response) + + +def _http_response_from_googleapiclient_error(error): + """Creates a requests HTTP Response object from the given googleapiclient error.""" + resp = requests.models.Response() + resp.raw = six.BytesIO(error.content) + resp.status_code = error.resp.status + return resp + + +def _http_status_to_error_code(status): + """Maps an HTTP status to a platform error code.""" + return _HTTP_STATUS_TO_ERROR_CODE.get(status, exceptions.UNKNOWN) + + +def _error_code_to_exception_type(code): + """Maps a platform error code to an exception type.""" + return _ERROR_CODE_TO_EXCEPTION_TYPE.get(code, exceptions.UnknownError) + + +def _parse_platform_error(content, status_code): + """Parses an HTTP error response from a Google Cloud Platform API and extracts the error code + and message fields. + + Args: + content: Decoded content of the response body. + status_code: HTTP status code. + + Returns: + tuple: A tuple containing error code and message. + """ + data = {} + try: + parsed_body = json.loads(content) + if isinstance(parsed_body, dict): + data = parsed_body + except ValueError: + pass + + error_dict = data.get('error', {}) + msg = error_dict.get('message') + if not msg: + msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content) + return error_dict, msg diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index fba5f3540..47a9a23f7 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -22,6 +22,7 @@ import time import firebase_admin +from firebase_admin import _auth_utils from firebase_admin import _http_client from firebase_admin import _token_gen from firebase_admin import _user_import @@ -30,22 +31,33 @@ _AUTH_ATTRIBUTE = '_auth' -_ID_TOKEN_REVOKED = 'ID_TOKEN_REVOKED' -_SESSION_COOKIE_REVOKED = 'SESSION_COOKIE_REVOKED' __all__ = [ 'ActionCodeSettings', - 'AuthError', + 'CertificateFetchError', 'DELETE_ATTRIBUTE', + 'EmailAlreadyExistsError', 'ErrorInfo', + 'ExpiredIdTokenError', + 'ExpiredSessionCookieError', 'ExportedUserRecord', 'ImportUserRecord', + 'InvalidDynamicLinkDomainError', + 'InvalidIdTokenError', + 'InvalidSessionCookieError', 'ListUsersPage', + 'PhoneNumberAlreadyExistsError', + 'RevokedIdTokenError', + 'RevokedSessionCookieError', + 'TokenSignError', + 'UidAlreadyExistsError', + 'UnexpectedResponseError', 'UserImportHash', 'UserImportResult', 'UserInfo', 'UserMetadata', + 'UserNotFoundError', 'UserProvider', 'UserRecord', @@ -69,15 +81,29 @@ ] ActionCodeSettings = _user_mgt.ActionCodeSettings +CertificateFetchError = _token_gen.CertificateFetchError DELETE_ATTRIBUTE = _user_mgt.DELETE_ATTRIBUTE +EmailAlreadyExistsError = _auth_utils.EmailAlreadyExistsError ErrorInfo = _user_import.ErrorInfo +ExpiredIdTokenError = _token_gen.ExpiredIdTokenError +ExpiredSessionCookieError = _token_gen.ExpiredSessionCookieError ExportedUserRecord = _user_mgt.ExportedUserRecord +ImportUserRecord = _user_import.ImportUserRecord +InvalidDynamicLinkDomainError = _auth_utils.InvalidDynamicLinkDomainError +InvalidIdTokenError = _auth_utils.InvalidIdTokenError +InvalidSessionCookieError = _token_gen.InvalidSessionCookieError ListUsersPage = _user_mgt.ListUsersPage +PhoneNumberAlreadyExistsError = _auth_utils.PhoneNumberAlreadyExistsError +RevokedIdTokenError = _token_gen.RevokedIdTokenError +RevokedSessionCookieError = _token_gen.RevokedSessionCookieError +TokenSignError = _token_gen.TokenSignError +UidAlreadyExistsError = _auth_utils.UidAlreadyExistsError +UnexpectedResponseError = _auth_utils.UnexpectedResponseError UserImportHash = _user_import.UserImportHash -ImportUserRecord = _user_import.ImportUserRecord UserImportResult = _user_import.UserImportResult UserInfo = _user_mgt.UserInfo UserMetadata = _user_mgt.UserMetadata +UserNotFoundError = _auth_utils.UserNotFoundError UserProvider = _user_import.UserProvider UserRecord = _user_mgt.UserRecord @@ -115,13 +141,10 @@ def create_custom_token(uid, developer_claims=None, app=None): Raises: ValueError: If input parameters are invalid. - AuthError: If an error occurs while creating the token using the remote IAM service. + TokenSignError: If an error occurs while signing the token using the remote IAM service. """ token_generator = _get_auth_service(app).token_generator - try: - return token_generator.create_custom_token(uid, developer_claims) - except _token_gen.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + return token_generator.create_custom_token(uid, developer_claims) def verify_id_token(id_token, app=None, check_revoked=False): @@ -139,9 +162,12 @@ def verify_id_token(id_token, app=None, check_revoked=False): dict: A dictionary of key-value pairs parsed from the decoded JWT. Raises: - ValueError: If the JWT was found to be invalid, or if the App's project ID cannot - be determined. - AuthError: If ``check_revoked`` is requested and the token was revoked. + ValueError: If ``id_token`` is a not a string or is empty. + InvalidIdTokenError: If ``id_token`` is not a valid Firebase ID token. + ExpiredIdTokenError: If the specified ID token has expired. + RevokedIdTokenError: If ``check_revoked`` is ``True`` and the ID token has been revoked. + CertificateFetchError: If an error occurs while fetching the public key certificates + required to verify the ID token. """ if not isinstance(check_revoked, bool): # guard against accidental wrong assignment. @@ -150,7 +176,7 @@ def verify_id_token(id_token, app=None, check_revoked=False): token_verifier = _get_auth_service(app).token_verifier verified_claims = token_verifier.verify_id_token(id_token) if check_revoked: - _check_jwt_revoked(verified_claims, _ID_TOKEN_REVOKED, 'ID token', app) + _check_jwt_revoked(verified_claims, RevokedIdTokenError, 'ID token', app) return verified_claims @@ -170,13 +196,10 @@ def create_session_cookie(id_token, expires_in, app=None): Raises: ValueError: If input parameters are invalid. - AuthError: If an error occurs while creating the cookie. + FirebaseError: If an error occurs while creating the cookie. """ token_generator = _get_auth_service(app).token_generator - try: - return token_generator.create_session_cookie(id_token, expires_in) - except _token_gen.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + return token_generator.create_session_cookie(id_token, expires_in) def verify_session_cookie(session_cookie, check_revoked=False, app=None): @@ -194,14 +217,17 @@ def verify_session_cookie(session_cookie, check_revoked=False, app=None): dict: A dictionary of key-value pairs parsed from the decoded JWT. Raises: - ValueError: If the cookie was found to be invalid, or if the App's project ID cannot - be determined. - AuthError: If ``check_revoked`` is requested and the cookie was revoked. + ValueError: If ``session_cookie`` is a not a string or is empty. + InvalidSessionCookieError: If ``session_cookie`` is not a valid Firebase session cookie. + ExpiredSessionCookieError: If the specified session cookie has expired. + RevokedSessionCookieError: If ``check_revoked`` is ``True`` and the cookie has been revoked. + CertificateFetchError: If an error occurs while fetching the public key certificates + required to verify the session cookie. """ token_verifier = _get_auth_service(app).token_verifier verified_claims = token_verifier.verify_session_cookie(session_cookie) if check_revoked: - _check_jwt_revoked(verified_claims, _SESSION_COOKIE_REVOKED, 'session cookie', app) + _check_jwt_revoked(verified_claims, RevokedSessionCookieError, 'session cookie', app) return verified_claims @@ -233,15 +259,12 @@ def get_user(uid, app=None): Raises: ValueError: If the user ID is None, empty or malformed. - AuthError: If an error occurs while retrieving the user or if the specified user ID - does not exist. + UserNotFoundError: If the specified user ID does not exist. + FirebaseError: If an error occurs while retrieving the user. """ user_manager = _get_auth_service(app).user_manager - try: - response = user_manager.get_user(uid=uid) - return UserRecord(response) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + response = user_manager.get_user(uid=uid) + return UserRecord(response) def get_user_by_email(email, app=None): @@ -256,15 +279,12 @@ def get_user_by_email(email, app=None): Raises: ValueError: If the email is None, empty or malformed. - AuthError: If an error occurs while retrieving the user or no user exists by the specified - email address. + UserNotFoundError: If no user exists by the specified email address. + FirebaseError: If an error occurs while retrieving the user. """ user_manager = _get_auth_service(app).user_manager - try: - response = user_manager.get_user(email=email) - return UserRecord(response) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + response = user_manager.get_user(email=email) + return UserRecord(response) def get_user_by_phone_number(phone_number, app=None): @@ -279,15 +299,12 @@ def get_user_by_phone_number(phone_number, app=None): Raises: ValueError: If the phone number is None, empty or malformed. - AuthError: If an error occurs while retrieving the user or no user exists by the specified - phone number. + UserNotFoundError: If no user exists by the specified phone number. + FirebaseError: If an error occurs while retrieving the user. """ user_manager = _get_auth_service(app).user_manager - try: - response = user_manager.get_user(phone_number=phone_number) - return UserRecord(response) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + response = user_manager.get_user(phone_number=phone_number) + return UserRecord(response) def list_users(page_token=None, max_results=_user_mgt.MAX_LIST_USERS_RESULTS, app=None): @@ -310,14 +327,11 @@ def list_users(page_token=None, max_results=_user_mgt.MAX_LIST_USERS_RESULTS, ap Raises: ValueError: If max_results or page_token are invalid. - AuthError: If an error occurs while retrieving the user accounts. + FirebaseError: If an error occurs while retrieving the user accounts. """ user_manager = _get_auth_service(app).user_manager def download(page_token, max_results): - try: - return user_manager.list_users(page_token, max_results) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + return user_manager.list_users(page_token, max_results) return ListUsersPage(download, page_token, max_results) @@ -341,15 +355,12 @@ def create_user(**kwargs): Raises: ValueError: If the specified user properties are invalid. - AuthError: If an error occurs while creating the user account. + FirebaseError: If an error occurs while creating the user account. """ app = kwargs.pop('app', None) user_manager = _get_auth_service(app).user_manager - try: - uid = user_manager.create_user(**kwargs) - return UserRecord(user_manager.get_user(uid=uid)) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + uid = user_manager.create_user(**kwargs) + return UserRecord(user_manager.get_user(uid=uid)) def update_user(uid, **kwargs): @@ -381,15 +392,12 @@ def update_user(uid, **kwargs): Raises: ValueError: If the specified user ID or properties are invalid. - AuthError: If an error occurs while updating the user account. + FirebaseError: If an error occurs while updating the user account. """ app = kwargs.pop('app', None) user_manager = _get_auth_service(app).user_manager - try: - user_manager.update_user(uid, **kwargs) - return UserRecord(user_manager.get_user(uid=uid)) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + user_manager.update_user(uid, **kwargs) + return UserRecord(user_manager.get_user(uid=uid)) def set_custom_user_claims(uid, custom_claims, app=None): @@ -410,13 +418,10 @@ def set_custom_user_claims(uid, custom_claims, app=None): Raises: ValueError: If the specified user ID or the custom claims are invalid. - AuthError: If an error occurs while updating the user account. + FirebaseError: If an error occurs while updating the user account. """ user_manager = _get_auth_service(app).user_manager - try: - user_manager.update_user(uid, custom_claims=custom_claims) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + user_manager.update_user(uid, custom_claims=custom_claims) def delete_user(uid, app=None): @@ -428,13 +433,10 @@ def delete_user(uid, app=None): Raises: ValueError: If the user ID is None, empty or malformed. - AuthError: If an error occurs while deleting the user account. + FirebaseError: If an error occurs while deleting the user account. """ user_manager = _get_auth_service(app).user_manager - try: - user_manager.delete_user(uid) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + user_manager.delete_user(uid) def import_users(users, hash_alg=None, app=None): @@ -457,14 +459,11 @@ def import_users(users, hash_alg=None, app=None): Raises: ValueError: If the provided arguments are invalid. - AuthError: If an error occurs while importing users. + FirebaseError: If an error occurs while importing users. """ user_manager = _get_auth_service(app).user_manager - try: - result = user_manager.import_users(users, hash_alg) - return UserImportResult(result, len(users)) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + result = user_manager.import_users(users, hash_alg) + return UserImportResult(result, len(users)) def generate_password_reset_link(email, action_code_settings=None, app=None): @@ -482,14 +481,11 @@ def generate_password_reset_link(email, action_code_settings=None, app=None): Raises: ValueError: If the provided arguments are invalid - AuthError: If an error occurs while generating the link + FirebaseError: If an error occurs while generating the link """ user_manager = _get_auth_service(app).user_manager - try: - return user_manager.generate_email_action_link('PASSWORD_RESET', email, - action_code_settings=action_code_settings) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + return user_manager.generate_email_action_link( + 'PASSWORD_RESET', email, action_code_settings=action_code_settings) def generate_email_verification_link(email, action_code_settings=None, app=None): @@ -507,14 +503,11 @@ def generate_email_verification_link(email, action_code_settings=None, app=None) Raises: ValueError: If the provided arguments are invalid - AuthError: If an error occurs while generating the link + FirebaseError: If an error occurs while generating the link """ user_manager = _get_auth_service(app).user_manager - try: - return user_manager.generate_email_action_link('VERIFY_EMAIL', email, - action_code_settings=action_code_settings) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + return user_manager.generate_email_action_link( + 'VERIFY_EMAIL', email, action_code_settings=action_code_settings) def generate_sign_in_with_email_link(email, action_code_settings, app=None): @@ -532,29 +525,17 @@ def generate_sign_in_with_email_link(email, action_code_settings, app=None): Raises: ValueError: If the provided arguments are invalid - AuthError: If an error occurs while generating the link + FirebaseError: If an error occurs while generating the link """ user_manager = _get_auth_service(app).user_manager - try: - return user_manager.generate_email_action_link('EMAIL_SIGNIN', email, - action_code_settings=action_code_settings) - except _user_mgt.ApiCallError as error: - raise AuthError(error.code, str(error), error.detail) + return user_manager.generate_email_action_link( + 'EMAIL_SIGNIN', email, action_code_settings=action_code_settings) -def _check_jwt_revoked(verified_claims, error_code, label, app): +def _check_jwt_revoked(verified_claims, exc_type, label, app): user = get_user(verified_claims.get('uid'), app=app) if verified_claims.get('iat') * 1000 < user.tokens_valid_after_timestamp: - raise AuthError(error_code, 'The Firebase {0} has been revoked.'.format(label)) - - -class AuthError(Exception): - """Represents an Exception encountered while invoking the Firebase auth API.""" - - def __init__(self, code, message, error=None): - Exception.__init__(self, message) - self.code = code - self.detail = error + raise exc_type('The Firebase {0} has been revoked.'.format(label)) class _AuthService(object): diff --git a/firebase_admin/db.py b/firebase_admin/db.py index 53efd9b15..ef7c96721 100644 --- a/firebase_admin/db.py +++ b/firebase_admin/db.py @@ -32,6 +32,7 @@ from six.moves import urllib import firebase_admin +from firebase_admin import exceptions from firebase_admin import _http_client from firebase_admin import _sseclient from firebase_admin import _utils @@ -209,7 +210,7 @@ def get(self, etag=False, shallow=False): Raises: ValueError: If both ``etag`` and ``shallow`` are set to True. - ApiCallError: If an error occurs while communicating with the remote database server. + FirebaseError: If an error occurs while communicating with the remote database server. """ if etag: if shallow: @@ -236,7 +237,7 @@ def get_if_changed(self, etag): Raises: ValueError: If the ETag is not a string. - ApiCallError: If an error occurs while communicating with the remote database server. + FirebaseError: If an error occurs while communicating with the remote database server. """ if not isinstance(etag, six.string_types): raise ValueError('ETag must be a string.') @@ -258,7 +259,7 @@ def set(self, value): Raises: ValueError: If the provided value is None. TypeError: If the value is not JSON-serializable. - ApiCallError: If an error occurs while communicating with the remote database server. + FirebaseError: If an error occurs while communicating with the remote database server. """ if value is None: raise ValueError('Value must not be None.') @@ -281,7 +282,7 @@ def set_if_unchanged(self, expected_etag, value): Raises: ValueError: If the value is None, or if expected_etag is not a string. - ApiCallError: If an error occurs while communicating with the remote database server. + FirebaseError: If an error occurs while communicating with the remote database server. """ # pylint: disable=missing-raises-doc if not isinstance(expected_etag, six.string_types): @@ -293,11 +294,11 @@ def set_if_unchanged(self, expected_etag, value): headers = self._client.headers( 'put', self._add_suffix(), json=value, headers={'if-match': expected_etag}) return True, value, headers.get('ETag') - except ApiCallError as error: - detail = error.detail - if detail.response is not None and 'ETag' in detail.response.headers: - etag = detail.response.headers['ETag'] - snapshot = detail.response.json() + except exceptions.FailedPreconditionError as error: + http_response = error.http_response + if http_response is not None and 'ETag' in http_response.headers: + etag = http_response.headers['ETag'] + snapshot = http_response.json() return False, snapshot, etag else: raise error @@ -317,7 +318,7 @@ def push(self, value=''): Raises: ValueError: If the value is None. TypeError: If the value is not JSON-serializable. - ApiCallError: If an error occurs while communicating with the remote database server. + FirebaseError: If an error occurs while communicating with the remote database server. """ if value is None: raise ValueError('Value must not be None.') @@ -333,7 +334,7 @@ def update(self, value): Raises: ValueError: If value is empty or not a dictionary. - ApiCallError: If an error occurs while communicating with the remote database server. + FirebaseError: If an error occurs while communicating with the remote database server. """ if not value or not isinstance(value, dict): raise ValueError('Value argument must be a non-empty dictionary.') @@ -345,7 +346,7 @@ def delete(self): """Deletes this node from the database. Raises: - ApiCallError: If an error occurs while communicating with the remote database server. + FirebaseError: If an error occurs while communicating with the remote database server. """ self._client.request('delete', self._add_suffix()) @@ -371,7 +372,7 @@ def listen(self, callback): ListenerRegistration: An object that can be used to stop the event listener. Raises: - ApiCallError: If an error occurs while starting the initial HTTP connection. + FirebaseError: If an error occurs while starting the initial HTTP connection. """ session = _sseclient.KeepAuthSession(self._client.credential) return self._listen_with_session(callback, session) @@ -387,9 +388,9 @@ def transaction(self, transaction_update): value of this reference into a new value. If another client writes to this location before the new value is successfully saved, the update function is called again with the new current value, and the write will be retried. In case of repeated failures, this method - will retry the transaction up to 25 times before giving up and raising a TransactionError. - The update function may also force an early abort by raising an exception instead of - returning a value. + will retry the transaction up to 25 times before giving up and raising a + TransactionAbortedError. The update function may also force an early abort by raising an + exception instead of returning a value. Args: transaction_update: A function which will be passed the current data stored at this @@ -402,7 +403,7 @@ def transaction(self, transaction_update): object: New value of the current database Reference (only if the transaction commits). Raises: - TransactionError: If the transaction aborts after exhausting all retry attempts. + TransactionAbortedError: If the transaction aborts after exhausting all retry attempts. ValueError: If transaction_update is not a function. """ if not callable(transaction_update): @@ -416,7 +417,8 @@ def transaction(self, transaction_update): if success: return new_data tries += 1 - raise TransactionError('Transaction aborted after failed retries.') + + raise TransactionAbortedError('Transaction aborted after failed retries.') def order_by_child(self, path): """Returns a Query that orders data by child values. @@ -468,7 +470,7 @@ def _listen_with_session(self, callback, session): sse = _sseclient.SSEClient(url, session) return ListenerRegistration(callback, sse) except requests.exceptions.RequestException as error: - raise ApiCallError(_Client.extract_error_message(error), error) + raise _Client.handle_rtdb_error(error) class Query(object): @@ -614,7 +616,7 @@ def get(self): object: Decoded JSON result of the Query. Raises: - ApiCallError: If an error occurs while communicating with the remote database server. + FirebaseError: If an error occurs while communicating with the remote database server. """ result = self._client.body('get', self._pathurl, params=self._querystr) if isinstance(result, (dict, list)) and self._order_by != '$priority': @@ -622,20 +624,11 @@ def get(self): return result -class ApiCallError(Exception): - """Represents an Exception encountered while invoking the Firebase database server API.""" - - def __init__(self, message, error): - Exception.__init__(self, message) - self.detail = error - - -class TransactionError(Exception): - """Represents an Exception encountered while performing a transaction.""" +class TransactionAbortedError(exceptions.AbortedError): + """A transaction was aborted aftr exceeding the maximum number of retries.""" def __init__(self, message): - Exception.__init__(self, message) - + exceptions.AbortedError.__init__(self, message) class _Sorter(object): @@ -934,7 +927,7 @@ def request(self, method, url, **kwargs): Response: An HTTP response object. Raises: - ApiCallError: If an error occurs while making the HTTP call. + FirebaseError: If an error occurs while making the HTTP call. """ query = '&'.join('{0}={1}'.format(key, self.params[key]) for key in self.params) extra_params = kwargs.get('params') @@ -950,33 +943,39 @@ def request(self, method, url, **kwargs): try: return super(_Client, self).request(method, url, **kwargs) except requests.exceptions.RequestException as error: - raise ApiCallError(_Client.extract_error_message(error), error) + raise _Client.handle_rtdb_error(error) + + @classmethod + def handle_rtdb_error(cls, error): + """Converts an error encountered while calling RTDB into a FirebaseError.""" + if error.response is None: + return _utils.handle_requests_error(error) + + message = cls._extract_error_message(error.response) + return _utils.handle_requests_error(error, message=message) @classmethod - def extract_error_message(cls, error): - """Extracts an error message from an exception. + def _extract_error_message(cls, response): + """Extracts an error message from an error response. - If the server has not sent any response, simply converts the exception into a string. If the server has sent a JSON response with an 'error' field, which is the typical behavior of the Realtime Database REST API, parses the response to retrieve the error message. If the server has sent a non-JSON response, returns the full response as the error message. - - Args: - error: An exception raised by the requests library. - - Returns: - str: A string error message extracted from the exception. """ - if error.response is None: - return str(error) + message = None try: - data = error.response.json() + # RTDB error format: {"error": "text message"} + data = response.json() if isinstance(data, dict): - return '{0}\nReason: {1}'.format(error, data.get('error', 'unknown')) + message = data.get('error') except ValueError: pass - return '{0}\nReason: {1}'.format(error, error.response.content.decode()) + + if not message: + message = 'Unexpected response from database: {0}'.format(response.content.decode()) + + return message class _EmulatorAdminCredentials(google.auth.credentials.Credentials): diff --git a/firebase_admin/exceptions.py b/firebase_admin/exceptions.py new file mode 100644 index 000000000..06504225f --- /dev/null +++ b/firebase_admin/exceptions.py @@ -0,0 +1,237 @@ +# Copyright 2019 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Firebase Exceptions module. + +This module defines the base types for exceptions and the platform-wide error codes as outlined in +https://cloud.google.com/apis/design/errors. + +:class:`FirebaseError` is the parent class of all exceptions raised by the Admin SDK. It contains +the ``code``, ``http_response`` and ``cause`` properties common to all Firebase exception types. +Each exception also carries a message that outlines what went wrong. This can be logged for +audit or debugging purposes. + +When calling an Admin SDK API, developers can catch the parent ``FirebaseError`` and +inspect its ``code`` to implement fine-grained error handling. Alternatively, developers can +catch one or more subtypes of ``FirebaseError``. Under normal conditions, any given API can raise +only a small subset of the available exception subtypes. However, the SDK also exposes rare error +conditions like connection timeouts and other I/O errors as instances of ``FirebaseError``. +Therefore it is always a good idea to have a handler specified for ``FirebaseError``, after all the +subtype error handlers. +""" + + +#: Error code for ``InvalidArgumentError`` type. +INVALID_ARGUMENT = 'INVALID_ARGUMENT' + +#: Error code for ``FailedPreconditionError`` type. +FAILED_PRECONDITION = 'FAILED_PRECONDITION' + +#: Error code for ``OutOfRangeError`` type. +OUT_OF_RANGE = 'OUT_OF_RANGE' + +#: Error code for ``UnauthenticatedError`` type. +UNAUTHENTICATED = 'UNAUTHENTICATED' + +#: Error code for ``PermissionDeniedError`` type. +PERMISSION_DENIED = 'PERMISSION_DENIED' + +#: Error code for ``NotFoundError`` type. +NOT_FOUND = 'NOT_FOUND' + +#: Error code for ``ConflictError`` type. +CONFLICT = 'CONFLICT' + +#: Error code for ``AbortedError`` type. +ABORTED = 'ABORTED' + +#: Error code for ``AlreadyExistsError`` type. +ALREADY_EXISTS = 'ALREADY_EXISTS' + +#: Error code for ``ResourceExhaustedError`` type. +RESOURCE_EXHAUSTED = 'RESOURCE_EXHAUSTED' + +#: Error code for ``CancelledError`` type. +CANCELLED = 'CANCELLED' + +#: Error code for ``DataLossError`` type. +DATA_LOSS = 'DATA_LOSS' + +#: Error code for ``UnknownError`` type. +UNKNOWN = 'UNKNOWN' + +#: Error code for ``InternalError`` type. +INTERNAL = 'INTERNAL' + +#: Error code for ``UnavailableError`` type. +UNAVAILABLE = 'UNAVAILABLE' + +#: Error code for ``DeadlineExceededError`` type. +DEADLINE_EXCEEDED = 'DEADLINE_EXCEEDED' + + +class FirebaseError(Exception): + """Base class for all errors raised by the Admin SDK. + + Args: + code: A string error code that represents the type of the exception. Possible error + codes are defined in https://cloud.google.com/apis/design/errors#handling_errors. + message: A human-readable error message string. + cause: The exception that caused this error (optional). + http_response: If this error was caused by an HTTP error response, this property is + set to the ``requests.Response`` object that represents the HTTP response (optional). + See https://2.python-requests.org/en/master/api/#requests.Response for details of + this object. + """ + + def __init__(self, code, message, cause=None, http_response=None): + Exception.__init__(self, message) + self._code = code + self._cause = cause + self._http_response = http_response + + @property + def code(self): + return self._code + + @property + def cause(self): + return self._cause + + @property + def http_response(self): + return self._http_response + + +class InvalidArgumentError(FirebaseError): + """Client specified an invalid argument.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, INVALID_ARGUMENT, message, cause, http_response) + + +class FailedPreconditionError(FirebaseError): + """Request can not be executed in the current system state, such as deleting a non-empty + directory.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, FAILED_PRECONDITION, message, cause, http_response) + + +class OutOfRangeError(FirebaseError): + """Client specified an invalid range.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, OUT_OF_RANGE, message, cause, http_response) + + +class UnauthenticatedError(FirebaseError): + """Request not authenticated due to missing, invalid, or expired OAuth token.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, UNAUTHENTICATED, message, cause, http_response) + + +class PermissionDeniedError(FirebaseError): + """Client does not have sufficient permission. + + This can happen because the OAuth token does not have the right scopes, the client doesn't + have permission, or the API has not been enabled for the client project. + """ + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, PERMISSION_DENIED, message, cause, http_response) + + +class NotFoundError(FirebaseError): + """A specified resource is not found, or the request is rejected by undisclosed reasons, such + as whitelisting.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, NOT_FOUND, message, cause, http_response) + + +class ConflictError(FirebaseError): + """Concurrency conflict, such as read-modify-write conflict.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, CONFLICT, message, cause, http_response) + + +class AbortedError(FirebaseError): + """Concurrency conflict, such as read-modify-write conflict.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, ABORTED, message, cause, http_response) + + +class AlreadyExistsError(FirebaseError): + """The resource that a client tried to create already exists.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, ALREADY_EXISTS, message, cause, http_response) + + +class ResourceExhaustedError(FirebaseError): + """Either out of resource quota or reaching rate limiting.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, RESOURCE_EXHAUSTED, message, cause, http_response) + + +class CancelledError(FirebaseError): + """Request cancelled by the client.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, CANCELLED, message, cause, http_response) + + +class DataLossError(FirebaseError): + """Unrecoverable data loss or data corruption.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, DATA_LOSS, message, cause, http_response) + + +class UnknownError(FirebaseError): + """Unknown server error.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, UNKNOWN, message, cause, http_response) + + +class InternalError(FirebaseError): + """Internal server error.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, INTERNAL, message, cause, http_response) + + +class UnavailableError(FirebaseError): + """Service unavailable. Typically the server is down.""" + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, UNAVAILABLE, message, cause, http_response) + + +class DeadlineExceededError(FirebaseError): + """Request deadline exceeded. + + This will happen only if the caller sets a deadline that is shorter than the method's + default deadline (i.e. requested deadline is not enough for the server to process the + request) and the request did not finish within the deadline. + """ + + def __init__(self, message, cause=None, http_response=None): + FirebaseError.__init__(self, DEADLINE_EXCEEDED, message, cause, http_response) diff --git a/firebase_admin/instance_id.py b/firebase_admin/instance_id.py index b290e9e7f..e9134fc28 100644 --- a/firebase_admin/instance_id.py +++ b/firebase_admin/instance_id.py @@ -53,14 +53,6 @@ def delete_instance_id(instance_id, app=None): _get_iid_service(app).delete_instance_id(instance_id) -class ApiCallError(Exception): - """Represents an Exception encountered while invoking the Firebase instance ID service.""" - - def __init__(self, message, error): - Exception.__init__(self, message) - self.detail = error - - class _InstanceIdService(object): """Provides methods for interacting with the remote instance ID service.""" @@ -94,14 +86,15 @@ def delete_instance_id(self, instance_id): try: self._client.request('delete', path) except requests.exceptions.RequestException as error: - raise ApiCallError(self._extract_message(instance_id, error), error) + msg = self._extract_message(instance_id, error) + raise _utils.handle_requests_error(error, msg) def _extract_message(self, instance_id, error): if error.response is None: - return str(error) + return None status = error.response.status_code msg = self.error_codes.get(status) if msg: return 'Instance ID "{0}": {1}'.format(instance_id, msg) else: - return str(error) + return 'Instance ID "{0}": {1}'.format(instance_id, error) diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index c0f023169..cbd3522fa 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -38,7 +38,6 @@ 'APNSConfig', 'APNSFCMOptions', 'APNSPayload', - 'ApiCallError', 'Aps', 'ApsAlert', 'BatchResponse', @@ -48,8 +47,12 @@ 'Message', 'MulticastMessage', 'Notification', + 'QuotaExceededError', + 'SenderIdMismatchError', 'SendResponse', + 'ThirdPartyAuthError', 'TopicManagementResponse', + 'UnregisteredError', 'WebpushConfig', 'WebpushFCMOptions', 'WebpushNotification', @@ -78,10 +81,14 @@ Notification = _messaging_utils.Notification WebpushConfig = _messaging_utils.WebpushConfig WebpushFCMOptions = _messaging_utils.WebpushFCMOptions -WebpushFcmOptions = _messaging_utils.WebpushFCMOptions WebpushNotification = _messaging_utils.WebpushNotification WebpushNotificationAction = _messaging_utils.WebpushNotificationAction +QuotaExceededError = _messaging_utils.QuotaExceededError +SenderIdMismatchError = _messaging_utils.SenderIdMismatchError +ThirdPartyAuthError = _messaging_utils.ThirdPartyAuthError +UnregisteredError = _messaging_utils.UnregisteredError + def _get_messaging_service(app): return _utils.get_app_service(app, _MESSAGING_ATTRIBUTE, _MessagingService) @@ -101,7 +108,7 @@ def send(message, dry_run=False, app=None): string: A message ID string that uniquely identifies the sent the message. Raises: - ApiCallError: If an error occurs while sending the message to the FCM service. + FirebaseError: If an error occurs while sending the message to the FCM service. ValueError: If the input arguments are invalid. """ return _get_messaging_service(app).send(message, dry_run) @@ -121,7 +128,7 @@ def send_all(messages, dry_run=False, app=None): BatchResponse: A ``messaging.BatchResponse`` instance. Raises: - ApiCallError: If an error occurs while sending the message to the FCM service. + FirebaseError: If an error occurs while sending the message to the FCM service. ValueError: If the input arguments are invalid. """ return _get_messaging_service(app).send_all(messages, dry_run) @@ -141,7 +148,7 @@ def send_multicast(multicast_message, dry_run=False, app=None): BatchResponse: A ``messaging.BatchResponse`` instance. Raises: - ApiCallError: If an error occurs while sending the message to the FCM service. + FirebaseError: If an error occurs while sending the message to the FCM service. ValueError: If the input arguments are invalid. """ if not isinstance(multicast_message, MulticastMessage): @@ -170,7 +177,7 @@ def subscribe_to_topic(tokens, topic, app=None): TopicManagementResponse: A ``TopicManagementResponse`` instance. Raises: - ApiCallError: If an error occurs while communicating with instance ID service. + FirebaseError: If an error occurs while communicating with instance ID service. ValueError: If the input arguments are invalid. """ return _get_messaging_service(app).make_topic_management_request( @@ -189,7 +196,7 @@ def unsubscribe_from_topic(tokens, topic, app=None): TopicManagementResponse: A ``TopicManagementResponse`` instance. Raises: - ApiCallError: If an error occurs while communicating with instance ID service. + FirebaseError: If an error occurs while communicating with instance ID service. ValueError: If the input arguments are invalid. """ return _get_messaging_service(app).make_topic_management_request( @@ -246,21 +253,6 @@ def errors(self): return self._errors -class ApiCallError(Exception): - """Represents an Exception encountered while invoking the FCM API. - - Attributes: - code: A string error code. - message: A error message string. - detail: Original low-level exception. - """ - - def __init__(self, code, message, detail=None): - Exception.__init__(self, message) - self.code = code - self.detail = detail - - class BatchResponse(object): """The response received from a batch request to the FCM API.""" @@ -303,7 +295,7 @@ def success(self): @property def exception(self): - """An ApiCallError if an error occurs while sending the message to the FCM service.""" + """A ``FirebaseError`` if an error occurs while sending the message to the FCM service.""" return self._exception @@ -316,30 +308,12 @@ class _MessagingService(object): IID_HEADERS = {'access_token_auth': 'true'} JSON_ENCODER = _messaging_utils.MessageEncoder() - INTERNAL_ERROR = 'internal-error' - UNKNOWN_ERROR = 'unknown-error' - FCM_ERROR_CODES = { - # FCM v1 canonical error codes - 'NOT_FOUND': 'registration-token-not-registered', - 'PERMISSION_DENIED': 'mismatched-credential', - 'RESOURCE_EXHAUSTED': 'message-rate-exceeded', - 'UNAUTHENTICATED': 'invalid-apns-credentials', - - # FCM v1 new error codes - 'APNS_AUTH_ERROR': 'invalid-apns-credentials', - 'INTERNAL': INTERNAL_ERROR, - 'INVALID_ARGUMENT': 'invalid-argument', - 'QUOTA_EXCEEDED': 'message-rate-exceeded', - 'SENDER_ID_MISMATCH': 'mismatched-credential', - 'UNAVAILABLE': 'server-unavailable', - 'UNREGISTERED': 'registration-token-not-registered', - } - IID_ERROR_CODES = { - 400: 'invalid-argument', - 401: 'authentication-error', - 403: 'authentication-error', - 500: INTERNAL_ERROR, - 503: 'server-unavailable', + FCM_ERROR_TYPES = { + 'APNS_AUTH_ERROR': ThirdPartyAuthError, + 'QUOTA_EXCEEDED': QuotaExceededError, + 'SENDER_ID_MISMATCH': SenderIdMismatchError, + 'THIRD_PARTY_AUTH_ERROR': ThirdPartyAuthError, + 'UNREGISTERED': UnregisteredError, } def __init__(self, app): @@ -375,11 +349,7 @@ def send(self, message, dry_run=False): timeout=self._timeout ) except requests.exceptions.RequestException as error: - if error.response is not None: - self._handle_fcm_error(error) - else: - msg = 'Failed to call messaging API: {0}'.format(error) - raise ApiCallError(self.INTERNAL_ERROR, msg, error) + raise self._handle_fcm_error(error) else: return resp['name'] @@ -395,7 +365,7 @@ def send_all(self, messages, dry_run=False): def batch_callback(_, response, error): exception = None if error: - exception = self._parse_batch_error(error) + exception = self._handle_batch_error(error) send_response = SendResponse(response, exception) responses.append(send_response) @@ -415,7 +385,7 @@ def batch_callback(_, response, error): try: batch.execute() except googleapiclient.http.HttpError as error: - raise self._parse_batch_error(error) + raise self._handle_batch_error(error) else: return BatchResponse(responses) @@ -447,10 +417,7 @@ def make_topic_management_request(self, tokens, topic, operation): timeout=self._timeout ) except requests.exceptions.RequestException as error: - if error.response is not None: - self._handle_iid_error(error) - else: - raise ApiCallError(self.INTERNAL_ERROR, 'Failed to call instance ID API.', error) + raise self._handle_iid_error(error) else: return TopicManagementResponse(resp) @@ -467,20 +434,14 @@ def _postproc(self, _, body): def _handle_fcm_error(self, error): """Handles errors received from the FCM API.""" - data = {} - try: - parsed_body = error.response.json() - if isinstance(parsed_body, dict): - data = parsed_body - except ValueError: - pass - - code, msg = _MessagingService._parse_fcm_error( - data, error.response.content, error.response.status_code) - raise ApiCallError(code, msg, error) + return _utils.handle_platform_error_from_requests( + error, _MessagingService._build_fcm_error_requests) def _handle_iid_error(self, error): """Handles errors received from the Instance ID API.""" + if error.response is None: + raise _utils.handle_requests_error(error) + data = {} try: parsed_body = error.response.json() @@ -489,46 +450,40 @@ def _handle_iid_error(self, error): except ValueError: pass - code = _MessagingService.IID_ERROR_CODES.get( - error.response.status_code, _MessagingService.UNKNOWN_ERROR) + # IID error response format: {"error": "some error message"} msg = data.get('error') if not msg: msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format( error.response.status_code, error.response.content.decode()) - raise ApiCallError(code, msg, error) - def _parse_batch_error(self, error): - """Parses a googleapiclient.http.HttpError content in to an ApiCallError.""" - if error.content is None: - msg = 'Failed to call messaging API: {0}'.format(error) - return ApiCallError(self.INTERNAL_ERROR, msg, error) + return _utils.handle_requests_error(error, msg) - data = {} - try: - parsed_body = json.loads(error.content.decode()) - if isinstance(parsed_body, dict): - data = parsed_body - except ValueError: - pass + def _handle_batch_error(self, error): + """Handles errors received from the googleapiclient while making batch requests.""" + return _utils.handle_platform_error_from_googleapiclient( + error, _MessagingService._build_fcm_error_googleapiclient) + + @classmethod + def _build_fcm_error_requests(cls, error, message, error_dict): + """Parses an error response from the FCM API and creates a FCM-specific exception if + appropriate.""" + exc_type = cls._build_fcm_error(error_dict) + return exc_type(message, cause=error, http_response=error.response) if exc_type else None - code, msg = _MessagingService._parse_fcm_error(data, error.content, error.resp.status) - return ApiCallError(code, msg, error) + @classmethod + def _build_fcm_error_googleapiclient(cls, error, message, error_dict, http_response): + """Parses an error response from the FCM API and creates a FCM-specific exception if + appropriate.""" + exc_type = cls._build_fcm_error(error_dict) + return exc_type(message, cause=error, http_response=http_response) if exc_type else None @classmethod - def _parse_fcm_error(cls, data, content, status_code): - """Parses an error response from the FCM API to a ApiCallError.""" - error_dict = data.get('error', {}) - server_code = None + def _build_fcm_error(cls, error_dict): + if not error_dict: + return None + fcm_code = None for detail in error_dict.get('details', []): if detail.get('@type') == 'type.googleapis.com/google.firebase.fcm.v1.FcmError': - server_code = detail.get('errorCode') + fcm_code = detail.get('errorCode') break - if not server_code: - server_code = error_dict.get('status') - code = _MessagingService.FCM_ERROR_CODES.get(server_code, _MessagingService.UNKNOWN_ERROR) - - msg = error_dict.get('message') - if not msg: - msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format( - status_code, content.decode()) - return code, msg + return _MessagingService.FCM_ERROR_TYPES.get(fcm_code) diff --git a/firebase_admin/project_management.py b/firebase_admin/project_management.py index cc57471c5..68e10797c 100644 --- a/firebase_admin/project_management.py +++ b/firebase_admin/project_management.py @@ -25,6 +25,7 @@ import six import firebase_admin +from firebase_admin import exceptions from firebase_admin import _http_client from firebase_admin import _utils @@ -57,9 +58,9 @@ def ios_app(app_id, app=None): app: An App instance (optional). Returns: - IosApp: An ``IosApp`` instance. + IOSApp: An ``IOSApp`` instance. """ - return IosApp(app_id=app_id, service=_get_project_management_service(app)) + return IOSApp(app_id=app_id, service=_get_project_management_service(app)) def list_android_apps(app=None): @@ -82,7 +83,7 @@ def list_ios_apps(app=None): app: An App instance (optional). Returns: - list: a list of ``IosApp`` instances referring to each iOS app in the Firebase project. + list: a list of ``IOSApp`` instances referring to each iOS app in the Firebase project. """ return _get_project_management_service(app).list_ios_apps() @@ -110,7 +111,7 @@ def create_ios_app(bundle_id, display_name=None, app=None): app: An App instance (optional). Returns: - IosApp: An ``IosApp`` instance that is a reference to the newly created app. + IOSApp: An ``IOSApp`` instance that is a reference to the newly created app. """ return _get_project_management_service(app).create_ios_app(bundle_id, display_name) @@ -139,21 +140,6 @@ def _check_not_none(obj, field_name): return obj -class ApiCallError(Exception): - """An error encountered while interacting with the Firebase Project Management Service.""" - - def __init__(self, message, error): - Exception.__init__(self, message) - self.detail = error - - -class _PollingError(Exception): - """An error encountered during the polling of an app's creation status.""" - - def __init__(self, message): - Exception.__init__(self, message) - - class AndroidApp(object): """A reference to an Android app within a Firebase project. @@ -185,7 +171,7 @@ def get_metadata(self): AndroidAppMetadata: An ``AndroidAppMetadata`` instance. Raises: - ApiCallError: If an error occurs while communicating with the Firebase Project + FirebaseError: If an error occurs while communicating with the Firebase Project Management Service. """ return self._service.get_android_app_metadata(self._app_id) @@ -200,7 +186,7 @@ def set_display_name(self, new_display_name): NoneType: None. Raises: - ApiCallError: If an error occurs while communicating with the Firebase Project + FirebaseError: If an error occurs while communicating with the Firebase Project Management Service. """ return self._service.set_android_app_display_name(self._app_id, new_display_name) @@ -213,10 +199,10 @@ def get_sha_certificates(self): """Retrieves the entire list of SHA certificates associated with this Android app. Returns: - list: A list of ``ShaCertificate`` instances. + list: A list of ``SHACertificate`` instances. Raises: - ApiCallError: If an error occurs while communicating with the Firebase Project + FirebaseError: If an error occurs while communicating with the Firebase Project Management Service. """ return self._service.get_sha_certificates(self._app_id) @@ -231,7 +217,7 @@ def add_sha_certificate(self, certificate_to_add): NoneType: None. Raises: - ApiCallError: If an error occurs while communicating with the Firebase Project + FirebaseError: If an error occurs while communicating with the Firebase Project Management Service. (For example, if the certificate_to_add already exists.) """ return self._service.add_sha_certificate(self._app_id, certificate_to_add) @@ -246,13 +232,13 @@ def delete_sha_certificate(self, certificate_to_delete): NoneType: None. Raises: - ApiCallError: If an error occurs while communicating with the Firebase Project + FirebaseError: If an error occurs while communicating with the Firebase Project Management Service. (For example, if the certificate_to_delete is not found.) """ return self._service.delete_sha_certificate(certificate_to_delete) -class IosApp(object): +class IOSApp(object): """A reference to an iOS app within a Firebase project. Note: Unless otherwise specified, all methods defined in this class make an RPC. @@ -280,10 +266,10 @@ def get_metadata(self): """Retrieves detailed information about this iOS app. Returns: - IosAppMetadata: An ``IosAppMetadata`` instance. + IOSAppMetadata: An ``IOSAppMetadata`` instance. Raises: - ApiCallError: If an error occurs while communicating with the Firebase Project + FirebaseError: If an error occurs while communicating with the Firebase Project Management Service. """ return self._service.get_ios_app_metadata(self._app_id) @@ -298,7 +284,7 @@ def set_display_name(self, new_display_name): NoneType: None. Raises: - ApiCallError: If an error occurs while communicating with the Firebase Project + FirebaseError: If an error occurs while communicating with the Firebase Project Management Service. """ return self._service.set_ios_app_display_name(self._app_id, new_display_name) @@ -373,12 +359,12 @@ def __hash__(self): (self._name, self.app_id, self.display_name, self.project_id, self.package_name)) -class IosAppMetadata(_AppMetadata): +class IOSAppMetadata(_AppMetadata): """iOS-specific information about an iOS Firebase app.""" def __init__(self, bundle_id, name, app_id, display_name, project_id): """Clients should not instantiate this class directly.""" - super(IosAppMetadata, self).__init__(name, app_id, display_name, project_id) + super(IOSAppMetadata, self).__init__(name, app_id, display_name, project_id) self._bundle_id = _check_is_nonempty_string(bundle_id, 'bundle_id') @property @@ -387,7 +373,7 @@ def bundle_id(self): return self._bundle_id def __eq__(self, other): - return super(IosAppMetadata, self).__eq__(other) and self.bundle_id == other.bundle_id + return super(IOSAppMetadata, self).__eq__(other) and self.bundle_id == other.bundle_id def __ne__(self, other): return not self.__eq__(other) @@ -396,7 +382,7 @@ def __hash__(self): return hash((self._name, self.app_id, self.display_name, self.project_id, self.bundle_id)) -class ShaCertificate(object): +class SHACertificate(object): """Represents a SHA-1 or SHA-256 certificate associated with an Android app.""" SHA_1 = 'SHA_1' @@ -406,7 +392,7 @@ class ShaCertificate(object): _SHA_256_RE = re.compile('^[0-9A-Fa-f]{64}$') def __init__(self, sha_hash, name=None): - """Creates a new ShaCertificate instance. + """Creates a new SHACertificate instance. Args: sha_hash: A string; the certificate hash for the Android app. @@ -421,10 +407,10 @@ def __init__(self, sha_hash, name=None): _check_is_nonempty_string_or_none(name, 'name') self._name = name self._sha_hash = sha_hash.lower() - if ShaCertificate._SHA_1_RE.match(sha_hash): - self._cert_type = ShaCertificate.SHA_1 - elif ShaCertificate._SHA_256_RE.match(sha_hash): - self._cert_type = ShaCertificate.SHA_256 + if SHACertificate._SHA_1_RE.match(sha_hash): + self._cert_type = SHACertificate.SHA_1 + elif SHACertificate._SHA_256_RE.match(sha_hash): + self._cert_type = SHACertificate.SHA_256 else: raise ValueError( 'The supplied certificate hash is neither a valid SHA-1 nor SHA_256 hash.') @@ -458,7 +444,7 @@ def cert_type(self): return self._cert_type def __eq__(self, other): - if not isinstance(other, ShaCertificate): + if not isinstance(other, SHACertificate): return False return (self.name == other.name and self.sha_hash == other.sha_hash and self.cert_type == other.cert_type) @@ -478,22 +464,11 @@ class _ProjectManagementService(object): MAXIMUM_POLLING_ATTEMPTS = 8 POLL_BASE_WAIT_TIME_SECONDS = 0.5 POLL_EXPONENTIAL_BACKOFF_FACTOR = 1.5 - ERROR_CODES = { - 401: 'Request not authorized.', - 403: 'Client does not have sufficient privileges.', - 404: 'Failed to find the resource.', - 409: 'The resource already exists.', - 429: 'Request throttled out by the backend server.', - 500: 'Internal server error.', - 503: 'Backend servers are over capacity. Try again later.' - } ANDROID_APPS_RESOURCE_NAME = 'androidApps' ANDROID_APP_IDENTIFIER_NAME = 'packageName' - ANDROID_APP_IDENTIFIER_LABEL = 'Package name' IOS_APPS_RESOURCE_NAME = 'iosApps' IOS_APP_IDENTIFIER_NAME = 'bundleId' - IOS_APP_IDENTIFIER_LABEL = 'Bundle ID' def __init__(self, app): project_id = app.project_id @@ -521,14 +496,14 @@ def get_ios_app_metadata(self, app_id): return self._get_app_metadata( platform_resource_name=_ProjectManagementService.IOS_APPS_RESOURCE_NAME, identifier_name=_ProjectManagementService.IOS_APP_IDENTIFIER_NAME, - metadata_class=IosAppMetadata, + metadata_class=IOSAppMetadata, app_id=app_id) def _get_app_metadata(self, platform_resource_name, identifier_name, metadata_class, app_id): """Retrieves detailed information about an Android or iOS app.""" _check_is_nonempty_string(app_id, 'app_id') path = '/v1beta1/projects/-/{0}/{1}'.format(platform_resource_name, app_id) - response = self._make_request('get', path, app_id, 'App ID') + response = self._make_request('get', path) return metadata_class( response[identifier_name], name=response['name'], @@ -553,7 +528,7 @@ def _set_display_name(self, app_id, new_display_name, platform_resource_name): path = '/v1beta1/projects/-/{0}/{1}?updateMask=displayName'.format( platform_resource_name, app_id) request_body = {'displayName': new_display_name} - self._make_request('patch', path, app_id, 'App ID', json=request_body) + self._make_request('patch', path, json=request_body) def list_android_apps(self): return self._list_apps( @@ -563,7 +538,7 @@ def list_android_apps(self): def list_ios_apps(self): return self._list_apps( platform_resource_name=_ProjectManagementService.IOS_APPS_RESOURCE_NAME, - app_class=IosApp) + app_class=IOSApp) def _list_apps(self, platform_resource_name, app_class): """Lists all the Android or iOS apps within the Firebase project.""" @@ -571,7 +546,7 @@ def _list_apps(self, platform_resource_name, app_class): self._project_id, platform_resource_name, _ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE) - response = self._make_request('get', path, self._project_id, 'Project ID') + response = self._make_request('get', path) apps_list = [] while True: apps = response.get('apps') @@ -587,14 +562,13 @@ def _list_apps(self, platform_resource_name, app_class): platform_resource_name, next_page_token, _ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE) - response = self._make_request('get', path, self._project_id, 'Project ID') + response = self._make_request('get', path) return apps_list def create_android_app(self, package_name, display_name=None): return self._create_app( platform_resource_name=_ProjectManagementService.ANDROID_APPS_RESOURCE_NAME, identifier_name=_ProjectManagementService.ANDROID_APP_IDENTIFIER_NAME, - identifier_label=_ProjectManagementService.ANDROID_APP_IDENTIFIER_LABEL, identifier=package_name, display_name=display_name, app_class=AndroidApp) @@ -603,16 +577,14 @@ def create_ios_app(self, bundle_id, display_name=None): return self._create_app( platform_resource_name=_ProjectManagementService.IOS_APPS_RESOURCE_NAME, identifier_name=_ProjectManagementService.IOS_APP_IDENTIFIER_NAME, - identifier_label=_ProjectManagementService.IOS_APP_IDENTIFIER_LABEL, identifier=bundle_id, display_name=display_name, - app_class=IosApp) + app_class=IOSApp) def _create_app( self, platform_resource_name, identifier_name, - identifier_label, identifier, display_name, app_class): @@ -622,15 +594,10 @@ def _create_app( request_body = {identifier_name: identifier} if display_name: request_body['displayName'] = display_name - response = self._make_request('post', path, identifier, identifier_label, json=request_body) + response = self._make_request('post', path, json=request_body) operation_name = response['name'] - try: - poll_response = self._poll_app_creation(operation_name) - return app_class(app_id=poll_response['appId'], service=self) - except _PollingError as error: - raise ApiCallError( - _ProjectManagementService._extract_message(operation_name, 'Operation name', error), - error) + poll_response = self._poll_app_creation(operation_name) + return app_class(app_id=poll_response['appId'], service=self) def _poll_app_creation(self, operation_name): """Polls the Long-Running Operation repeatedly until it is done with exponential backoff.""" @@ -640,16 +607,17 @@ def _poll_app_creation(self, operation_name): wait_time_seconds = delay_factor * _ProjectManagementService.POLL_BASE_WAIT_TIME_SECONDS time.sleep(wait_time_seconds) path = '/v1/{0}'.format(operation_name) - poll_response = self._make_request('get', path, operation_name, 'Operation name') + poll_response, http_response = self._body_and_response('get', path) done = poll_response.get('done') if done: response = poll_response.get('response') if response: return response else: - raise _PollingError( - 'Polling finished, but the operation terminated in an error.') - raise _PollingError('Polling deadline exceeded.') + raise exceptions.UnknownError( + 'Polling finished, but the operation terminated in an error.', + http_response=http_response) + raise exceptions.DeadlineExceededError('Polling deadline exceeded.') def get_android_app_config(self, app_id): return self._get_app_config( @@ -662,44 +630,36 @@ def get_ios_app_config(self, app_id): def _get_app_config(self, platform_resource_name, app_id): path = '/v1beta1/projects/-/{0}/{1}/config'.format(platform_resource_name, app_id) - response = self._make_request('get', path, app_id, 'App ID') + response = self._make_request('get', path) # In Python 2.7, the base64 module works with strings, while in Python 3, it works with # bytes objects. This line works in both versions. return base64.standard_b64decode(response['configFileContents']).decode(encoding='utf-8') def get_sha_certificates(self, app_id): path = '/v1beta1/projects/-/androidApps/{0}/sha'.format(app_id) - response = self._make_request('get', path, app_id, 'App ID') + response = self._make_request('get', path) cert_list = response.get('certificates') or [] - return [ShaCertificate(sha_hash=cert['shaHash'], name=cert['name']) for cert in cert_list] + return [SHACertificate(sha_hash=cert['shaHash'], name=cert['name']) for cert in cert_list] def add_sha_certificate(self, app_id, certificate_to_add): path = '/v1beta1/projects/-/androidApps/{0}/sha'.format(app_id) sha_hash = _check_not_none(certificate_to_add, 'certificate_to_add').sha_hash cert_type = certificate_to_add.cert_type request_body = {'shaHash': sha_hash, 'certType': cert_type} - self._make_request('post', path, app_id, 'App ID', json=request_body) + self._make_request('post', path, json=request_body) def delete_sha_certificate(self, certificate_to_delete): name = _check_not_none(certificate_to_delete, 'certificate_to_delete').name path = '/v1beta1/{0}'.format(name) - self._make_request('delete', path, name, 'SHA ID') + self._make_request('delete', path) + + def _make_request(self, method, url, json=None): + body, _ = self._body_and_response(method, url, json) + return body - def _make_request(self, method, url, resource_identifier, resource_identifier_label, json=None): + def _body_and_response(self, method, url, json=None): try: - return self._client.body(method=method, url=url, json=json, timeout=self._timeout) + return self._client.body_and_response( + method=method, url=url, json=json, timeout=self._timeout) except requests.exceptions.RequestException as error: - raise ApiCallError( - _ProjectManagementService._extract_message( - resource_identifier, resource_identifier_label, error), - error) - - @staticmethod - def _extract_message(identifier, identifier_label, error): - if not isinstance(error, requests.exceptions.RequestException) or error.response is None: - return '{0} "{1}": {2}'.format(identifier_label, identifier, str(error)) - status = error.response.status_code - message = _ProjectManagementService.ERROR_CODES.get(status) - if message: - return '{0} "{1}": {2}'.format(identifier_label, identifier, message) - return '{0} "{1}": Error {2}.'.format(identifier_label, identifier, status) + raise _utils.handle_platform_error_from_requests(error) diff --git a/integration/test_auth.py b/integration/test_auth.py index 53577b827..eb1464476 100644 --- a/integration/test_auth.py +++ b/integration/test_auth.py @@ -29,6 +29,7 @@ import google.oauth2.credentials from google.auth import transport + _verify_token_url = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken' _verify_password_url = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword' _password_reset_url = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/resetPassword' @@ -129,25 +130,30 @@ def test_session_cookies(api_key): estimated_exp = int(time.time() + expires_in.total_seconds()) assert abs(claims['exp'] - estimated_exp) < 5 +def test_session_cookie_error(): + expires_in = datetime.timedelta(days=1) + with pytest.raises(auth.InvalidIdTokenError): + auth.create_session_cookie('not.a.token', expires_in=expires_in) + def test_get_non_existing_user(): - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.UserNotFoundError) as excinfo: auth.get_user('non.existing') - assert 'USER_NOT_FOUND_ERROR' in str(excinfo.value.code) + assert str(excinfo.value) == 'No user record found for the provided user ID: non.existing.' def test_get_non_existing_user_by_email(): - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.UserNotFoundError) as excinfo: auth.get_user_by_email('non.existing@definitely.non.existing') - assert 'USER_NOT_FOUND_ERROR' in str(excinfo.value.code) + error_msg = ('No user record found for the provided email: ' + 'non.existing@definitely.non.existing.') + assert str(excinfo.value) == error_msg def test_update_non_existing_user(): - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.UserNotFoundError): auth.update_user('non.existing') - assert 'USER_UPDATE_ERROR' in str(excinfo.value.code) def test_delete_non_existing_user(): - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.UserNotFoundError): auth.delete_user('non.existing') - assert 'USER_DELETE_ERROR' in str(excinfo.value.code) @pytest.fixture def new_user(): @@ -250,9 +256,8 @@ def test_create_user(new_user): assert user.user_metadata.creation_timestamp > 0 assert user.user_metadata.last_sign_in_timestamp is None assert len(user.provider_data) is 0 - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.UidAlreadyExistsError): auth.create_user(uid=new_user.uid) - assert excinfo.value.code == 'USER_CREATE_ERROR' def test_update_user(new_user): _, email = _random_id() @@ -321,9 +326,8 @@ def test_disable_user(new_user_with_params): def test_delete_user(): user = auth.create_user() auth.delete_user(user.uid) - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.UserNotFoundError): auth.get_user(user.uid) - assert excinfo.value.code == 'USER_NOT_FOUND_ERROR' def test_revoke_refresh_tokens(new_user): user = auth.get_user(new_user.uid) @@ -347,9 +351,8 @@ def test_verify_id_token_revoked(new_user, api_key): # verify_id_token succeeded because it didn't check revoked. assert claims['iat'] * 1000 < user.tokens_valid_after_timestamp - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.RevokedIdTokenError) as excinfo: claims = auth.verify_id_token(id_token, check_revoked=True) - assert excinfo.value.code == auth._ID_TOKEN_REVOKED assert str(excinfo.value) == 'The Firebase ID token has been revoked.' # Sign in again, verify works. @@ -369,9 +372,8 @@ def test_verify_session_cookie_revoked(new_user, api_key): # verify_session_cookie succeeded because it didn't check revoked. assert claims['iat'] * 1000 < user.tokens_valid_after_timestamp - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.RevokedSessionCookieError) as excinfo: claims = auth.verify_session_cookie(session_cookie, check_revoked=True) - assert excinfo.value.code == auth._SESSION_COOKIE_REVOKED assert str(excinfo.value) == 'The Firebase session cookie has been revoked.' # Sign in again, verify works. diff --git a/integration/test_db.py b/integration/test_db.py index d88d145ba..4c2f6bde2 100644 --- a/integration/test_db.py +++ b/integration/test_db.py @@ -22,6 +22,7 @@ import firebase_admin from firebase_admin import db +from firebase_admin import exceptions from integration import conftest from tests import testutils @@ -359,30 +360,26 @@ def init_ref(self, path, app): admin_ref.set('test') assert admin_ref.get() == 'test' - def check_permission_error(self, excinfo): - assert isinstance(excinfo.value, db.ApiCallError) - assert 'Reason: Permission denied' in str(excinfo.value) - def test_no_access(self, app, override_app): path = '_adminsdk/python/admin' self.init_ref(path, app) user_ref = db.reference(path, override_app) - with pytest.raises(db.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnauthenticatedError) as excinfo: assert user_ref.get() - self.check_permission_error(excinfo) + assert str(excinfo.value) == 'Permission denied' - with pytest.raises(db.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnauthenticatedError) as excinfo: user_ref.set('test2') - self.check_permission_error(excinfo) + assert str(excinfo.value) == 'Permission denied' def test_read(self, app, override_app): path = '_adminsdk/python/protected/user2' self.init_ref(path, app) user_ref = db.reference(path, override_app) assert user_ref.get() == 'test' - with pytest.raises(db.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnauthenticatedError) as excinfo: user_ref.set('test2') - self.check_permission_error(excinfo) + assert str(excinfo.value) == 'Permission denied' def test_read_write(self, app, override_app): path = '_adminsdk/python/protected/user1' @@ -394,9 +391,9 @@ def test_read_write(self, app, override_app): def test_query(self, override_app): user_ref = db.reference('_adminsdk/python/protected', override_app) - with pytest.raises(db.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnauthenticatedError) as excinfo: user_ref.order_by_key().limit_to_first(2).get() - self.check_permission_error(excinfo) + assert str(excinfo.value) == 'Permission denied' def test_none_auth_override(self, app, none_override_app): path = '_adminsdk/python/public' @@ -405,14 +402,14 @@ def test_none_auth_override(self, app, none_override_app): assert public_ref.get() == 'test' ref = db.reference('_adminsdk/python', none_override_app) - with pytest.raises(db.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnauthenticatedError) as excinfo: assert ref.child('protected/user1').get() - self.check_permission_error(excinfo) + assert str(excinfo.value) == 'Permission denied' - with pytest.raises(db.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnauthenticatedError) as excinfo: assert ref.child('protected/user2').get() - self.check_permission_error(excinfo) + assert str(excinfo.value) == 'Permission denied' - with pytest.raises(db.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnauthenticatedError) as excinfo: assert ref.child('admin').get() - self.check_permission_error(excinfo) + assert str(excinfo.value) == 'Permission denied' diff --git a/integration/test_instance_id.py b/integration/test_instance_id.py index 1a176a9a0..99b6787d3 100644 --- a/integration/test_instance_id.py +++ b/integration/test_instance_id.py @@ -16,10 +16,11 @@ import pytest +from firebase_admin import exceptions from firebase_admin import instance_id def test_delete_non_existing(): - with pytest.raises(instance_id.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: # legal instance IDs are /[cdef][A-Za-z0-9_-]{9}[AEIMQUYcgkosw048]/ instance_id.delete_instance_id('fictive-ID0') assert str(excinfo.value) == 'Instance ID "fictive-ID0": Failed to find the instance ID.' diff --git a/integration/test_messaging.py b/integration/test_messaging.py index b1caa09f9..21f9d9669 100644 --- a/integration/test_messaging.py +++ b/integration/test_messaging.py @@ -16,6 +16,9 @@ import re +import pytest + +from firebase_admin import exceptions from firebase_admin import messaging @@ -51,6 +54,22 @@ def test_send(): msg_id = messaging.send(msg, dry_run=True) assert re.match('^projects/.*/messages/.*$', msg_id) +def test_send_invalid_token(): + msg = messaging.Message( + token=_REGISTRATION_TOKEN, + notification=messaging.Notification('test-title', 'test-body') + ) + with pytest.raises(messaging.SenderIdMismatchError): + messaging.send(msg, dry_run=True) + +def test_send_malformed_token(): + msg = messaging.Message( + token='not-a-token', + notification=messaging.Notification('test-title', 'test-body') + ) + with pytest.raises(exceptions.InvalidArgumentError): + messaging.send(msg, dry_run=True) + def test_send_all(): messages = [ messaging.Message( @@ -79,7 +98,7 @@ def test_send_all(): response = batch_response.responses[2] assert response.success is False - assert response.exception is not None + assert isinstance(response.exception, exceptions.InvalidArgumentError) assert response.message_id is None def test_send_one_hundred(): diff --git a/integration/test_project_management.py b/integration/test_project_management.py index 7386a4837..ca648f12d 100644 --- a/integration/test_project_management.py +++ b/integration/test_project_management.py @@ -20,6 +20,7 @@ import pytest +from firebase_admin import exceptions from firebase_admin import project_management @@ -31,8 +32,8 @@ SHA_1_HASH_2 = 'aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbb' SHA_256_HASH_1 = '123456789a123456789a123456789a123456789a123456789a123456789a1234' SHA_256_HASH_2 = 'cafef00dba5eba11b01dfaceacc01adeda7aba5eca55e77e0b57ac1e5ca1ab1e' -SHA_1 = project_management.ShaCertificate.SHA_1 -SHA_256 = project_management.ShaCertificate.SHA_256 +SHA_1 = project_management.SHACertificate.SHA_1 +SHA_256 = project_management.SHACertificate.SHA_256 def _starts_with(display_name, prefix): @@ -64,11 +65,12 @@ def ios_app(default_app): def test_create_android_app_already_exists(android_app): del android_app - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.AlreadyExistsError) as excinfo: project_management.create_android_app( package_name=TEST_APP_PACKAGE_NAME, display_name=TEST_APP_DISPLAY_NAME_PREFIX) - assert 'The resource already exists' in str(excinfo.value) - assert excinfo.value.detail is not None + assert 'Requested entity already exists' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None def test_android_set_display_name_and_get_metadata(android_app, project_id): @@ -118,10 +120,10 @@ def test_android_sha_certificates(android_app): android_app.delete_sha_certificate(cert) # Add four different certs and assert that they have all been added successfully. - android_app.add_sha_certificate(project_management.ShaCertificate(SHA_1_HASH_1)) - android_app.add_sha_certificate(project_management.ShaCertificate(SHA_1_HASH_2)) - android_app.add_sha_certificate(project_management.ShaCertificate(SHA_256_HASH_1)) - android_app.add_sha_certificate(project_management.ShaCertificate(SHA_256_HASH_2)) + android_app.add_sha_certificate(project_management.SHACertificate(SHA_1_HASH_1)) + android_app.add_sha_certificate(project_management.SHACertificate(SHA_1_HASH_2)) + android_app.add_sha_certificate(project_management.SHACertificate(SHA_256_HASH_1)) + android_app.add_sha_certificate(project_management.SHACertificate(SHA_256_HASH_2)) cert_list = android_app.get_sha_certificates() @@ -133,10 +135,11 @@ def test_android_sha_certificates(android_app): assert cert.name # Adding the same cert twice should cause an already-exists error. - with pytest.raises(project_management.ApiCallError) as excinfo: - android_app.add_sha_certificate(project_management.ShaCertificate(SHA_256_HASH_2)) - assert 'The resource already exists' in str(excinfo.value) - assert excinfo.value.detail is not None + with pytest.raises(exceptions.AlreadyExistsError) as excinfo: + android_app.add_sha_certificate(project_management.SHACertificate(SHA_256_HASH_2)) + assert 'Requested entity already exists' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None # Delete all certs and assert that they have all been deleted successfully. for cert in cert_list: @@ -145,20 +148,22 @@ def test_android_sha_certificates(android_app): assert android_app.get_sha_certificates() == [] # Deleting a nonexistent cert should cause a not-found error. - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: android_app.delete_sha_certificate(cert_list[0]) - assert 'Failed to find the resource' in str(excinfo.value) - assert excinfo.value.detail is not None + assert 'Requested entity was not found' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None def test_create_ios_app_already_exists(ios_app): del ios_app - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.AlreadyExistsError) as excinfo: project_management.create_ios_app( bundle_id=TEST_APP_BUNDLE_ID, display_name=TEST_APP_DISPLAY_NAME_PREFIX) - assert 'The resource already exists' in str(excinfo.value) - assert excinfo.value.detail is not None + assert 'Requested entity already exists' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None def test_ios_set_display_name_and_get_metadata(ios_app, project_id): diff --git a/lint.sh b/lint.sh index 603b78f92..aeb37f741 100755 --- a/lint.sh +++ b/lint.sh @@ -20,7 +20,7 @@ function lintAllFiles () { } function lintChangedFiles () { - files=`git status -s $1 | grep -v "^D" | awk '{print $NF}' | grep .py$` + files=`git status -s $1 | (grep -v "^D") | awk '{print $NF}' | (grep .py$ || true)` for f in $files do echo "Running linter on $f" diff --git a/requirements.txt b/requirements.txt index 7a8d855bd..fd73d36bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,8 +5,8 @@ pytest-localserver >= 0.4.1 tox >= 3.6.0 cachecontrol >= 0.12.4 -google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != 'PyPy' +google-api-core[grpc] >= 1.14.0, < 2.0.0dev; platform.python_implementation != 'PyPy' google-api-python-client >= 1.7.8 -google-cloud-firestore >= 0.31.0; platform.python_implementation != 'PyPy' -google-cloud-storage >= 1.13.0 +google-cloud-firestore >= 1.4.0; platform.python_implementation != 'PyPy' +google-cloud-storage >= 1.18.0 six >= 1.6.1 diff --git a/setup.py b/setup.py index 15ae97f93..a3cce8be5 100644 --- a/setup.py +++ b/setup.py @@ -38,10 +38,10 @@ 'to integrate Firebase into their services and applications.') install_requires = [ 'cachecontrol>=0.12.4', - 'google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != "PyPy"', + 'google-api-core[grpc] >= 1.14.0, < 2.0.0dev; platform.python_implementation != "PyPy"', 'google-api-python-client >= 1.7.8', - 'google-cloud-firestore>=0.31.0; platform.python_implementation != "PyPy"', - 'google-cloud-storage>=1.13.0', + 'google-cloud-firestore>=1.4.0; platform.python_implementation != "PyPy"', + 'google-cloud-storage>=1.18.0', 'six>=1.6.1' ] diff --git a/snippets/auth/index.py b/snippets/auth/index.py index 5bfe21f8e..552875696 100644 --- a/snippets/auth/index.py +++ b/snippets/auth/index.py @@ -24,6 +24,7 @@ # [END import_sdk] from firebase_admin import credentials from firebase_admin import auth +from firebase_admin import exceptions sys.path.append("lib") @@ -31,6 +32,7 @@ def initialize_sdk_with_service_account(): # [START initialize_sdk_with_service_account] import firebase_admin from firebase_admin import credentials + from firebase_admin import exceptions cred = credentials.Certificate('path/to/serviceAccountKey.json') default_app = firebase_admin.initialize_app(cred) @@ -144,13 +146,12 @@ def verify_token_uid_check_revoke(id_token): decoded_token = auth.verify_id_token(id_token, check_revoked=True) # Token is valid and not revoked. uid = decoded_token['uid'] - except auth.AuthError as exc: - if exc.code == 'ID_TOKEN_REVOKED': - # Token revoked, inform the user to reauthenticate or signOut(). - pass - else: - # Token is invalid - pass + except auth.RevokedIdTokenError: + # Token revoked, inform the user to reauthenticate or signOut(). + pass + except auth.InvalidIdTokenError: + # Token is invalid + pass # [END verify_token_id_check_revoked] firebase_admin.delete_app(default_app) return uid @@ -322,7 +323,7 @@ def session_login(): response.set_cookie( 'session', session_cookie, expires=expires, httponly=True, secure=True) return response - except auth.AuthError: + except exceptions.FirebaseError: return flask.abort(401, 'Failed to create a session cookie') # [END session_login] @@ -344,9 +345,9 @@ def check_auth_time(id_token, flask): # User did not sign in recently. To guard against ID token theft, require # re-authentication. return flask.abort(401, 'Recent sign in required') - except ValueError: + except auth.InvalidIdTokenError: return flask.abort(401, 'Invalid ID token') - except auth.AuthError: + except exceptions.FirebaseError: return flask.abort(401, 'Failed to create a session cookie') # [END check_auth_time] @@ -359,16 +360,17 @@ def serve_content_for_user(decoded_claims): @app.route('/profile', methods=['POST']) def access_restricted_content(): session_cookie = flask.request.cookies.get('session') + if not session_cookie: + # Session cookie is unavailable. Force user to login. + return flask.redirect('/login') + # Verify the session cookie. In this case an additional check is added to detect # if the user's Firebase session was revoked, user deleted/disabled, etc. try: decoded_claims = auth.verify_session_cookie(session_cookie, check_revoked=True) return serve_content_for_user(decoded_claims) - except ValueError: - # Session cookie is unavailable or invalid. Force user to login. - return flask.redirect('/login') - except auth.AuthError: - # Session revoked. Force user to login. + except auth.InvalidSessionCookieError: + # Session cookie is invalid, expired or revoked. Force user to login. return flask.redirect('/login') # [END session_verify] @@ -385,11 +387,8 @@ def serve_content_for_admin(decoded_claims): return serve_content_for_admin(decoded_claims) else: return flask.abort(401, 'Insufficient permissions') - except ValueError: - # Session cookie is unavailable or invalid. Force user to login. - return flask.redirect('/login') - except auth.AuthError: - # Session revoked. Force user to login. + except auth.InvalidSessionCookieError: + # Session cookie is invalid, expired or revoked. Force user to login. return flask.redirect('/login') # [END session_verify_with_permission_check] @@ -413,7 +412,7 @@ def session_logout(): response = flask.make_response(flask.redirect('/login')) response.set_cookie('session', expires=0) return response - except ValueError: + except auth.InvalidSessionCookieError: return flask.redirect('/login') # [END session_clear_and_revoke] @@ -444,7 +443,7 @@ def import_users(): result.success_count, result.failure_count)) for err in result.errors: print('Failed to import {0} due to {1}'.format(users[err.index].uid, err.reason)) - except auth.AuthError: + except exceptions.FirebaseError: # Some unrecoverable error occurred that prevented the operation from running. pass # [END import_users] @@ -465,7 +464,7 @@ def import_with_hmac(): result = auth.import_users(users, hash_alg=hash_alg) for err in result.errors: print('Failed to import user:', err.reason) - except auth.AuthError as error: + except exceptions.FirebaseError as error: print('Error importing users:', error) # [END import_with_hmac] @@ -485,7 +484,7 @@ def import_with_pbkdf(): result = auth.import_users(users, hash_alg=hash_alg) for err in result.errors: print('Failed to import user:', err.reason) - except auth.AuthError as error: + except exceptions.FirebaseError as error: print('Error importing users:', error) # [END import_with_pbkdf] @@ -506,7 +505,7 @@ def import_with_standard_scrypt(): result = auth.import_users(users, hash_alg=hash_alg) for err in result.errors: print('Failed to import user:', err.reason) - except auth.AuthError as error: + except exceptions.FirebaseError as error: print('Error importing users:', error) # [END import_with_standard_scrypt] @@ -526,7 +525,7 @@ def import_with_bcrypt(): result = auth.import_users(users, hash_alg=hash_alg) for err in result.errors: print('Failed to import user:', err.reason) - except auth.AuthError as error: + except exceptions.FirebaseError as error: print('Error importing users:', error) # [END import_with_bcrypt] @@ -553,7 +552,7 @@ def import_with_scrypt(): result = auth.import_users(users, hash_alg=hash_alg) for err in result.errors: print('Failed to import user:', err.reason) - except auth.AuthError as error: + except exceptions.FirebaseError as error: print('Error importing users:', error) # [END import_with_scrypt] @@ -583,7 +582,7 @@ def import_without_password(): result = auth.import_users(users) for err in result.errors: print('Failed to import user:', err.reason) - except auth.AuthError as error: + except exceptions.FirebaseError as error: print('Error importing users:', error) # [END import_without_password] diff --git a/snippets/database/index.py b/snippets/database/index.py index fee23f626..adfa13476 100644 --- a/snippets/database/index.py +++ b/snippets/database/index.py @@ -214,7 +214,7 @@ def increment_votes(current_value): try: new_vote_count = upvotes_ref.transaction(increment_votes) print('Transaction completed') - except db.TransactionError: + except db.TransactionAbortedError: print('Transaction failed to commit') # [END transaction] diff --git a/tests/test_db.py b/tests/test_db.py index 211eabb4b..081c31e3d 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -22,6 +22,7 @@ import firebase_admin from firebase_admin import db +from firebase_admin import exceptions from firebase_admin import _sseclient from tests import testutils @@ -31,14 +32,15 @@ class MockAdapter(testutils.MockAdapter): ETAG = '0' - def __init__(self, data, status, recorder): + def __init__(self, data, status, recorder, etag=ETAG): testutils.MockAdapter.__init__(self, data, status, recorder) + self._etag = etag def send(self, request, **kwargs): if_match = request.headers.get('if-match') if_none_match = request.headers.get('if-none-match') resp = super(MockAdapter, self).send(request, **kwargs) - resp.headers = {'ETag': MockAdapter.ETAG} + resp.headers = {'ETag': self._etag} if if_match and if_match != MockAdapter.ETAG: resp.status_code = 412 elif if_none_match == MockAdapter.ETAG: @@ -125,6 +127,38 @@ def test_invalid_child(self, child): parent.child(child) +class _RefOperations(object): + """A collection of operations that can be performed using a ``db.Reference``. + + This can be used to test any functionality that is common across multiple API calls. + """ + + @classmethod + def get(cls, ref): + ref.get() + + @classmethod + def push(cls, ref): + ref.push() + + @classmethod + def set(cls, ref): + ref.set({'foo': 'bar'}) + + @classmethod + def delete(cls, ref): + ref.delete() + + @classmethod + def query(cls, ref): + query = ref.order_by_key() + query.get() + + @classmethod + def get_ops(cls): + return [cls.get, cls.push, cls.set, cls.delete, cls.query] + + class TestReference(object): """Test cases for database queries via References.""" @@ -132,6 +166,12 @@ class TestReference(object): valid_values = [ '', 'foo', 0, 1, 100, 1.2, True, False, [], [1, 2], {}, {'foo' : 'bar'} ] + error_codes = { + 400: exceptions.InvalidArgumentError, + 401: exceptions.UnauthenticatedError, + 404: exceptions.NotFoundError, + 500: exceptions.InternalError, + } @classmethod def setup_class(cls): @@ -141,9 +181,9 @@ def setup_class(cls): def teardown_class(cls): testutils.cleanup_apps() - def instrument(self, ref, payload, status=200): + def instrument(self, ref, payload, status=200, etag=MockAdapter.ETAG): recorder = [] - adapter = MockAdapter(payload, status, recorder) + adapter = MockAdapter(payload, status, recorder, etag) ref._client.session.mount(self.test_url, adapter) return recorder @@ -427,6 +467,19 @@ def transaction_update(data): assert len(recorder) == 1 assert recorder[0].method == 'GET' + def test_transaction_abort(self): + ref = db.reference('/test/count') + data = 42 + recorder = self.instrument(ref, json.dumps(data), etag='1') + + with pytest.raises(db.TransactionAbortedError) as excinfo: + ref.transaction(lambda x: x + 1 if x else 1) + assert isinstance(excinfo.value, exceptions.AbortedError) + assert str(excinfo.value) == 'Transaction aborted after failed retries.' + assert excinfo.value.cause is None + assert excinfo.value.http_response is None + assert len(recorder) == 1 + 25 + @pytest.mark.parametrize('func', [None, 0, 1, True, False, 'foo', dict(), list(), tuple()]) def test_transaction_invalid_function(self, func): ref = db.reference('/test') @@ -449,21 +502,29 @@ def test_get_reference(self, path, expected): else: assert ref.parent.path == parent - @pytest.mark.parametrize('error_code', [400, 401, 500]) - def test_server_error(self, error_code): + @pytest.mark.parametrize('error_code', error_codes.keys()) + @pytest.mark.parametrize('func', _RefOperations.get_ops()) + def test_server_error(self, error_code, func): ref = db.reference('/test') self.instrument(ref, json.dumps({'error' : 'json error message'}), error_code) - with pytest.raises(db.ApiCallError) as excinfo: - ref.get() - assert 'Reason: json error message' in str(excinfo.value) - - @pytest.mark.parametrize('error_code', [400, 401, 500]) - def test_other_error(self, error_code): + exc_type = self.error_codes[error_code] + with pytest.raises(exc_type) as excinfo: + func(ref) + assert str(excinfo.value) == 'json error message' + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + + @pytest.mark.parametrize('error_code', error_codes.keys()) + @pytest.mark.parametrize('func', _RefOperations.get_ops()) + def test_other_error(self, error_code, func): ref = db.reference('/test') self.instrument(ref, 'custom error message', error_code) - with pytest.raises(db.ApiCallError) as excinfo: - ref.get() - assert 'Reason: custom error message' in str(excinfo.value) + exc_type = self.error_codes[error_code] + with pytest.raises(exc_type) as excinfo: + func(ref) + assert str(excinfo.value) == 'Unexpected response from database: custom error message' + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None class TestListenerRegistration(object): @@ -481,9 +542,11 @@ def test_listen_error(self): session.mount(test_url, adapter) def callback(_): pass - with pytest.raises(db.ApiCallError) as excinfo: + with pytest.raises(exceptions.InternalError) as excinfo: ref._listen_with_session(callback, session) - assert 'Reason: json error message' in str(excinfo.value) + assert str(excinfo.value) == 'json error message' + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None finally: testutils.cleanup_apps() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 000000000..98d9ce5e9 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,335 @@ +# Copyright 2019 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import socket + +import httplib2 +import pytest +import requests +from requests import models +import six + +from googleapiclient import errors +from firebase_admin import exceptions +from firebase_admin import _utils + + +_NOT_FOUND_ERROR_DICT = { + 'status': 'NOT_FOUND', + 'message': 'test error' +} + + +_NOT_FOUND_PAYLOAD = json.dumps({ + 'error': _NOT_FOUND_ERROR_DICT, +}) + + +class TestRequests(object): + + def test_timeout_error(self): + error = requests.exceptions.Timeout('Test error') + firebase_error = _utils.handle_requests_error(error) + assert isinstance(firebase_error, exceptions.DeadlineExceededError) + assert str(firebase_error) == 'Timed out while making an API call: Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is None + + def test_requests_connection_error(self): + error = requests.exceptions.ConnectionError('Test error') + firebase_error = _utils.handle_requests_error(error) + assert isinstance(firebase_error, exceptions.UnavailableError) + assert str(firebase_error) == 'Failed to establish a connection: Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is None + + def test_unknown_transport_error(self): + error = requests.exceptions.RequestException('Test error') + firebase_error = _utils.handle_requests_error(error) + assert isinstance(firebase_error, exceptions.UnknownError) + assert str(firebase_error) == 'Unknown error while making a remote service call: Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is None + + def test_http_response(self): + resp, error = self._create_response() + firebase_error = _utils.handle_requests_error(error) + assert isinstance(firebase_error, exceptions.InternalError) + assert str(firebase_error) == 'Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is resp + + def test_http_response_with_unknown_status(self): + resp, error = self._create_response(status=501) + firebase_error = _utils.handle_requests_error(error) + assert isinstance(firebase_error, exceptions.UnknownError) + assert str(firebase_error) == 'Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is resp + + def test_http_response_with_message(self): + resp, error = self._create_response() + firebase_error = _utils.handle_requests_error(error, message='Explicit error message') + assert isinstance(firebase_error, exceptions.InternalError) + assert str(firebase_error) == 'Explicit error message' + assert firebase_error.cause is error + assert firebase_error.http_response is resp + + def test_http_response_with_code(self): + resp, error = self._create_response() + firebase_error = _utils.handle_requests_error(error, code=exceptions.UNAVAILABLE) + assert isinstance(firebase_error, exceptions.UnavailableError) + assert str(firebase_error) == 'Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is resp + + def test_http_response_with_message_and_code(self): + resp, error = self._create_response() + firebase_error = _utils.handle_requests_error( + error, message='Explicit error message', code=exceptions.UNAVAILABLE) + assert isinstance(firebase_error, exceptions.UnavailableError) + assert str(firebase_error) == 'Explicit error message' + assert firebase_error.cause is error + assert firebase_error.http_response is resp + + def test_handle_platform_error(self): + resp, error = self._create_response(payload=_NOT_FOUND_PAYLOAD) + firebase_error = _utils.handle_platform_error_from_requests(error) + assert isinstance(firebase_error, exceptions.NotFoundError) + assert str(firebase_error) == 'test error' + assert firebase_error.cause is error + assert firebase_error.http_response is resp + + def test_handle_platform_error_with_no_response(self): + error = requests.exceptions.RequestException('Test error') + firebase_error = _utils.handle_platform_error_from_requests(error) + assert isinstance(firebase_error, exceptions.UnknownError) + assert str(firebase_error) == 'Unknown error while making a remote service call: Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is None + + def test_handle_platform_error_with_no_error_code(self): + resp, error = self._create_response(payload='no error code') + firebase_error = _utils.handle_platform_error_from_requests(error) + assert isinstance(firebase_error, exceptions.InternalError) + message = 'Unexpected HTTP response with status: 500; body: no error code' + assert str(firebase_error) == message + assert firebase_error.cause is error + assert firebase_error.http_response is resp + + def test_handle_platform_error_with_custom_handler(self): + resp, error = self._create_response(payload=_NOT_FOUND_PAYLOAD) + invocations = [] + + def _custom_handler(cause, message, error_dict): + invocations.append((cause, message, error_dict)) + return exceptions.InvalidArgumentError('Custom message', cause, cause.response) + + firebase_error = _utils.handle_platform_error_from_requests(error, _custom_handler) + + assert isinstance(firebase_error, exceptions.InvalidArgumentError) + assert str(firebase_error) == 'Custom message' + assert firebase_error.cause is error + assert firebase_error.http_response is resp + assert len(invocations) == 1 + args = invocations[0] + assert len(args) == 3 + assert args[0] is error + assert args[1] == 'test error' + assert args[2] == _NOT_FOUND_ERROR_DICT + + def test_handle_platform_error_with_custom_handler_ignore(self): + resp, error = self._create_response(payload=_NOT_FOUND_PAYLOAD) + invocations = [] + + def _custom_handler(cause, message, error_dict): + invocations.append((cause, message, error_dict)) + return None + + firebase_error = _utils.handle_platform_error_from_requests(error, _custom_handler) + + assert isinstance(firebase_error, exceptions.NotFoundError) + assert str(firebase_error) == 'test error' + assert firebase_error.cause is error + assert firebase_error.http_response is resp + assert len(invocations) == 1 + args = invocations[0] + assert len(args) == 3 + assert args[0] is error + assert args[1] == 'test error' + assert args[2] == _NOT_FOUND_ERROR_DICT + + def _create_response(self, status=500, payload=None): + resp = models.Response() + resp.status_code = status + if payload: + resp.raw = six.BytesIO(payload.encode()) + exc = requests.exceptions.RequestException('Test error', response=resp) + return resp, exc + + +class TestGoogleApiClient(object): + + @pytest.mark.parametrize('error', [ + socket.timeout('Test error'), + socket.error('Read timed out') + ]) + def test_googleapicleint_timeout_error(self, error): + firebase_error = _utils.handle_googleapiclient_error(error) + assert isinstance(firebase_error, exceptions.DeadlineExceededError) + assert str(firebase_error) == 'Timed out while making an API call: {0}'.format(error) + assert firebase_error.cause is error + assert firebase_error.http_response is None + + def test_googleapiclient_connection_error(self): + error = httplib2.ServerNotFoundError('Test error') + firebase_error = _utils.handle_googleapiclient_error(error) + assert isinstance(firebase_error, exceptions.UnavailableError) + assert str(firebase_error) == 'Failed to establish a connection: Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is None + + def test_unknown_transport_error(self): + error = socket.error('Test error') + firebase_error = _utils.handle_googleapiclient_error(error) + assert isinstance(firebase_error, exceptions.UnknownError) + assert str(firebase_error) == 'Unknown error while making a remote service call: Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is None + + def test_http_response(self): + error = self._create_http_error() + firebase_error = _utils.handle_googleapiclient_error(error) + assert isinstance(firebase_error, exceptions.InternalError) + assert str(firebase_error) == str(error) + assert firebase_error.cause is error + assert firebase_error.http_response.status_code == 500 + assert firebase_error.http_response.content.decode() == 'Body' + + def test_http_response_with_unknown_status(self): + error = self._create_http_error(status=501) + firebase_error = _utils.handle_googleapiclient_error(error) + assert isinstance(firebase_error, exceptions.UnknownError) + assert str(firebase_error) == str(error) + assert firebase_error.cause is error + assert firebase_error.http_response.status_code == 501 + assert firebase_error.http_response.content.decode() == 'Body' + + def test_http_response_with_message(self): + error = self._create_http_error() + firebase_error = _utils.handle_googleapiclient_error( + error, message='Explicit error message') + assert isinstance(firebase_error, exceptions.InternalError) + assert str(firebase_error) == 'Explicit error message' + assert firebase_error.cause is error + assert firebase_error.http_response.status_code == 500 + assert firebase_error.http_response.content.decode() == 'Body' + + def test_http_response_with_code(self): + error = self._create_http_error() + firebase_error = _utils.handle_googleapiclient_error( + error, code=exceptions.UNAVAILABLE) + assert isinstance(firebase_error, exceptions.UnavailableError) + assert str(firebase_error) == str(error) + assert firebase_error.cause is error + assert firebase_error.http_response.status_code == 500 + assert firebase_error.http_response.content.decode() == 'Body' + + def test_http_response_with_message_and_code(self): + error = self._create_http_error() + firebase_error = _utils.handle_googleapiclient_error( + error, message='Explicit error message', code=exceptions.UNAVAILABLE) + assert isinstance(firebase_error, exceptions.UnavailableError) + assert str(firebase_error) == 'Explicit error message' + assert firebase_error.cause is error + assert firebase_error.http_response.status_code == 500 + assert firebase_error.http_response.content.decode() == 'Body' + + def test_handle_platform_error(self): + error = self._create_http_error(payload=_NOT_FOUND_PAYLOAD) + firebase_error = _utils.handle_platform_error_from_googleapiclient(error) + assert isinstance(firebase_error, exceptions.NotFoundError) + assert str(firebase_error) == 'test error' + assert firebase_error.cause is error + assert firebase_error.http_response.status_code == 500 + assert firebase_error.http_response.content.decode() == _NOT_FOUND_PAYLOAD + + def test_handle_platform_error_with_no_response(self): + error = socket.error('Test error') + firebase_error = _utils.handle_platform_error_from_googleapiclient(error) + assert isinstance(firebase_error, exceptions.UnknownError) + assert str(firebase_error) == 'Unknown error while making a remote service call: Test error' + assert firebase_error.cause is error + assert firebase_error.http_response is None + + def test_handle_platform_error_with_no_error_code(self): + error = self._create_http_error(payload='no error code') + firebase_error = _utils.handle_platform_error_from_googleapiclient(error) + assert isinstance(firebase_error, exceptions.InternalError) + message = 'Unexpected HTTP response with status: 500; body: no error code' + assert str(firebase_error) == message + assert firebase_error.cause is error + assert firebase_error.http_response.status_code == 500 + assert firebase_error.http_response.content.decode() == 'no error code' + + def test_handle_platform_error_with_custom_handler(self): + error = self._create_http_error(payload=_NOT_FOUND_PAYLOAD) + invocations = [] + + def _custom_handler(cause, message, error_dict, http_response): + invocations.append((cause, message, error_dict, http_response)) + return exceptions.InvalidArgumentError('Custom message', cause, http_response) + + firebase_error = _utils.handle_platform_error_from_googleapiclient(error, _custom_handler) + + assert isinstance(firebase_error, exceptions.InvalidArgumentError) + assert str(firebase_error) == 'Custom message' + assert firebase_error.cause is error + assert firebase_error.http_response.status_code == 500 + assert firebase_error.http_response.content.decode() == _NOT_FOUND_PAYLOAD + assert len(invocations) == 1 + args = invocations[0] + assert len(args) == 4 + assert args[0] is error + assert args[1] == 'test error' + assert args[2] == _NOT_FOUND_ERROR_DICT + assert args[3] is not None + + def test_handle_platform_error_with_custom_handler_ignore(self): + error = self._create_http_error(payload=_NOT_FOUND_PAYLOAD) + invocations = [] + + def _custom_handler(cause, message, error_dict, http_response): + invocations.append((cause, message, error_dict, http_response)) + return None + + firebase_error = _utils.handle_platform_error_from_googleapiclient(error, _custom_handler) + + assert isinstance(firebase_error, exceptions.NotFoundError) + assert str(firebase_error) == 'test error' + assert firebase_error.cause is error + assert firebase_error.http_response.status_code == 500 + assert firebase_error.http_response.content.decode() == _NOT_FOUND_PAYLOAD + assert len(invocations) == 1 + args = invocations[0] + assert len(args) == 4 + assert args[0] is error + assert args[1] == 'test error' + assert args[2] == _NOT_FOUND_ERROR_DICT + assert args[3] is not None + + def _create_http_error(self, status=500, payload='Body'): + resp = httplib2.Response({'status': status}) + return errors.HttpError(resp, payload.encode()) diff --git a/tests/test_instance_id.py b/tests/test_instance_id.py index e8e8edd27..83e66491a 100644 --- a/tests/test_instance_id.py +++ b/tests/test_instance_id.py @@ -17,15 +17,37 @@ import pytest import firebase_admin +from firebase_admin import exceptions from firebase_admin import instance_id from tests import testutils http_errors = { - 404: 'Instance ID "test_iid": Failed to find the instance ID.', - 409: 'Instance ID "test_iid": Already deleted.', - 429: 'Instance ID "test_iid": Request throttled out by the backend server.', - 500: 'Instance ID "test_iid": Internal server error.', + 400: ( + 'Instance ID "test_iid": Malformed instance ID argument.', + exceptions.InvalidArgumentError), + 401: ( + 'Instance ID "test_iid": Request not authorized.', + exceptions.UnauthenticatedError), + 403: ( + ('Instance ID "test_iid": Project does not match instance ID or the client does not have ' + 'sufficient privileges.'), + exceptions.PermissionDeniedError), + 404: ( + 'Instance ID "test_iid": Failed to find the instance ID.', + exceptions.NotFoundError), + 409: ( + 'Instance ID "test_iid": Already deleted.', + exceptions.ConflictError), + 429: ( + 'Instance ID "test_iid": Request throttled out by the backend server.', + exceptions.ResourceExhaustedError), + 500: ( + 'Instance ID "test_iid": Internal server error.', + exceptions.InternalError), + 503: ( + 'Instance ID "test_iid": Backend servers are over capacity. Try again later.', + exceptions.UnavailableError), } class TestDeleteInstanceId(object): @@ -74,11 +96,17 @@ def test_delete_instance_id_error(self, status): cred = testutils.MockCredential() app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) _, recorder = self._instrument_iid_service(app, status, 'some error') - with pytest.raises(instance_id.ApiCallError) as excinfo: + msg, exc = http_errors.get(status) + with pytest.raises(exc) as excinfo: instance_id.delete_instance_id('test_iid') - assert str(excinfo.value) == http_errors.get(status) - assert excinfo.value.detail is not None - assert len(recorder) == 1 + assert str(excinfo.value) == msg + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + if status != 401: + assert len(recorder) == 1 + else: + # 401 responses are automatically retried by google-auth + assert len(recorder) == 3 assert recorder[0].method == 'DELETE' assert recorder[0].url == self._get_url('explicit-project-id', 'test_iid') @@ -86,12 +114,13 @@ def test_delete_instance_id_unexpected_error(self): cred = testutils.MockCredential() app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) _, recorder = self._instrument_iid_service(app, 501, 'some error') - with pytest.raises(instance_id.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnknownError) as excinfo: instance_id.delete_instance_id('test_iid') url = self._get_url('explicit-project-id', 'test_iid') - message = '501 Server Error: None for url: {0}'.format(url) + message = 'Instance ID "test_iid": 501 Server Error: None for url: {0}'.format(url) assert str(excinfo.value) == message - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 assert recorder[0].method == 'DELETE' assert recorder[0].url == url diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 4f7520045..1f6fa102c 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -23,6 +23,7 @@ from googleapiclient.http import HttpMockSequence import firebase_admin +from firebase_admin import exceptions from firebase_admin import messaging from tests import testutils @@ -31,7 +32,20 @@ NON_DICT_ARGS = ['', list(), tuple(), True, False, 1, 0, {1: 'foo'}, {'foo': 1}] NON_OBJECT_ARGS = [list(), tuple(), dict(), 'foo', 0, 1, True, False] NON_LIST_ARGS = ['', tuple(), dict(), True, False, 1, 0, [1], ['foo', 1]] -HTTP_ERRORS = [400, 404, 500] +HTTP_ERROR_CODES = { + 400: exceptions.InvalidArgumentError, + 403: exceptions.PermissionDeniedError, + 404: exceptions.NotFoundError, + 500: exceptions.InternalError, + 503: exceptions.UnavailableError, +} +FCM_ERROR_CODES = { + 'APNS_AUTH_ERROR': messaging.ThirdPartyAuthError, + 'QUOTA_EXCEEDED': messaging.QuotaExceededError, + 'SENDER_ID_MISMATCH': messaging.SenderIdMismatchError, + 'THIRD_PARTY_AUTH_ERROR': messaging.ThirdPartyAuthError, + 'UNREGISTERED': messaging.UnregisteredError, +} def check_encoding(msg, expected=None): @@ -39,6 +53,13 @@ def check_encoding(msg, expected=None): if expected: assert encoded == expected +def check_exception(exception, message, status): + assert isinstance(exception, exceptions.FirebaseError) + assert str(exception) == message + assert exception.cause is not None + assert exception.http_response is not None + assert exception.http_response.status_code == status + class TestMulticastMessage(object): @@ -551,25 +572,6 @@ def test_webpush_options(self): } check_encoding(msg, expected) - def test_deprecated_fcm_options(self): - msg = messaging.Message( - topic='topic', - webpush=messaging.WebpushConfig( - fcm_options=messaging.WebpushFcmOptions( - link='https://example', - ), - ) - ) - expected = { - 'topic': 'topic', - 'webpush': { - 'fcm_options': { - 'link': 'https://example', - }, - }, - } - check_encoding(msg, expected) - class TestWebpushNotificationEncoder(object): @@ -1409,15 +1411,14 @@ def test_send(self): body = {'message': messaging._MessagingService.encode_message(msg)} assert json.loads(recorder[0].body.decode()) == body - @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_error(self, status): + @pytest.mark.parametrize('status,exc_type', HTTP_ERROR_CODES.items()) + def test_send_error(self, status, exc_type): _, recorder = self._instrument_messaging_service(status=status, payload='{}') msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exc_type) as excinfo: messaging.send(msg) expected = 'Unexpected HTTP response with status: {0}; body: {{}}'.format(status) - assert str(excinfo.value) == expected - assert str(excinfo.value.code) == messaging._MessagingService.UNKNOWN_ERROR + check_exception(excinfo.value, expected, status) assert len(recorder) == 1 assert recorder[0].method == 'POST' assert recorder[0].url == self._get_url('explicit-project-id') @@ -1426,7 +1427,7 @@ def test_send_error(self, status): body = {'message': messaging._MessagingService.JSON_ENCODER.default(msg)} assert json.loads(recorder[0].body.decode()) == body - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_detailed_error(self, status): payload = json.dumps({ 'error': { @@ -1436,17 +1437,16 @@ def test_send_detailed_error(self, status): }) _, recorder = self._instrument_messaging_service(status=status, payload=payload) msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exceptions.InvalidArgumentError) as excinfo: messaging.send(msg) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'invalid-argument' + check_exception(excinfo.value, 'test error', status) assert len(recorder) == 1 assert recorder[0].method == 'POST' assert recorder[0].url == self._get_url('explicit-project-id') body = {'message': messaging._MessagingService.JSON_ENCODER.default(msg)} assert json.loads(recorder[0].body.decode()) == body - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_canonical_error_code(self, status): payload = json.dumps({ 'error': { @@ -1456,18 +1456,18 @@ def test_send_canonical_error_code(self, status): }) _, recorder = self._instrument_messaging_service(status=status, payload=payload) msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: messaging.send(msg) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' + check_exception(excinfo.value, 'test error', status) assert len(recorder) == 1 assert recorder[0].method == 'POST' assert recorder[0].url == self._get_url('explicit-project-id') body = {'message': messaging._MessagingService.JSON_ENCODER.default(msg)} assert json.loads(recorder[0].body.decode()) == body - @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_fcm_error_code(self, status): + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) + @pytest.mark.parametrize('fcm_error_code, exc_type', FCM_ERROR_CODES.items()) + def test_send_fcm_error_code(self, status, fcm_error_code, exc_type): payload = json.dumps({ 'error': { 'status': 'INVALID_ARGUMENT', @@ -1475,17 +1475,41 @@ def test_send_fcm_error_code(self, status): 'details': [ { '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', - 'errorCode': 'UNREGISTERED', + 'errorCode': fcm_error_code, + }, + ], + } + }) + _, recorder = self._instrument_messaging_service(status=status, payload=payload) + msg = messaging.Message(topic='foo') + with pytest.raises(exc_type) as excinfo: + messaging.send(msg) + check_exception(excinfo.value, 'test error', status) + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == self._get_url('explicit-project-id') + body = {'message': messaging._MessagingService.JSON_ENCODER.default(msg)} + assert json.loads(recorder[0].body.decode()) == body + + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) + def test_send_unknown_fcm_error_code(self, status): + payload = json.dumps({ + 'error': { + 'status': 'INVALID_ARGUMENT', + 'message': 'test error', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'SOME_UNKNOWN_CODE', }, ], } }) _, recorder = self._instrument_messaging_service(status=status, payload=payload) msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exceptions.InvalidArgumentError) as excinfo: messaging.send(msg) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' + check_exception(excinfo.value, 'test error', status) assert len(recorder) == 1 assert recorder[0].method == 'POST' assert recorder[0].url == self._get_url('explicit-project-id') @@ -1569,7 +1593,7 @@ def test_send_all(self): assert all([r.success for r in batch_response.responses]) assert not any([r.exception for r in batch_response.responses]) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_all_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ @@ -1592,12 +1616,11 @@ def test_send_all_detailed_error(self, status): error_response = batch_response.responses[1] assert error_response.message_id is None assert error_response.success is False - assert error_response.exception is not None exception = error_response.exception - assert str(exception) == 'test error' - assert str(exception.code) == 'invalid-argument' + assert isinstance(exception, exceptions.InvalidArgumentError) + check_exception(exception, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_all_canonical_error_code(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ @@ -1620,13 +1643,13 @@ def test_send_all_canonical_error_code(self, status): error_response = batch_response.responses[1] assert error_response.message_id is None assert error_response.success is False - assert error_response.exception is not None exception = error_response.exception - assert str(exception) == 'test error' - assert str(exception.code) == 'registration-token-not-registered' + assert isinstance(exception, exceptions.NotFoundError) + check_exception(exception, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_all_fcm_error_code(self, status): + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) + @pytest.mark.parametrize('fcm_error_code, exc_type', FCM_ERROR_CODES.items()) + def test_send_all_fcm_error_code(self, status, fcm_error_code, exc_type): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ 'error': { @@ -1635,7 +1658,7 @@ def test_send_all_fcm_error_code(self, status): 'details': [ { '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', - 'errorCode': 'UNREGISTERED', + 'errorCode': fcm_error_code, }, ], } @@ -1654,22 +1677,20 @@ def test_send_all_fcm_error_code(self, status): error_response = batch_response.responses[1] assert error_response.message_id is None assert error_response.success is False - assert error_response.exception is not None exception = error_response.exception - assert str(exception) == 'test error' - assert str(exception.code) == 'registration-token-not-registered' + assert isinstance(exception, exc_type) + check_exception(exception, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_all_batch_error(self, status): + @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) + def test_send_all_batch_error(self, status, exc_type): _ = self._instrument_batch_messaging_service(status=status, payload='{}') msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exc_type) as excinfo: messaging.send_all([msg]) expected = 'Unexpected HTTP response with status: {0}; body: {{}}'.format(status) - assert str(excinfo.value) == expected - assert str(excinfo.value.code) == messaging._MessagingService.UNKNOWN_ERROR + check_exception(excinfo.value, expected, status) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_all_batch_detailed_error(self, status): payload = json.dumps({ 'error': { @@ -1679,12 +1700,11 @@ def test_send_all_batch_detailed_error(self, status): }) _ = self._instrument_batch_messaging_service(status=status, payload=payload) msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exceptions.InvalidArgumentError) as excinfo: messaging.send_all([msg]) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'invalid-argument' + check_exception(excinfo.value, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_all_batch_canonical_error_code(self, status): payload = json.dumps({ 'error': { @@ -1694,12 +1714,11 @@ def test_send_all_batch_canonical_error_code(self, status): }) _ = self._instrument_batch_messaging_service(status=status, payload=payload) msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: messaging.send_all([msg]) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' + check_exception(excinfo.value, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_all_batch_fcm_error_code(self, status): payload = json.dumps({ 'error': { @@ -1715,10 +1734,9 @@ def test_send_all_batch_fcm_error_code(self, status): }) _ = self._instrument_batch_messaging_service(status=status, payload=payload) msg = messaging.Message(topic='foo') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(messaging.UnregisteredError) as excinfo: messaging.send_all([msg]) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' + check_exception(excinfo.value, 'test error', status) class TestSendMulticast(TestBatch): @@ -1750,7 +1768,7 @@ def test_send_multicast(self): assert all([r.success for r in batch_response.responses]) assert not any([r.exception for r in batch_response.responses]) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_multicast_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ @@ -1775,10 +1793,10 @@ def test_send_multicast_detailed_error(self, status): assert error_response.success is False assert error_response.exception is not None exception = error_response.exception - assert str(exception) == 'test error' - assert str(exception.code) == 'invalid-argument' + assert isinstance(exception, exceptions.InvalidArgumentError) + check_exception(exception, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_multicast_canonical_error_code(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ @@ -1803,10 +1821,10 @@ def test_send_multicast_canonical_error_code(self, status): assert error_response.success is False assert error_response.exception is not None exception = error_response.exception - assert str(exception) == 'test error' - assert str(exception.code) == 'registration-token-not-registered' + assert isinstance(exception, exceptions.NotFoundError) + check_exception(exception, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_multicast_fcm_error_code(self, status): success_payload = json.dumps({'name': 'message-id'}) error_payload = json.dumps({ @@ -1837,20 +1855,19 @@ def test_send_multicast_fcm_error_code(self, status): assert error_response.success is False assert error_response.exception is not None exception = error_response.exception - assert str(exception) == 'test error' - assert str(exception.code) == 'registration-token-not-registered' + assert isinstance(exception, messaging.UnregisteredError) + check_exception(exception, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_send_multicast_batch_error(self, status): + @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) + def test_send_multicast_batch_error(self, status, exc_type): _ = self._instrument_batch_messaging_service(status=status, payload='{}') msg = messaging.MulticastMessage(tokens=['foo']) - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exc_type) as excinfo: messaging.send_multicast(msg) expected = 'Unexpected HTTP response with status: {0}; body: {{}}'.format(status) - assert str(excinfo.value) == expected - assert str(excinfo.value.code) == messaging._MessagingService.UNKNOWN_ERROR + check_exception(excinfo.value, expected, status) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_multicast_batch_detailed_error(self, status): payload = json.dumps({ 'error': { @@ -1860,12 +1877,11 @@ def test_send_multicast_batch_detailed_error(self, status): }) _ = self._instrument_batch_messaging_service(status=status, payload=payload) msg = messaging.MulticastMessage(tokens=['foo']) - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exceptions.InvalidArgumentError) as excinfo: messaging.send_multicast(msg) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'invalid-argument' + check_exception(excinfo.value, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_multicast_batch_canonical_error_code(self, status): payload = json.dumps({ 'error': { @@ -1875,12 +1891,11 @@ def test_send_multicast_batch_canonical_error_code(self, status): }) _ = self._instrument_batch_messaging_service(status=status, payload=payload) msg = messaging.MulticastMessage(tokens=['foo']) - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: messaging.send_multicast(msg) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' + check_exception(excinfo.value, 'test error', status) - @pytest.mark.parametrize('status', HTTP_ERRORS) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_multicast_batch_fcm_error_code(self, status): payload = json.dumps({ 'error': { @@ -1896,10 +1911,9 @@ def test_send_multicast_batch_fcm_error_code(self, status): }) _ = self._instrument_batch_messaging_service(status=status, payload=payload) msg = messaging.MulticastMessage(tokens=['foo']) - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(messaging.UnregisteredError) as excinfo: messaging.send_multicast(msg) - assert str(excinfo.value) == 'test error' - assert str(excinfo.value.code) == 'registration-token-not-registered' + check_exception(excinfo.value, 'test error', status) class TestTopicManagement(object): @@ -1977,30 +1991,24 @@ def test_subscribe_to_topic(self, args): assert recorder[0].url == self._get_url('iid/v1:batchAdd') assert json.loads(recorder[0].body.decode()) == args[2] - @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_subscribe_to_topic_error(self, status): + @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) + def test_subscribe_to_topic_error(self, status, exc_type): _, recorder = self._instrument_iid_service( status=status, payload=self._DEFAULT_ERROR_RESPONSE) - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exc_type) as excinfo: messaging.subscribe_to_topic('foo', 'test-topic') assert str(excinfo.value) == 'error_reason' - code = messaging._MessagingService.IID_ERROR_CODES.get( - status, messaging._MessagingService.UNKNOWN_ERROR) - assert excinfo.value.code == code assert len(recorder) == 1 assert recorder[0].method == 'POST' assert recorder[0].url == self._get_url('iid/v1:batchAdd') - @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_subscribe_to_topic_non_json_error(self, status): + @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) + def test_subscribe_to_topic_non_json_error(self, status, exc_type): _, recorder = self._instrument_iid_service(status=status, payload='not json') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exc_type) as excinfo: messaging.subscribe_to_topic('foo', 'test-topic') reason = 'Unexpected HTTP response with status: {0}; body: not json'.format(status) - code = messaging._MessagingService.IID_ERROR_CODES.get( - status, messaging._MessagingService.UNKNOWN_ERROR) assert str(excinfo.value) == reason - assert excinfo.value.code == code assert len(recorder) == 1 assert recorder[0].method == 'POST' assert recorder[0].url == self._get_url('iid/v1:batchAdd') @@ -2015,30 +2023,24 @@ def test_unsubscribe_from_topic(self, args): assert recorder[0].url == self._get_url('iid/v1:batchRemove') assert json.loads(recorder[0].body.decode()) == args[2] - @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_unsubscribe_from_topic_error(self, status): + @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) + def test_unsubscribe_from_topic_error(self, status, exc_type): _, recorder = self._instrument_iid_service( status=status, payload=self._DEFAULT_ERROR_RESPONSE) - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exc_type) as excinfo: messaging.unsubscribe_from_topic('foo', 'test-topic') assert str(excinfo.value) == 'error_reason' - code = messaging._MessagingService.IID_ERROR_CODES.get( - status, messaging._MessagingService.UNKNOWN_ERROR) - assert excinfo.value.code == code assert len(recorder) == 1 assert recorder[0].method == 'POST' assert recorder[0].url == self._get_url('iid/v1:batchRemove') - @pytest.mark.parametrize('status', HTTP_ERRORS) - def test_unsubscribe_from_topic_non_json_error(self, status): + @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) + def test_unsubscribe_from_topic_non_json_error(self, status, exc_type): _, recorder = self._instrument_iid_service(status=status, payload='not json') - with pytest.raises(messaging.ApiCallError) as excinfo: + with pytest.raises(exc_type) as excinfo: messaging.unsubscribe_from_topic('foo', 'test-topic') reason = 'Unexpected HTTP response with status: {0}; body: not json'.format(status) - code = messaging._MessagingService.IID_ERROR_CODES.get( - status, messaging._MessagingService.UNKNOWN_ERROR) assert str(excinfo.value) == reason - assert excinfo.value.code == code assert len(recorder) == 1 assert recorder[0].method == 'POST' assert recorder[0].url == self._get_url('iid/v1:batchRemove') diff --git a/tests/test_project_management.py b/tests/test_project_management.py index 9de95f7fd..e8353e212 100644 --- a/tests/test_project_management.py +++ b/tests/test_project_management.py @@ -20,6 +20,7 @@ import pytest import firebase_admin +from firebase_admin import exceptions from firebase_admin import project_management from tests import testutils @@ -172,10 +173,10 @@ 'configFileContents': TEST_APP_ENCODED_CONFIG, }) -SHA_1_CERTIFICATE = project_management.ShaCertificate( +SHA_1_CERTIFICATE = project_management.SHACertificate( '123456789a123456789a123456789a123456789a', 'projects/-/androidApps/1:12345678:android:deadbeef/sha/name1') -SHA_256_CERTIFICATE = project_management.ShaCertificate( +SHA_256_CERTIFICATE = project_management.SHACertificate( '123456789a123456789a123456789a123456789a123456789a123456789a1234', 'projects/-/androidApps/1:12345678:android:deadbeef/sha/name256') GET_SHA_CERTIFICATES_RESPONSE = json.dumps({'certificates': [ @@ -189,13 +190,17 @@ app_id='1:12345678:android:deadbeef', display_name='My Android App', project_id='test-project-id') -IOS_APP_METADATA = project_management.IosAppMetadata( +IOS_APP_METADATA = project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='1:12345678:android:deadbeef', display_name='My iOS App', project_id='test-project-id') +ALREADY_EXISTS_RESPONSE = ('{"error": {"status": "ALREADY_EXISTS", ' + '"message": "The resource already exists"}}') +NOT_FOUND_RESPONSE = '{"error": {"message": "Failed to find the resource"}}' +UNAVAILABLE_RESPONSE = '{"error": {"message": "Backend servers are over capacity"}}' class TestAndroidAppMetadata(object): @@ -310,12 +315,12 @@ def test_android_app_metadata_project_id(self): assert ANDROID_APP_METADATA.project_id == 'test-project-id' -class TestIosAppMetadata(object): +class TestIOSAppMetadata(object): def test_create_ios_app_metadata_errors(self): # bundle_id must be a non-empty string. with pytest.raises(ValueError): - project_management.IosAppMetadata( + project_management.IOSAppMetadata( bundle_id='', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='1:12345678:android:deadbeef', @@ -323,7 +328,7 @@ def test_create_ios_app_metadata_errors(self): project_id='test-project-id') # name must be a non-empty string. with pytest.raises(ValueError): - project_management.IosAppMetadata( + project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='', app_id='1:12345678:android:deadbeef', @@ -331,7 +336,7 @@ def test_create_ios_app_metadata_errors(self): project_id='test-project-id') # app_id must be a non-empty string. with pytest.raises(ValueError): - project_management.IosAppMetadata( + project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='', @@ -339,7 +344,7 @@ def test_create_ios_app_metadata_errors(self): project_id='test-project-id') # display_name must be a string or None. with pytest.raises(ValueError): - project_management.IosAppMetadata( + project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='1:12345678:android:deadbeef', @@ -347,7 +352,7 @@ def test_create_ios_app_metadata_errors(self): project_id='test-project-id') # project_id must be a nonempty string. with pytest.raises(ValueError): - project_management.IosAppMetadata( + project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='1:12345678:android:deadbeef', @@ -356,37 +361,37 @@ def test_create_ios_app_metadata_errors(self): def test_ios_app_metadata_eq_and_hash(self): metadata_1 = IOS_APP_METADATA - metadata_2 = project_management.IosAppMetadata( + metadata_2 = project_management.IOSAppMetadata( bundle_id='different', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='1:12345678:android:deadbeef', display_name='My iOS App', project_id='test-project-id') - metadata_3 = project_management.IosAppMetadata( + metadata_3 = project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='different', app_id='1:12345678:android:deadbeef', display_name='My iOS App', project_id='test-project-id') - metadata_4 = project_management.IosAppMetadata( + metadata_4 = project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='different', display_name='My iOS App', project_id='test-project-id') - metadata_5 = project_management.IosAppMetadata( + metadata_5 = project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='1:12345678:android:deadbeef', display_name='different', project_id='test-project-id') - metadata_6 = project_management.IosAppMetadata( + metadata_6 = project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='1:12345678:android:deadbeef', display_name='My iOS App', project_id='different') - metadata_7 = project_management.IosAppMetadata( + metadata_7 = project_management.IOSAppMetadata( bundle_id='com.hello.world.ios', name='projects/test-project-id/iosApps/1:12345678:ios:ca5cade5', app_id='1:12345678:android:deadbeef', @@ -422,40 +427,40 @@ def test_ios_app_metadata_project_id(self): assert IOS_APP_METADATA.project_id == 'test-project-id' -class TestShaCertificate(object): +class TestSHACertificate(object): def test_create_sha_certificate_errors(self): # sha_hash cannot be None. with pytest.raises(ValueError): - project_management.ShaCertificate(sha_hash=None) + project_management.SHACertificate(sha_hash=None) # sha_hash must be a string. with pytest.raises(ValueError): - project_management.ShaCertificate(sha_hash=0x123456789a123456789a123456789a123456789a) + project_management.SHACertificate(sha_hash=0x123456789a123456789a123456789a123456789a) # sha_hash must be a valid SHA-1 or SHA-256 hash. with pytest.raises(ValueError): - project_management.ShaCertificate(sha_hash='123456789a123456789') + project_management.SHACertificate(sha_hash='123456789a123456789') with pytest.raises(ValueError): - project_management.ShaCertificate(sha_hash='123456789a123456789a123456789a123456oops') + project_management.SHACertificate(sha_hash='123456789a123456789a123456789a123456oops') def test_sha_certificate_eq(self): - sha_cert_1 = project_management.ShaCertificate( + sha_cert_1 = project_management.SHACertificate( '123456789a123456789a123456789a123456789a', 'projects/-/androidApps/1:12345678:android:deadbeef/sha/name1') # sha_hash is different from sha_cert_1, but name is the same. - sha_cert_2 = project_management.ShaCertificate( + sha_cert_2 = project_management.SHACertificate( '0000000000000000000000000000000000000000', 'projects/-/androidApps/1:12345678:android:deadbeef/sha/name1') # name is different from sha_cert_1, but sha_hash is the same. - sha_cert_3 = project_management.ShaCertificate( + sha_cert_3 = project_management.SHACertificate( '123456789a123456789a123456789a123456789a', None) # name is different from sha_cert_1, but sha_hash is the same. - sha_cert_4 = project_management.ShaCertificate( + sha_cert_4 = project_management.SHACertificate( '123456789a123456789a123456789a123456789a', 'projects/-/androidApps/{0}/sha/notname1') # sha_hash and cert_type are different from sha_cert_1, but name is the same. - sha_cert_5 = project_management.ShaCertificate( + sha_cert_5 = project_management.SHACertificate( '123456789a123456789a123456789a123456789a123456789a123456789a1234', 'projects/-/androidApps/{0}/sha/name1') # Exactly the same as sha_cert_1. - sha_cert_6 = project_management.ShaCertificate( + sha_cert_6 = project_management.SHACertificate( '123456789a123456789a123456789a123456789a', 'projects/-/androidApps/1:12345678:android:deadbeef/sha/name1') not_a_sha_cert = { @@ -578,15 +583,16 @@ def test_create_android_app(self): recorder[2], 'GET', 'https://firebase.googleapis.com/v1/operations/abcdefg') def test_create_android_app_already_exists(self): - recorder = self._instrument_service(statuses=[409], responses=['some error response']) + recorder = self._instrument_service(statuses=[409], responses=[ALREADY_EXISTS_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.AlreadyExistsError) as excinfo: project_management.create_android_app( package_name='com.hello.world.android', display_name='My Android App') assert 'The resource already exists' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_create_android_app_polling_rpc_error(self): @@ -595,16 +601,17 @@ def test_create_android_app_polling_rpc_error(self): responses=[ OPERATION_IN_PROGRESS_RESPONSE, # Request to create Android app asynchronously. OPERATION_IN_PROGRESS_RESPONSE, # Creation operation is still not done. - 'some error response', # Error 503. + UNAVAILABLE_RESPONSE, # Error 503. ]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnavailableError) as excinfo: project_management.create_android_app( package_name='com.hello.world.android', display_name='My Android App') assert 'Backend servers are over capacity' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 3 def test_create_android_app_polling_failure(self): @@ -616,13 +623,14 @@ def test_create_android_app_polling_failure(self): OPERATION_FAILED_RESPONSE, # Operation is finished, but terminated with an error. ]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnknownError) as excinfo: project_management.create_android_app( package_name='com.hello.world.android', display_name='My Android App') assert 'Polling finished, but the operation terminated in an error' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is None + assert excinfo.value.http_response is not None assert len(recorder) == 3 def test_create_android_app_polling_limit_exceeded(self): @@ -635,17 +643,17 @@ def test_create_android_app_polling_limit_exceeded(self): OPERATION_IN_PROGRESS_RESPONSE, # Creation Operation is still not done. ]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.DeadlineExceededError) as excinfo: project_management.create_android_app( package_name='com.hello.world.android', display_name='My Android App') assert 'Polling deadline exceeded' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is None assert len(recorder) == 3 -class TestCreateIosApp(BaseProjectManagementTest): +class TestCreateIOSApp(BaseProjectManagementTest): _CREATION_URL = 'https://firebase.googleapis.com/v1beta1/projects/test-project-id/iosApps' def test_create_ios_app_without_display_name(self): @@ -663,7 +671,7 @@ def test_create_ios_app_without_display_name(self): assert ios_app.app_id == '1:12345678:ios:ca5cade5' assert len(recorder) == 3 body = {'bundleId': 'com.hello.world.ios'} - self._assert_request_is_correct(recorder[0], 'POST', TestCreateIosApp._CREATION_URL, body) + self._assert_request_is_correct(recorder[0], 'POST', TestCreateIOSApp._CREATION_URL, body) self._assert_request_is_correct( recorder[1], 'GET', 'https://firebase.googleapis.com/v1/operations/abcdefg') self._assert_request_is_correct( @@ -688,22 +696,23 @@ def test_create_ios_app(self): 'bundleId': 'com.hello.world.ios', 'displayName': 'My iOS App', } - self._assert_request_is_correct(recorder[0], 'POST', TestCreateIosApp._CREATION_URL, body) + self._assert_request_is_correct(recorder[0], 'POST', TestCreateIOSApp._CREATION_URL, body) self._assert_request_is_correct( recorder[1], 'GET', 'https://firebase.googleapis.com/v1/operations/abcdefg') self._assert_request_is_correct( recorder[2], 'GET', 'https://firebase.googleapis.com/v1/operations/abcdefg') def test_create_ios_app_already_exists(self): - recorder = self._instrument_service(statuses=[409], responses=['some error response']) + recorder = self._instrument_service(statuses=[409], responses=[ALREADY_EXISTS_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.AlreadyExistsError) as excinfo: project_management.create_ios_app( bundle_id='com.hello.world.ios', display_name='My iOS App') assert 'The resource already exists' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_create_ios_app_polling_rpc_error(self): @@ -712,16 +721,17 @@ def test_create_ios_app_polling_rpc_error(self): responses=[ OPERATION_IN_PROGRESS_RESPONSE, # Request to create iOS app asynchronously. OPERATION_IN_PROGRESS_RESPONSE, # Creation operation is still not done. - 'some error response', # Error 503. + UNAVAILABLE_RESPONSE, # Error 503. ]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnavailableError) as excinfo: project_management.create_ios_app( bundle_id='com.hello.world.ios', display_name='My iOS App') assert 'Backend servers are over capacity' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 3 def test_create_ios_app_polling_failure(self): @@ -733,13 +743,14 @@ def test_create_ios_app_polling_failure(self): OPERATION_FAILED_RESPONSE, # Operation is finished, but terminated with an error. ]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnknownError) as excinfo: project_management.create_ios_app( bundle_id='com.hello.world.ios', display_name='My iOS App') assert 'Polling finished, but the operation terminated in an error' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is None + assert excinfo.value.http_response is not None assert len(recorder) == 3 def test_create_ios_app_polling_limit_exceeded(self): @@ -752,13 +763,13 @@ def test_create_ios_app_polling_limit_exceeded(self): OPERATION_IN_PROGRESS_RESPONSE, # Creation Operation is still not done. ]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.DeadlineExceededError) as excinfo: project_management.create_ios_app( bundle_id='com.hello.world.ios', display_name='My iOS App') assert 'Polling deadline exceeded' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is None assert len(recorder) == 3 @@ -779,13 +790,14 @@ def test_list_android_apps(self): self._assert_request_is_correct(recorder[0], 'GET', TestListAndroidApps._LISTING_URL) def test_list_android_apps_rpc_error(self): - recorder = self._instrument_service(statuses=[503], responses=['some error response']) + recorder = self._instrument_service(statuses=[503], responses=[UNAVAILABLE_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnavailableError) as excinfo: project_management.list_android_apps() assert 'Backend servers are over capacity' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_list_android_apps_empty_list(self): @@ -813,17 +825,18 @@ def test_list_android_apps_multiple_pages(self): def test_list_android_apps_multiple_pages_rpc_error(self): recorder = self._instrument_service( statuses=[200, 503], - responses=[LIST_ANDROID_APPS_PAGE_1_RESPONSE, 'some error response']) + responses=[LIST_ANDROID_APPS_PAGE_1_RESPONSE, UNAVAILABLE_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnavailableError) as excinfo: project_management.list_android_apps() assert 'Backend servers are over capacity' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 2 -class TestListIosApps(BaseProjectManagementTest): +class TestListIOSApps(BaseProjectManagementTest): _LISTING_URL = ('https://firebase.googleapis.com/v1beta1/projects/test-project-id/' 'iosApps?pageSize=100') _LISTING_PAGE_2_URL = ('https://firebase.googleapis.com/v1beta1/projects/test-project-id/' @@ -837,16 +850,17 @@ def test_list_ios_apps(self): expected_app_ids = set(['1:12345678:ios:ca5cade5', '1:12345678:ios:ca5cade5cafe']) assert set(app.app_id for app in ios_apps) == expected_app_ids assert len(recorder) == 1 - self._assert_request_is_correct(recorder[0], 'GET', TestListIosApps._LISTING_URL) + self._assert_request_is_correct(recorder[0], 'GET', TestListIOSApps._LISTING_URL) def test_list_ios_apps_rpc_error(self): - recorder = self._instrument_service(statuses=[503], responses=['some error response']) + recorder = self._instrument_service(statuses=[503], responses=[UNAVAILABLE_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnavailableError) as excinfo: project_management.list_ios_apps() assert 'Backend servers are over capacity' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_list_ios_apps_empty_list(self): @@ -856,7 +870,7 @@ def test_list_ios_apps_empty_list(self): assert ios_apps == [] assert len(recorder) == 1 - self._assert_request_is_correct(recorder[0], 'GET', TestListIosApps._LISTING_URL) + self._assert_request_is_correct(recorder[0], 'GET', TestListIOSApps._LISTING_URL) def test_list_ios_apps_multiple_pages(self): recorder = self._instrument_service( @@ -868,19 +882,20 @@ def test_list_ios_apps_multiple_pages(self): expected_app_ids = set(['1:12345678:ios:ca5cade5', '1:12345678:ios:ca5cade5cafe']) assert set(app.app_id for app in ios_apps) == expected_app_ids assert len(recorder) == 2 - self._assert_request_is_correct(recorder[0], 'GET', TestListIosApps._LISTING_URL) - self._assert_request_is_correct(recorder[1], 'GET', TestListIosApps._LISTING_PAGE_2_URL) + self._assert_request_is_correct(recorder[0], 'GET', TestListIOSApps._LISTING_URL) + self._assert_request_is_correct(recorder[1], 'GET', TestListIOSApps._LISTING_PAGE_2_URL) def test_list_ios_apps_multiple_pages_rpc_error(self): recorder = self._instrument_service( statuses=[200, 503], - responses=[LIST_IOS_APPS_PAGE_1_RESPONSE, 'some error response']) + responses=[LIST_IOS_APPS_PAGE_1_RESPONSE, UNAVAILABLE_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnavailableError) as excinfo: project_management.list_ios_apps() assert 'Backend servers are over capacity' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 2 @@ -936,21 +951,24 @@ def test_get_metadata_unknown_error(self, android_app): recorder = self._instrument_service( statuses=[428], responses=['precondition required error']) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnknownError) as excinfo: android_app.get_metadata() - assert 'Error 428' in str(excinfo.value) - assert excinfo.value.detail is not None + message = 'Unexpected HTTP response with status: 428; body: precondition required error' + assert str(excinfo.value) == message + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_get_metadata_not_found(self, android_app): - recorder = self._instrument_service(statuses=[404], responses=['some error response']) + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: android_app.get_metadata() assert 'Failed to find the resource' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_set_display_name(self, android_app): @@ -965,14 +983,15 @@ def test_set_display_name(self, android_app): recorder[0], 'PATCH', TestAndroidApp._SET_DISPLAY_NAME_URL, body) def test_set_display_name_not_found(self, android_app): - recorder = self._instrument_service(statuses=[404], responses=['some error response']) + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) new_display_name = 'A new display name!' - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: android_app.set_display_name(new_display_name) assert 'Failed to find the resource' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_get_config(self, android_app): @@ -985,13 +1004,14 @@ def test_get_config(self, android_app): self._assert_request_is_correct(recorder[0], 'GET', TestAndroidApp._GET_CONFIG_URL) def test_get_config_not_found(self, android_app): - recorder = self._instrument_service(statuses=[404], responses=['some error response']) + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: android_app.get_config() assert 'Failed to find the resource' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_get_sha_certificates(self, android_app): @@ -1005,13 +1025,14 @@ def test_get_sha_certificates(self, android_app): self._assert_request_is_correct(recorder[0], 'GET', TestAndroidApp._LIST_CERTS_URL) def test_get_sha_certificates_not_found(self, android_app): - recorder = self._instrument_service(statuses=[404], responses=['some error response']) + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: android_app.get_sha_certificates() assert 'Failed to find the resource' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_add_certificate_none_error(self, android_app): @@ -1022,7 +1043,7 @@ def test_add_sha_1_certificate(self, android_app): recorder = self._instrument_service(statuses=[200], responses=[json.dumps({})]) android_app.add_sha_certificate( - project_management.ShaCertificate('123456789a123456789a123456789a123456789a')) + project_management.SHACertificate('123456789a123456789a123456789a123456789a')) assert len(recorder) == 1 body = {'shaHash': '123456789a123456789a123456789a123456789a', 'certType': 'SHA_1'} @@ -1031,7 +1052,7 @@ def test_add_sha_1_certificate(self, android_app): def test_add_sha_256_certificate(self, android_app): recorder = self._instrument_service(statuses=[200], responses=[json.dumps({})]) - android_app.add_sha_certificate(project_management.ShaCertificate( + android_app.add_sha_certificate(project_management.SHACertificate( '123456789a123456789a123456789a123456789a123456789a123456789a1234')) assert len(recorder) == 1 @@ -1042,14 +1063,15 @@ def test_add_sha_256_certificate(self, android_app): self._assert_request_is_correct(recorder[0], 'POST', TestAndroidApp._ADD_CERT_URL, body) def test_add_sha_certificates_already_exists(self, android_app): - recorder = self._instrument_service(statuses=[409], responses=['some error response']) + recorder = self._instrument_service(statuses=[409], responses=[ALREADY_EXISTS_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.AlreadyExistsError) as excinfo: android_app.add_sha_certificate( - project_management.ShaCertificate('123456789a123456789a123456789a123456789a')) + project_management.SHACertificate('123456789a123456789a123456789a123456789a')) assert 'The resource already exists' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_delete_certificate_none_error(self, android_app): @@ -1075,13 +1097,14 @@ def test_delete_sha_256_certificate(self, android_app): recorder[0], 'DELETE', TestAndroidApp._DELETE_SHA_256_CERT_URL) def test_delete_sha_certificates_not_found(self, android_app): - recorder = self._instrument_service(statuses=[404], responses=['some error response']) + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: android_app.delete_sha_certificate(SHA_1_CERTIFICATE) assert 'Failed to find the resource' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_raises_if_app_has_no_project_id(self): @@ -1094,7 +1117,7 @@ def evaluate(): testutils.run_without_project_id(evaluate) -class TestIosApp(BaseProjectManagementTest): +class TestIOSApp(BaseProjectManagementTest): _GET_METADATA_URL = ('https://firebase.googleapis.com/v1beta1/projects/-/iosApps/' '1:12345678:ios:ca5cade5') _SET_DISPLAY_NAME_URL = ('https://firebase.googleapis.com/v1beta1/projects/-/iosApps/' @@ -1118,7 +1141,7 @@ def test_get_metadata_no_display_name(self, ios_app): assert metadata.project_id == 'test-project-id' assert metadata.bundle_id == 'com.hello.world.ios' assert len(recorder) == 1 - self._assert_request_is_correct(recorder[0], 'GET', TestIosApp._GET_METADATA_URL) + self._assert_request_is_correct(recorder[0], 'GET', TestIOSApp._GET_METADATA_URL) def test_get_metadata(self, ios_app): recorder = self._instrument_service(statuses=[200], responses=[IOS_APP_METADATA_RESPONSE]) @@ -1131,27 +1154,30 @@ def test_get_metadata(self, ios_app): assert metadata.project_id == 'test-project-id' assert metadata.bundle_id == 'com.hello.world.ios' assert len(recorder) == 1 - self._assert_request_is_correct(recorder[0], 'GET', TestIosApp._GET_METADATA_URL) + self._assert_request_is_correct(recorder[0], 'GET', TestIOSApp._GET_METADATA_URL) def test_get_metadata_unknown_error(self, ios_app): recorder = self._instrument_service( statuses=[428], responses=['precondition required error']) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.UnknownError) as excinfo: ios_app.get_metadata() - assert 'Error 428' in str(excinfo.value) - assert excinfo.value.detail is not None + message = 'Unexpected HTTP response with status: 428; body: precondition required error' + assert str(excinfo.value) == message + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_get_metadata_not_found(self, ios_app): - recorder = self._instrument_service(statuses=[404], responses=['some error response']) + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: ios_app.get_metadata() assert 'Failed to find the resource' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_set_display_name(self, ios_app): @@ -1163,17 +1189,18 @@ def test_set_display_name(self, ios_app): assert len(recorder) == 1 body = {'displayName': new_display_name} self._assert_request_is_correct( - recorder[0], 'PATCH', TestIosApp._SET_DISPLAY_NAME_URL, body) + recorder[0], 'PATCH', TestIOSApp._SET_DISPLAY_NAME_URL, body) def test_set_display_name_not_found(self, ios_app): - recorder = self._instrument_service(statuses=[404], responses=['some error response']) + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) new_display_name = 'A new display name!' - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: ios_app.set_display_name(new_display_name) assert 'Failed to find the resource' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_get_config(self, ios_app): @@ -1183,16 +1210,17 @@ def test_get_config(self, ios_app): assert config == 'hello world' assert len(recorder) == 1 - self._assert_request_is_correct(recorder[0], 'GET', TestIosApp._GET_CONFIG_URL) + self._assert_request_is_correct(recorder[0], 'GET', TestIOSApp._GET_CONFIG_URL) def test_get_config_not_found(self, ios_app): - recorder = self._instrument_service(statuses=[404], responses=['some error response']) + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) - with pytest.raises(project_management.ApiCallError) as excinfo: + with pytest.raises(exceptions.NotFoundError) as excinfo: ios_app.get_config() assert 'Failed to find the resource' in str(excinfo.value) - assert excinfo.value.detail is not None + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None assert len(recorder) == 1 def test_raises_if_app_has_no_project_id(self): diff --git a/tests/test_token_gen.py b/tests/test_token_gen.py index 412ba3d0e..e016b8fb1 100644 --- a/tests/test_token_gen.py +++ b/tests/test_token_gen.py @@ -21,8 +21,8 @@ import time from google.auth import crypt -from google.auth import exceptions from google.auth import jwt +import google.auth.exceptions import google.oauth2.id_token import pytest from pytest_localserver import plugin @@ -31,6 +31,7 @@ import firebase_admin from firebase_admin import auth from firebase_admin import credentials +from firebase_admin import exceptions from firebase_admin import _token_gen from tests import testutils @@ -45,6 +46,15 @@ INVALID_STRINGS = [None, '', 0, 1, True, False, list(), tuple(), dict()] INVALID_BOOLS = [None, '', 'foo', 0, 1, list(), tuple(), dict()] +INVALID_JWT_ARGS = { + 'NoneToken': None, + 'EmptyToken': '', + 'BoolToken': True, + 'IntToken': 1, + 'ListToken': [], + 'EmptyDictToken': {}, + 'NonEmptyDictToken': {'a': 1}, +} # Fixture for mocking a HTTP server httpserver = plugin.httpserver @@ -219,10 +229,12 @@ def test_sign_with_iam_error(self): try: iam_resp = '{"error": {"code": 403, "message": "test error"}}' _overwrite_iam_request(app, testutils.MockRequest(403, iam_resp)) - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.TokenSignError) as excinfo: auth.create_custom_token(MOCK_UID, app=app) - assert excinfo.value.code == _token_gen.TOKEN_SIGN_ERROR - assert iam_resp in str(excinfo.value) + error = excinfo.value + assert error.code == exceptions.UNKNOWN + assert iam_resp in str(error) + assert isinstance(error.cause, google.auth.exceptions.TransportError) finally: firebase_admin.delete_app(app) @@ -298,17 +310,38 @@ def test_valid_args(self, user_mgt_app, expires_in): assert request == {'idToken' : 'id_token', 'validDuration': 3600} def test_error(self, user_mgt_app): - _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') - with pytest.raises(auth.AuthError) as excinfo: + _instrument_user_manager(user_mgt_app, 500, '{"error":{"message": "INVALID_ID_TOKEN"}}') + with pytest.raises(auth.InvalidIdTokenError) as excinfo: auth.create_session_cookie('id_token', expires_in=3600, app=user_mgt_app) - assert excinfo.value.code == _token_gen.COOKIE_CREATE_ERROR - assert '{"error":"test"}' in str(excinfo.value) + assert excinfo.value.code == exceptions.INVALID_ARGUMENT + assert str(excinfo.value) == 'The provided ID token is invalid (INVALID_ID_TOKEN).' + + def test_error_with_details(self, user_mgt_app): + _instrument_user_manager( + user_mgt_app, 500, '{"error":{"message": "INVALID_ID_TOKEN: More details."}}') + with pytest.raises(auth.InvalidIdTokenError) as excinfo: + auth.create_session_cookie('id_token', expires_in=3600, app=user_mgt_app) + assert excinfo.value.code == exceptions.INVALID_ARGUMENT + expected = 'The provided ID token is invalid (INVALID_ID_TOKEN). More details.' + assert str(excinfo.value) == expected + + def test_unexpected_error_code(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 500, '{"error":{"message": "SOMETHING_UNUSUAL"}}') + with pytest.raises(exceptions.InternalError) as excinfo: + auth.create_session_cookie('id_token', expires_in=3600, app=user_mgt_app) + assert str(excinfo.value) == 'Error while calling Auth service (SOMETHING_UNUSUAL).' + + def test_unexpected_error_response(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 500, '{}') + with pytest.raises(exceptions.InternalError) as excinfo: + auth.create_session_cookie('id_token', expires_in=3600, app=user_mgt_app) + assert str(excinfo.value) == 'Unexpected error response: {}' def test_unexpected_response(self, user_mgt_app): _instrument_user_manager(user_mgt_app, 200, '{}') - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.UnexpectedResponseError) as excinfo: auth.create_session_cookie('id_token', expires_in=3600, app=user_mgt_app) - assert excinfo.value.code == _token_gen.COOKIE_CREATE_ERROR + assert excinfo.value.code == exceptions.UNKNOWN assert 'Failed to create session cookie' in str(excinfo.value) @@ -339,13 +372,6 @@ class TestVerifyIdToken(object): 'iat': int(time.time()) - 10000, 'exp': int(time.time()) - 3600 }), - 'NoneToken': None, - 'EmptyToken': '', - 'BoolToken': True, - 'IntToken': 1, - 'ListToken': [], - 'EmptyDictToken': {}, - 'NonEmptyDictToken': {'a': 1}, 'BadFormatToken': 'foobar' } @@ -368,9 +394,8 @@ def test_valid_token_check_revoked(self, user_mgt_app, id_token): def test_revoked_token_check_revoked(self, user_mgt_app, revoked_tokens, id_token): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) _instrument_user_manager(user_mgt_app, 200, revoked_tokens) - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.RevokedIdTokenError) as excinfo: auth.verify_id_token(id_token, app=user_mgt_app, check_revoked=True) - assert excinfo.value.code == 'ID_TOKEN_REVOKED' assert str(excinfo.value) == 'The Firebase ID token has been revoked.' @pytest.mark.parametrize('arg', INVALID_BOOLS) @@ -387,11 +412,30 @@ def test_revoked_token_do_not_check_revoked(self, user_mgt_app, revoked_tokens, assert claims['admin'] is True assert claims['uid'] == claims['sub'] + @pytest.mark.parametrize('id_token', INVALID_JWT_ARGS.values(), ids=list(INVALID_JWT_ARGS)) + def test_invalid_arg(self, user_mgt_app, id_token): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + with pytest.raises(ValueError) as excinfo: + auth.verify_id_token(id_token, app=user_mgt_app) + assert 'Illegal ID token provided' in str(excinfo.value) + @pytest.mark.parametrize('id_token', invalid_tokens.values(), ids=list(invalid_tokens)) def test_invalid_token(self, user_mgt_app, id_token): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) - with pytest.raises(ValueError): + with pytest.raises(auth.InvalidIdTokenError) as excinfo: + auth.verify_id_token(id_token, app=user_mgt_app) + assert isinstance(excinfo.value, exceptions.InvalidArgumentError) + assert excinfo.value.http_response is None + + def test_expired_token(self, user_mgt_app): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + id_token = self.invalid_tokens['ExpiredToken'] + with pytest.raises(auth.ExpiredIdTokenError) as excinfo: auth.verify_id_token(id_token, app=user_mgt_app) + assert isinstance(excinfo.value, auth.InvalidIdTokenError) + assert 'Token expired' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is None def test_project_id_option(self): app = firebase_admin.initialize_app( @@ -416,13 +460,19 @@ def test_project_id_env_var(self, env_var_app): def test_custom_token(self, auth_app): id_token = auth.create_custom_token(MOCK_UID, app=auth_app) _overwrite_cert_request(auth_app, MOCK_REQUEST) - with pytest.raises(ValueError): + with pytest.raises(auth.InvalidIdTokenError) as excinfo: auth.verify_id_token(id_token, app=auth_app) + message = 'verify_id_token() expects an ID token, but was given a custom token.' + assert str(excinfo.value) == message def test_certificate_request_failure(self, user_mgt_app): _overwrite_cert_request(user_mgt_app, testutils.MockRequest(404, 'not found')) - with pytest.raises(exceptions.TransportError): + with pytest.raises(auth.CertificateFetchError) as excinfo: auth.verify_id_token(TEST_ID_TOKEN, app=user_mgt_app) + assert 'Could not fetch certificates' in str(excinfo.value) + assert isinstance(excinfo.value, exceptions.UnknownError) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is None class TestVerifySessionCookie(object): @@ -447,13 +497,6 @@ class TestVerifySessionCookie(object): 'iat': int(time.time()) - 10000, 'exp': int(time.time()) - 3600 }), - 'NoneCookie': None, - 'EmptyCookie': '', - 'BoolCookie': True, - 'IntCookie': 1, - 'ListCookie': [], - 'EmptyDictCookie': {}, - 'NonEmptyDictCookie': {'a': 1}, 'BadFormatCookie': 'foobar', 'IDToken': TEST_ID_TOKEN, } @@ -477,9 +520,8 @@ def test_valid_cookie_check_revoked(self, user_mgt_app, cookie): def test_revoked_cookie_check_revoked(self, user_mgt_app, revoked_tokens, cookie): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) _instrument_user_manager(user_mgt_app, 200, revoked_tokens) - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.RevokedSessionCookieError) as excinfo: auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=True) - assert excinfo.value.code == 'SESSION_COOKIE_REVOKED' assert str(excinfo.value) == 'The Firebase session cookie has been revoked.' @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) @@ -490,11 +532,30 @@ def test_revoked_cookie_does_not_check_revoked(self, user_mgt_app, revoked_token assert claims['admin'] is True assert claims['uid'] == claims['sub'] + @pytest.mark.parametrize('cookie', INVALID_JWT_ARGS.values(), ids=list(INVALID_JWT_ARGS)) + def test_invalid_args(self, user_mgt_app, cookie): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + with pytest.raises(ValueError) as excinfo: + auth.verify_session_cookie(cookie, app=user_mgt_app) + assert 'Illegal session cookie provided' in str(excinfo.value) + @pytest.mark.parametrize('cookie', invalid_cookies.values(), ids=list(invalid_cookies)) def test_invalid_cookie(self, user_mgt_app, cookie): _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) - with pytest.raises(ValueError): + with pytest.raises(auth.InvalidSessionCookieError) as excinfo: auth.verify_session_cookie(cookie, app=user_mgt_app) + assert isinstance(excinfo.value, exceptions.InvalidArgumentError) + assert excinfo.value.http_response is None + + def test_expired_cookie(self, user_mgt_app): + _overwrite_cert_request(user_mgt_app, MOCK_REQUEST) + cookie = self.invalid_cookies['ExpiredCookie'] + with pytest.raises(auth.ExpiredSessionCookieError) as excinfo: + auth.verify_session_cookie(cookie, app=user_mgt_app) + assert isinstance(excinfo.value, auth.InvalidSessionCookieError) + assert 'Token expired' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is None def test_project_id_option(self): app = firebase_admin.initialize_app( @@ -516,13 +577,17 @@ def test_project_id_env_var(self, env_var_app): def test_custom_token(self, auth_app): custom_token = auth.create_custom_token(MOCK_UID, app=auth_app) _overwrite_cert_request(auth_app, MOCK_REQUEST) - with pytest.raises(ValueError): + with pytest.raises(auth.InvalidSessionCookieError): auth.verify_session_cookie(custom_token, app=auth_app) def test_certificate_request_failure(self, user_mgt_app): _overwrite_cert_request(user_mgt_app, testutils.MockRequest(404, 'not found')) - with pytest.raises(exceptions.TransportError): + with pytest.raises(auth.CertificateFetchError) as excinfo: auth.verify_session_cookie(TEST_SESSION_COOKIE, app=user_mgt_app) + assert 'Could not fetch certificates' in str(excinfo.value) + assert isinstance(excinfo.value, exceptions.UnknownError) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is None class TestCertificateCaching(object): diff --git a/tests/test_user_mgt.py b/tests/test_user_mgt.py index 797e0ce59..dc71b6b6d 100644 --- a/tests/test_user_mgt.py +++ b/tests/test_user_mgt.py @@ -21,6 +21,7 @@ import firebase_admin from firebase_admin import auth +from firebase_admin import exceptions from firebase_admin import _auth_utils from firebase_admin import _user_import from firebase_admin import _user_mgt @@ -211,34 +212,89 @@ def test_get_user_by_phone(self, user_mgt_app): def test_get_user_non_existing(self, user_mgt_app): _instrument_user_manager(user_mgt_app, 200, '{"users":[]}') - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(auth.UserNotFoundError) as excinfo: auth.get_user('nonexistentuser', user_mgt_app) - assert excinfo.value.code == _user_mgt.USER_NOT_FOUND_ERROR + error_msg = 'No user record found for the provided user ID: nonexistentuser.' + assert excinfo.value.code == exceptions.NOT_FOUND + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is None + + def test_get_user_by_email_non_existing(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, '{"users":[]}') + with pytest.raises(auth.UserNotFoundError) as excinfo: + auth.get_user_by_email('nonexistent@user', user_mgt_app) + error_msg = 'No user record found for the provided email: nonexistent@user.' + assert excinfo.value.code == exceptions.NOT_FOUND + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is None + + def test_get_user_by_phone_non_existing(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, '{"users":[]}') + with pytest.raises(auth.UserNotFoundError) as excinfo: + auth.get_user_by_phone_number('+1234567890', user_mgt_app) + error_msg = 'No user record found for the provided phone number: +1234567890.' + assert excinfo.value.code == exceptions.NOT_FOUND + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is None def test_get_user_http_error(self, user_mgt_app): - _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') - with pytest.raises(auth.AuthError) as excinfo: + _instrument_user_manager(user_mgt_app, 500, '{"error":{"message": "USER_NOT_FOUND"}}') + with pytest.raises(auth.UserNotFoundError) as excinfo: + auth.get_user('testuser', user_mgt_app) + error_msg = 'No user record found for the given identifier (USER_NOT_FOUND).' + assert excinfo.value.code == exceptions.NOT_FOUND + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + + def test_get_user_http_error_unexpected_code(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 500, '{"error":{"message": "UNEXPECTED_CODE"}}') + with pytest.raises(exceptions.InternalError) as excinfo: auth.get_user('testuser', user_mgt_app) - assert excinfo.value.code == _user_mgt.INTERNAL_ERROR - assert '{"error":"test"}' in str(excinfo.value) + assert str(excinfo.value) == 'Error while calling Auth service (UNEXPECTED_CODE).' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + + def test_get_user_http_error_malformed_response(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 500, '{"error": "UNEXPECTED_CODE"}') + with pytest.raises(exceptions.InternalError) as excinfo: + auth.get_user('testuser', user_mgt_app) + assert str(excinfo.value) == 'Unexpected error response: {"error": "UNEXPECTED_CODE"}' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None def test_get_user_by_email_http_error(self, user_mgt_app): - _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') - with pytest.raises(auth.AuthError) as excinfo: + _instrument_user_manager(user_mgt_app, 500, '{"error":{"message": "USER_NOT_FOUND"}}') + with pytest.raises(auth.UserNotFoundError) as excinfo: auth.get_user_by_email('non.existent.user@example.com', user_mgt_app) - assert excinfo.value.code == _user_mgt.INTERNAL_ERROR - assert '{"error":"test"}' in str(excinfo.value) + error_msg = 'No user record found for the given identifier (USER_NOT_FOUND).' + assert excinfo.value.code == exceptions.NOT_FOUND + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None def test_get_user_by_phone_http_error(self, user_mgt_app): - _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') - with pytest.raises(auth.AuthError) as excinfo: + _instrument_user_manager(user_mgt_app, 500, '{"error":{"message": "USER_NOT_FOUND"}}') + with pytest.raises(auth.UserNotFoundError) as excinfo: auth.get_user_by_phone_number('+1234567890', user_mgt_app) - assert excinfo.value.code == _user_mgt.INTERNAL_ERROR - assert '{"error":"test"}' in str(excinfo.value) + error_msg = 'No user record found for the given identifier (USER_NOT_FOUND).' + assert excinfo.value.code == exceptions.NOT_FOUND + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None class TestCreateUser(object): + already_exists_errors = { + 'DUPLICATE_EMAIL': auth.EmailAlreadyExistsError, + 'DUPLICATE_LOCAL_ID': auth.UidAlreadyExistsError, + 'PHONE_NUMBER_EXISTS': auth.PhoneNumberAlreadyExistsError, + } + @pytest.mark.parametrize('arg', INVALID_STRINGS[1:] + ['a'*129]) def test_invalid_uid(self, user_mgt_app, arg): with pytest.raises(ValueError): @@ -301,11 +357,33 @@ def test_create_user_with_id(self, user_mgt_app): assert request == {'localId' : 'testuser'} def test_create_user_error(self, user_mgt_app): - _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') - with pytest.raises(auth.AuthError) as excinfo: + _instrument_user_manager(user_mgt_app, 500, '{"error": {"message": "UNEXPECTED_CODE"}}') + with pytest.raises(exceptions.InternalError) as excinfo: + auth.create_user(app=user_mgt_app) + assert str(excinfo.value) == 'Error while calling Auth service (UNEXPECTED_CODE).' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + + @pytest.mark.parametrize('error_code', already_exists_errors.keys()) + def test_user_already_exists(self, user_mgt_app, error_code): + resp = {'error': {'message': error_code}} + _instrument_user_manager(user_mgt_app, 500, json.dumps(resp)) + exc_type = self.already_exists_errors[error_code] + with pytest.raises(exc_type) as excinfo: auth.create_user(app=user_mgt_app) - assert excinfo.value.code == _user_mgt.USER_CREATE_ERROR - assert '{"error":"test"}' in str(excinfo.value) + assert isinstance(excinfo.value, exceptions.AlreadyExistsError) + assert str(excinfo.value) == '{0} ({1}).'.format(exc_type.default_message, error_code) + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + + def test_create_user_unexpected_response(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, '{"error": "test"}') + with pytest.raises(auth.UnexpectedResponseError) as excinfo: + auth.create_user(app=user_mgt_app) + assert str(excinfo.value) == 'Failed to create new user.' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is None + assert isinstance(excinfo.value, exceptions.UnknownError) class TestUpdateUser(object): @@ -387,16 +465,6 @@ def test_delete_user_custom_claims(self, user_mgt_app): request = json.loads(recorder[0].body.decode()) assert request == {'localId' : 'testuser', 'customAttributes' : json.dumps({})} - def test_update_user_delete_fields_with_none(self, user_mgt_app): - user_mgt, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}') - user_mgt.update_user('testuser', display_name=None, photo_url=None, phone_number=None) - request = json.loads(recorder[0].body.decode()) - assert request == { - 'localId' : 'testuser', - 'deleteAttribute' : ['DISPLAY_NAME', 'PHOTO_URL'], - 'deleteProvider' : ['phone'], - } - def test_update_user_delete_fields(self, user_mgt_app): user_mgt, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}') user_mgt.update_user( @@ -412,11 +480,21 @@ def test_update_user_delete_fields(self, user_mgt_app): } def test_update_user_error(self, user_mgt_app): - _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') - with pytest.raises(auth.AuthError) as excinfo: + _instrument_user_manager(user_mgt_app, 500, '{"error": {"message": "UNEXPECTED_CODE"}}') + with pytest.raises(exceptions.InternalError) as excinfo: auth.update_user('user', app=user_mgt_app) - assert excinfo.value.code == _user_mgt.USER_UPDATE_ERROR - assert '{"error":"test"}' in str(excinfo.value) + assert str(excinfo.value) == 'Error while calling Auth service (UNEXPECTED_CODE).' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + + def test_update_user_unexpected_response(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, '{"error": "test"}') + with pytest.raises(auth.UnexpectedResponseError) as excinfo: + auth.update_user('user', app=user_mgt_app) + assert str(excinfo.value) == 'Failed to update user: user.' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is None + assert isinstance(excinfo.value, exceptions.UnknownError) @pytest.mark.parametrize('arg', [1, 1.0]) def test_update_user_valid_since(self, user_mgt_app, arg): @@ -473,18 +551,19 @@ def test_set_custom_user_claims_str(self, user_mgt_app): request = json.loads(recorder[0].body.decode()) assert request == {'localId' : 'testuser', 'customAttributes' : claims} - def test_set_custom_user_claims_none(self, user_mgt_app): + def test_set_custom_user_claims_remove(self, user_mgt_app): _, recorder = _instrument_user_manager(user_mgt_app, 200, '{"localId":"testuser"}') - auth.set_custom_user_claims('testuser', None, app=user_mgt_app) + auth.set_custom_user_claims('testuser', auth.DELETE_ATTRIBUTE, app=user_mgt_app) request = json.loads(recorder[0].body.decode()) assert request == {'localId' : 'testuser', 'customAttributes' : json.dumps({})} def test_set_custom_user_claims_error(self, user_mgt_app): - _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') - with pytest.raises(auth.AuthError) as excinfo: + _instrument_user_manager(user_mgt_app, 500, '{"error": {"message": "UNEXPECTED_CODE"}}') + with pytest.raises(exceptions.InternalError) as excinfo: auth.set_custom_user_claims('user', {}, app=user_mgt_app) - assert excinfo.value.code == _user_mgt.USER_UPDATE_ERROR - assert '{"error":"test"}' in str(excinfo.value) + assert str(excinfo.value) == 'Error while calling Auth service (UNEXPECTED_CODE).' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None class TestDeleteUser(object): @@ -500,11 +579,21 @@ def test_delete_user(self, user_mgt_app): auth.delete_user('testuser', user_mgt_app) def test_delete_user_error(self, user_mgt_app): - _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') - with pytest.raises(auth.AuthError) as excinfo: + _instrument_user_manager(user_mgt_app, 500, '{"error": {"message": "UNEXPECTED_CODE"}}') + with pytest.raises(exceptions.InternalError) as excinfo: + auth.delete_user('user', app=user_mgt_app) + assert str(excinfo.value) == 'Error while calling Auth service (UNEXPECTED_CODE).' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + + def test_delete_user_unexpected_response(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, '{"error": "test"}') + with pytest.raises(auth.UnexpectedResponseError) as excinfo: auth.delete_user('user', app=user_mgt_app) - assert excinfo.value.code == _user_mgt.USER_DELETE_ERROR - assert '{"error":"test"}' in str(excinfo.value) + assert str(excinfo.value) == 'Failed to delete user: user.' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is None + assert isinstance(excinfo.value, exceptions.UnknownError) class TestListUsers(object): @@ -640,10 +729,9 @@ def test_list_users_with_all_args(self, user_mgt_app): def test_list_users_error(self, user_mgt_app): _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') - with pytest.raises(auth.AuthError) as excinfo: + with pytest.raises(exceptions.InternalError) as excinfo: auth.list_users(app=user_mgt_app) - assert excinfo.value.code == _user_mgt.USER_DOWNLOAD_ERROR - assert '{"error":"test"}' in str(excinfo.value) + assert str(excinfo.value) == 'Unexpected error response: {"error":"test"}' def _check_page(self, page): assert isinstance(page, auth.ListUsersPage) @@ -718,6 +806,7 @@ def test_invalid_args(self, arg): with pytest.raises(ValueError): auth.UserMetadata(**arg) + class TestImportUserRecord(object): _INVALID_USERS = ( @@ -984,6 +1073,25 @@ def test_import_users_with_hash(self, user_mgt_app): } self._check_rpc_calls(recorder, expected) + def test_import_users_http_error(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 401, '{"error": {"message": "ERROR_CODE"}}') + users = [ + auth.ImportUserRecord(uid='user1'), + auth.ImportUserRecord(uid='user2'), + ] + with pytest.raises(exceptions.UnauthenticatedError) as excinfo: + auth.import_users(users, app=user_mgt_app) + assert str(excinfo.value) == 'Error while calling Auth service (ERROR_CODE).' + + def test_import_users_unexpected_response(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, '"not dict"') + users = [ + auth.ImportUserRecord(uid='user1'), + auth.ImportUserRecord(uid='user2'), + ] + with pytest.raises(auth.UnexpectedResponseError): + auth.import_users(users, app=user_mgt_app) + def _check_rpc_calls(self, recorder, expected): assert len(recorder) == 1 request = json.loads(recorder[0].body.decode()) @@ -1003,6 +1111,7 @@ def test_revoke_refresh_tokens(self, user_mgt_app): assert int(request['validSince']) >= int(before_time) assert int(request['validSince']) <= int(after_time) + class TestActionCodeSetting(object): def test_valid_data(self): @@ -1047,6 +1156,7 @@ def test_encode_action_code_bad_data(self): with pytest.raises(AttributeError): _user_mgt.encode_action_code_settings({"foo":"bar"}) + class TestGenerateEmailActionLink(object): def test_email_verification_no_settings(self, user_mgt_app): @@ -1106,9 +1216,29 @@ def test_password_reset_with_settings(self, user_mgt_app): auth.generate_password_reset_link, ]) def test_api_call_failure(self, user_mgt_app, func): - _instrument_user_manager(user_mgt_app, 500, '{"error":"dummy error"}') - with pytest.raises(auth.AuthError): + _instrument_user_manager(user_mgt_app, 500, '{"error":{"message": "UNEXPECTED_CODE"}}') + with pytest.raises(exceptions.InternalError) as excinfo: + func('test@test.com', MOCK_ACTION_CODE_SETTINGS, app=user_mgt_app) + assert str(excinfo.value) == 'Error while calling Auth service (UNEXPECTED_CODE).' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + + @pytest.mark.parametrize('func', [ + auth.generate_sign_in_with_email_link, + auth.generate_email_verification_link, + auth.generate_password_reset_link, + ]) + def test_invalid_dynamic_link(self, user_mgt_app, func): + resp = '{"error":{"message": "INVALID_DYNAMIC_LINK_DOMAIN: Because of this reason."}}' + _instrument_user_manager(user_mgt_app, 500, resp) + with pytest.raises(auth.InvalidDynamicLinkDomainError) as excinfo: func('test@test.com', MOCK_ACTION_CODE_SETTINGS, app=user_mgt_app) + assert isinstance(excinfo.value, exceptions.InvalidArgumentError) + assert str(excinfo.value) == ('Dynamic link domain specified in ActionCodeSettings is ' + 'not authorized (INVALID_DYNAMIC_LINK_DOMAIN). Because ' + 'of this reason.') + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None @pytest.mark.parametrize('func', [ auth.generate_sign_in_with_email_link, @@ -1117,8 +1247,12 @@ def test_api_call_failure(self, user_mgt_app, func): ]) def test_api_call_no_link(self, user_mgt_app, func): _instrument_user_manager(user_mgt_app, 200, '{}') - with pytest.raises(auth.AuthError): + with pytest.raises(auth.UnexpectedResponseError) as excinfo: func('test@test.com', MOCK_ACTION_CODE_SETTINGS, app=user_mgt_app) + assert str(excinfo.value) == 'Failed to generate email action link.' + assert excinfo.value.http_response is not None + assert excinfo.value.cause is None + assert isinstance(excinfo.value, exceptions.UnknownError) @pytest.mark.parametrize('func', [ auth.generate_sign_in_with_email_link,