diff --git a/firebase_admin/_auth_client.py b/firebase_admin/_auth_client.py index b7af6ddb6..12d60592e 100644 --- a/firebase_admin/_auth_client.py +++ b/firebase_admin/_auth_client.py @@ -21,6 +21,7 @@ from firebase_admin import _auth_utils from firebase_admin import _http_client from firebase_admin import _token_gen +from firebase_admin import _user_identifier from firebase_admin import _user_import from firebase_admin import _user_mgt @@ -182,6 +183,56 @@ def get_user_by_phone_number(self, phone_number): response = self._user_manager.get_user(phone_number=phone_number) return _user_mgt.UserRecord(response) + def get_users(self, identifiers): + """Gets the user data corresponding to the specified identifiers. + + There are no ordering guarantees; in particular, the nth entry in the + result list is not guaranteed to correspond to the nth entry in the input + parameters list. + + A maximum of 100 identifiers may be supplied. If more than 100 + identifiers are supplied, this method raises a `ValueError`. + + Args: + identifiers (list[Identifier]): A list of ``Identifier`` instances used + to indicate which user records should be returned. Must have <= 100 + entries. + + Returns: + GetUsersResult: A ``GetUsersResult`` instance corresponding to the + specified identifiers. + + Raises: + ValueError: If any of the identifiers are invalid or if more than 100 + identifiers are specified. + """ + response = self._user_manager.get_users(identifiers=identifiers) + + def _matches(identifier, user_record): + if isinstance(identifier, _user_identifier.UidIdentifier): + return identifier.uid == user_record.uid + if isinstance(identifier, _user_identifier.EmailIdentifier): + return identifier.email == user_record.email + if isinstance(identifier, _user_identifier.PhoneIdentifier): + return identifier.phone_number == user_record.phone_number + if isinstance(identifier, _user_identifier.ProviderIdentifier): + return next(( + True + for user_info in user_record.provider_data + if identifier.provider_id == user_info.provider_id + and identifier.provider_uid == user_info.uid + ), False) + raise TypeError("Unexpected type: {}".format(type(identifier))) + + def _is_user_found(identifier, user_records): + return any(_matches(identifier, user_record) for user_record in user_records) + + users = [_user_mgt.UserRecord(user) for user in response] + not_found = [ + identifier for identifier in identifiers if not _is_user_found(identifier, users)] + + return _user_mgt.GetUsersResult(users=users, not_found=not_found) + def list_users(self, page_token=None, max_results=_user_mgt.MAX_LIST_USERS_RESULTS): """Retrieves a page of user accounts from a Firebase project. @@ -306,6 +357,33 @@ def delete_user(self, uid): """ self._user_manager.delete_user(uid) + def delete_users(self, uids): + """Deletes the users specified by the given identifiers. + + Deleting a non-existing user does not generate an error (the method is + idempotent.) Non-existing users are considered to be successfully + deleted and are therefore included in the + `DeleteUserResult.success_count` value. + + A maximum of 1000 identifiers may be supplied. If more than 1000 + identifiers are supplied, this method raises a `ValueError`. + + Args: + uids: A list of strings indicating the uids of the users to be deleted. + Must have <= 1000 entries. + + Returns: + DeleteUsersResult: The total number of successful/failed deletions, as + well as the array of errors that correspond to the failed + deletions. + + Raises: + ValueError: If any of the identifiers are invalid or if more than 1000 + identifiers are specified. + """ + result = self._user_manager.delete_users(uids, force_delete=True) + return _user_mgt.DeleteUsersResult(result, len(uids)) + def import_users(self, users, hash_alg=None): """Imports the specified list of users into Firebase Auth. diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index f1ce97dee..2226675f9 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -136,6 +136,15 @@ def validate_provider_id(provider_id, required=True): 'string.'.format(provider_id)) return provider_id +def validate_provider_uid(provider_uid, required=True): + if provider_uid is None and not required: + return None + if not isinstance(provider_uid, str) or not provider_uid: + raise ValueError( + 'Invalid provider UID: "{0}". Provider UID must be a non-empty ' + 'string.'.format(provider_uid)) + return provider_uid + def validate_photo_url(photo_url, required=False): """Parses and validates the given URL string.""" if photo_url is None and not required: diff --git a/firebase_admin/_rfc3339.py b/firebase_admin/_rfc3339.py new file mode 100644 index 000000000..2c720bdd1 --- /dev/null +++ b/firebase_admin/_rfc3339.py @@ -0,0 +1,87 @@ +# Copyright 2020 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. + +"""Parse RFC3339 date strings""" + +from datetime import datetime, timezone +import re + +def parse_to_epoch(datestr): + """Parse an RFC3339 date string and return the number of seconds since the + epoch (as a float). + + In particular, this method is meant to parse the strings returned by the + JSON mapping of protobuf google.protobuf.timestamp.Timestamp instances: + https://github.com/protocolbuffers/protobuf/blob/4cf5bfee9546101d98754d23ff378ff718ba8438/src/google/protobuf/timestamp.proto#L99 + + This method has microsecond precision; nanoseconds will be truncated. + + Args: + datestr: A string in RFC3339 format. + Returns: + Float: The number of seconds since the Unix epoch. + Raises: + ValueError: Raised if the `datestr` is not a valid RFC3339 date string. + """ + return _parse_to_datetime(datestr).timestamp() + + +def _parse_to_datetime(datestr): + """Parse an RFC3339 date string and return a python datetime instance. + + Args: + datestr: A string in RFC3339 format. + Returns: + datetime: The corresponding `datetime` (with timezone information). + Raises: + ValueError: Raised if the `datestr` is not a valid RFC3339 date string. + """ + # If more than 6 digits appear in the fractional seconds position, truncate + # to just the most significant 6. (i.e. we only have microsecond precision; + # nanos are truncated.) + datestr_modified = re.sub(r'(\.\d{6})\d*', r'\1', datestr) + + # This format is the one we actually expect to occur from our backend. The + # others are only present because the spec says we *should* accept them. + try: + return datetime.strptime( + datestr_modified, '%Y-%m-%dT%H:%M:%S.%fZ' + ).replace(tzinfo=timezone.utc) + except ValueError: + pass + + try: + return datetime.strptime( + datestr_modified, '%Y-%m-%dT%H:%M:%SZ' + ).replace(tzinfo=timezone.utc) + except ValueError: + pass + + # Note: %z parses timezone offsets, but requires the timezone offset *not* + # include a separating ':'. As of python 3.7, this was relaxed. + # TODO(rsgowman): Once python3.7 becomes our floor, we can drop the regex + # replacement. + datestr_modified = re.sub(r'(\d\d):(\d\d)$', r'\1\2', datestr_modified) + + try: + return datetime.strptime(datestr_modified, '%Y-%m-%dT%H:%M:%S.%f%z') + except ValueError: + pass + + try: + return datetime.strptime(datestr_modified, '%Y-%m-%dT%H:%M:%S%z') + except ValueError: + pass + + raise ValueError('time data {0} does not match RFC3339 format'.format(datestr)) diff --git a/firebase_admin/_user_identifier.py b/firebase_admin/_user_identifier.py new file mode 100644 index 000000000..85a224e0b --- /dev/null +++ b/firebase_admin/_user_identifier.py @@ -0,0 +1,103 @@ +# Copyright 2020 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. + +"""Classes to uniquely identify a user.""" + +from firebase_admin import _auth_utils + +class UserIdentifier: + """Identifies a user to be looked up.""" + + +class UidIdentifier(UserIdentifier): + """Used for looking up an account by uid. + + See ``auth.get_user()``. + """ + + def __init__(self, uid): + """Constructs a new `UidIdentifier` object. + + Args: + uid: A user ID string. + """ + self._uid = _auth_utils.validate_uid(uid, required=True) + + @property + def uid(self): + return self._uid + + +class EmailIdentifier(UserIdentifier): + """Used for looking up an account by email. + + See ``auth.get_user()``. + """ + + def __init__(self, email): + """Constructs a new `EmailIdentifier` object. + + Args: + email: A user email address string. + """ + self._email = _auth_utils.validate_email(email, required=True) + + @property + def email(self): + return self._email + + +class PhoneIdentifier(UserIdentifier): + """Used for looking up an account by phone number. + + See ``auth.get_user()``. + """ + + def __init__(self, phone_number): + """Constructs a new `PhoneIdentifier` object. + + Args: + phone_number: A phone number string. + """ + self._phone_number = _auth_utils.validate_phone(phone_number, required=True) + + @property + def phone_number(self): + return self._phone_number + + +class ProviderIdentifier(UserIdentifier): + """Used for looking up an account by provider. + + See ``auth.get_user()``. + """ + + def __init__(self, provider_id, provider_uid): + """Constructs a new `ProviderIdentifier` object. + +   Args: +     provider_id: A provider ID string. +     provider_uid: A provider UID string. + """ + self._provider_id = _auth_utils.validate_provider_id(provider_id, required=True) + self._provider_uid = _auth_utils.validate_provider_uid( + provider_uid, required=True) + + @property + def provider_id(self): + return self._provider_id + + @property + def provider_uid(self): + return self._provider_uid diff --git a/firebase_admin/_user_import.py b/firebase_admin/_user_import.py index 21cc8082d..7834b232a 100644 --- a/firebase_admin/_user_import.py +++ b/firebase_admin/_user_import.py @@ -472,7 +472,12 @@ def standard_scrypt(cls, memory_cost, parallelization, block_size, derived_key_l class ErrorInfo: - """Represents an error encountered while importing an ``ImportUserRecord``.""" + """Represents an error encountered while performing a batch operation such + as importing users or deleting multiple user accounts. + """ + # TODO(rsgowman): This class used to be specific to importing users (hence + # it's home in _user_import.py). It's now also used by bulk deletion of + # users. Move this to a more common location. def __init__(self, error): self._index = error['index'] diff --git a/firebase_admin/_user_mgt.py b/firebase_admin/_user_mgt.py index 0b0c5ddb6..0307959f3 100644 --- a/firebase_admin/_user_mgt.py +++ b/firebase_admin/_user_mgt.py @@ -15,13 +15,17 @@ """Firebase user management sub module.""" import base64 +from collections import defaultdict import json from urllib import parse import requests from firebase_admin import _auth_utils +from firebase_admin import _rfc3339 +from firebase_admin import _user_identifier from firebase_admin import _user_import +from firebase_admin._user_import import ErrorInfo MAX_LIST_USERS_RESULTS = 1000 @@ -41,11 +45,14 @@ def __init__(self, description): class UserMetadata: """Contains additional metadata associated with a user account.""" - def __init__(self, creation_timestamp=None, last_sign_in_timestamp=None): + def __init__(self, creation_timestamp=None, last_sign_in_timestamp=None, + last_refresh_timestamp=None): self._creation_timestamp = _auth_utils.validate_timestamp( creation_timestamp, 'creation_timestamp') self._last_sign_in_timestamp = _auth_utils.validate_timestamp( last_sign_in_timestamp, 'last_sign_in_timestamp') + self._last_refresh_timestamp = _auth_utils.validate_timestamp( + last_refresh_timestamp, 'last_refresh_timestamp') @property def creation_timestamp(self): @@ -65,6 +72,16 @@ def last_sign_in_timestamp(self): """ return self._last_sign_in_timestamp + @property + def last_refresh_timestamp(self): + """The time at which the user was last active (ID token refreshed). + + Returns: + integer: Milliseconds since epoch timestamp, or `None` if the user was + never active. + """ + return self._last_refresh_timestamp + class UserInfo: """A collection of standard profile information for a user. @@ -216,7 +233,12 @@ def _int_or_none(key): if key in self._data: return int(self._data[key]) return None - return UserMetadata(_int_or_none('createdAt'), _int_or_none('lastLoginAt')) + last_refresh_at_millis = None + last_refresh_at_rfc3339 = self._data.get('lastRefreshAt', None) + if last_refresh_at_rfc3339: + last_refresh_at_millis = int(_rfc3339.parse_to_epoch(last_refresh_at_rfc3339) * 1000) + return UserMetadata( + _int_or_none('createdAt'), _int_or_none('lastLoginAt'), last_refresh_at_millis) @property def provider_data(self): @@ -289,6 +311,35 @@ def password_salt(self): return self._data.get('salt') +class GetUsersResult: + """Represents the result of the ``auth.get_users()`` API.""" + + def __init__(self, users, not_found): + """Constructs a `GetUsersResult` object. + + Args: + users: List of `UserRecord` instances. + not_found: List of `UserIdentifier` instances. + """ + self._users = users + self._not_found = not_found + + @property + def users(self): + """Set of `UserRecord` instances, corresponding to the set of users + that were requested. Only users that were found are listed here. The + result set is unordered. + """ + return self._users + + @property + def not_found(self): + """Set of `UserIdentifier` instances that were requested, but not + found. + """ + return self._not_found + + class ListUsersPage: """Represents a page of user records exported from a Firebase project. @@ -340,6 +391,63 @@ def iterate_all(self): return _UserIterator(self) +class DeleteUsersResult: + """Represents the result of the ``auth.delete_users()`` API.""" + + def __init__(self, result, total): + """Constructs a `DeleteUsersResult` object. + + Args: + result: The proto response, wrapped in a + `BatchDeleteAccountsResponse` instance. + total: Total integer number of deletion attempts. + """ + errors = result.errors + self._success_count = total - len(errors) + self._failure_count = len(errors) + self._errors = errors + + @property + def success_count(self): + """Returns the number of users that were deleted successfully (possibly + zero). + + Users that did not exist prior to calling `delete_users()` are + considered to be successfully deleted. + """ + return self._success_count + + @property + def failure_count(self): + """Returns the number of users that failed to be deleted (possibly + zero). + """ + return self._failure_count + + @property + def errors(self): + """A list of `auth.ErrorInfo` instances describing the errors that + were encountered during the deletion. Length of this list is equal to + `failure_count`. + """ + return self._errors + + +class BatchDeleteAccountsResponse: + """Represents the results of a `delete_users()` call.""" + + def __init__(self, errors=None): + """Constructs a `BatchDeleteAccountsResponse` instance, corresponding to + the JSON representing the `BatchDeleteAccountsResponse` proto. + + Args: + errors: List of dictionaries, with each dictionary representing an + `ErrorInfo` instance as returned by the server. `None` implies + an empty list. + """ + self.errors = [ErrorInfo(err) for err in errors] if errors else [] + + class ProviderUserInfo(UserInfo): """Contains metadata regarding how a user is known by a particular identity provider.""" @@ -492,6 +600,53 @@ def get_user(self, **kwargs): http_response=http_resp) return body['users'][0] + def get_users(self, identifiers): + """Looks up multiple users by their identifiers (uid, email, etc.) + + Args: + identifiers: UserIdentifier[]: The identifiers indicating the user + to be looked up. Must have <= 100 entries. + + Returns: + list[dict[string, string]]: List of dicts representing the JSON + `UserInfo` responses from the server. + + Raises: + ValueError: If any of the identifiers are invalid or if more than + 100 identifiers are specified. + UnexpectedResponseError: If the backend server responds with an + unexpected message. + """ + if not identifiers: + return [] + if len(identifiers) > 100: + raise ValueError('`identifiers` parameter must have <= 100 entries.') + + payload = defaultdict(list) + for identifier in identifiers: + if isinstance(identifier, _user_identifier.UidIdentifier): + payload['localId'].append(identifier.uid) + elif isinstance(identifier, _user_identifier.EmailIdentifier): + payload['email'].append(identifier.email) + elif isinstance(identifier, _user_identifier.PhoneIdentifier): + payload['phoneNumber'].append(identifier.phone_number) + elif isinstance(identifier, _user_identifier.ProviderIdentifier): + payload['federatedUserId'].append({ + 'providerId': identifier.provider_id, + 'rawId': identifier.provider_uid + }) + else: + raise ValueError( + 'Invalid entry in "identifiers" list. Unsupported type: {}' + .format(type(identifier))) + + body, http_resp = self._make_request( + 'post', '/accounts:lookup', json=payload) + if not http_resp.ok: + raise _auth_utils.UnexpectedResponseError( + 'Failed to get users.', http_response=http_resp) + return body.get('users', []) + def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS): """Retrieves a batch of users.""" if page_token is not None: @@ -585,6 +740,42 @@ def delete_user(self, uid): raise _auth_utils.UnexpectedResponseError( 'Failed to delete user: {0}.'.format(uid), http_response=http_resp) + def delete_users(self, uids, force_delete=False): + """Deletes the users identified by the specified user ids. + + Args: + uids: A list of strings indicating the uids of the users to be deleted. + Must have <= 1000 entries. + force_delete: Optional parameter that indicates if users should be + deleted, even if they're not disabled. Defaults to False. + + + Returns: + BatchDeleteAccountsResponse: Server's proto response, wrapped in a + python object. + + Raises: + ValueError: If any of the identifiers are invalid or if more than 1000 + identifiers are specified. + UnexpectedResponseError: If the backend server responds with an + unexpected message. + """ + if not uids: + return BatchDeleteAccountsResponse() + + if len(uids) > 1000: + raise ValueError("`uids` paramter must have <= 1000 entries.") + for uid in uids: + _auth_utils.validate_uid(uid, required=True) + + body, http_resp = self._make_request('post', '/accounts:batchDelete', + json={'localIds': uids, 'force': force_delete}) + if not isinstance(body, dict): + raise _auth_utils.UnexpectedResponseError( + 'Unexpected response from server while attempting to delete users.', + http_response=http_resp) + return BatchDeleteAccountsResponse(body.get('errors', [])) + def import_users(self, users, hash_alg=None): """Imports the given list of users to Firebase Auth.""" try: diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index 1cce9ea00..5d2fe0f68 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -22,6 +22,7 @@ from firebase_admin import _auth_client from firebase_admin import _auth_providers from firebase_admin import _auth_utils +from firebase_admin import _user_identifier from firebase_admin import _token_gen from firebase_admin import _user_import from firebase_admin import _user_mgt @@ -66,6 +67,12 @@ 'UserProvider', 'UserRecord', + 'UserIdentifier', + 'UidIdentifier', + 'EmailIdentifier', + 'PhoneIdentifier', + 'ProviderIdentifier', + 'create_custom_token', 'create_oidc_provider_config', 'create_saml_provider_config', @@ -74,6 +81,7 @@ 'delete_oidc_provider_config', 'delete_saml_provider_config', 'delete_user', + 'delete_users', 'generate_email_verification_link', 'generate_password_reset_link', 'generate_sign_in_with_email_link', @@ -82,6 +90,7 @@ 'get_user', 'get_user_by_email', 'get_user_by_phone_number', + 'get_users', 'import_users', 'list_saml_provider_configs', 'list_users', @@ -99,11 +108,13 @@ Client = _auth_client.Client ConfigurationNotFoundError = _auth_utils.ConfigurationNotFoundError DELETE_ATTRIBUTE = _user_mgt.DELETE_ATTRIBUTE +DeleteUsersResult = _user_mgt.DeleteUsersResult EmailAlreadyExistsError = _auth_utils.EmailAlreadyExistsError ErrorInfo = _user_import.ErrorInfo ExpiredIdTokenError = _token_gen.ExpiredIdTokenError ExpiredSessionCookieError = _token_gen.ExpiredSessionCookieError ExportedUserRecord = _user_mgt.ExportedUserRecord +GetUsersResult = _user_mgt.GetUsersResult ImportUserRecord = _user_import.ImportUserRecord InsufficientPermissionError = _auth_utils.InsufficientPermissionError InvalidDynamicLinkDomainError = _auth_utils.InvalidDynamicLinkDomainError @@ -128,6 +139,12 @@ UserProvider = _user_import.UserProvider UserRecord = _user_mgt.UserRecord +UserIdentifier = _user_identifier.UserIdentifier +UidIdentifier = _user_identifier.UidIdentifier +EmailIdentifier = _user_identifier.EmailIdentifier +PhoneIdentifier = _user_identifier.PhoneIdentifier +ProviderIdentifier = _user_identifier.ProviderIdentifier + def _get_client(app): """Returns a client instance for an App. @@ -328,6 +345,34 @@ def get_user_by_phone_number(phone_number, app=None): return client.get_user_by_phone_number(phone_number=phone_number) +def get_users(identifiers, app=None): + """Gets the user data corresponding to the specified identifiers. + + There are no ordering guarantees; in particular, the nth entry in the + result list is not guaranteed to correspond to the nth entry in the input + parameters list. + + A maximum of 100 identifiers may be supplied. If more than 100 + identifiers are supplied, this method raises a `ValueError`. + + Args: + identifiers (list[Identifier]): A list of ``Identifier`` instances used + to indicate which user records should be returned. Must have <= 100 + entries. + app: An App instance (optional). + + Returns: + GetUsersResult: A ``GetUsersResult`` instance corresponding to the + specified identifiers. + + Raises: + ValueError: If any of the identifiers are invalid or if more than 100 + identifiers are specified. + """ + client = _get_client(app) + return client.get_users(identifiers) + + def list_users(page_token=None, max_results=_user_mgt.MAX_LIST_USERS_RESULTS, app=None): """Retrieves a page of user accounts from a Firebase project. @@ -460,6 +505,34 @@ def delete_user(uid, app=None): client.delete_user(uid) +def delete_users(uids, app=None): + """Deletes the users specified by the given identifiers. + + Deleting a non-existing user does not generate an error (the method is + idempotent.) Non-existing users are considered to be successfully deleted + and are therefore included in the `DeleteUserResult.success_count` value. + + A maximum of 1000 identifiers may be supplied. If more than 1000 + identifiers are supplied, this method raises a `ValueError`. + + Args: + uids: A list of strings indicating the uids of the users to be deleted. + Must have <= 1000 entries. + app: An App instance (optional). + + Returns: + DeleteUsersResult: The total number of successful/failed deletions, as + well as the array of errors that correspond to the failed + deletions. + + Raises: + ValueError: If any of the identifiers are invalid or if more than 1000 + identifiers are specified. + """ + client = _get_client(app) + return client.delete_users(uids) + + def import_users(users, hash_alg=None, app=None): """Imports the specified list of users into Firebase Auth. diff --git a/integration/test_auth.py b/integration/test_auth.py index cfd775016..26cf53d20 100644 --- a/integration/test_auth.py +++ b/integration/test_auth.py @@ -18,6 +18,7 @@ import random import string import time +from typing import List from urllib import parse import uuid @@ -71,7 +72,7 @@ def _sign_in(custom_token, api_key): return resp.json().get('idToken') def _sign_in_with_password(email, password, api_key): - body = {'email': email, 'password': password} + body = {'email': email, 'password': password, 'returnSecureToken': True} params = {'key' : api_key} resp = requests.request('post', _verify_password_url, params=params, json=body) resp.raise_for_status() @@ -191,7 +192,7 @@ def new_user(): auth.delete_user(user.uid) @pytest.fixture -def new_user_with_params(): +def new_user_with_params() -> auth.UserRecord: random_id, email = _random_id() phone = _random_phone() user = auth.create_user( @@ -214,9 +215,52 @@ def new_user_list(): auth.create_user(password='password').uid, ] yield users + # TODO(rsgowman): Using auth.delete_users() would make more sense here, but + # that's currently rate limited to 1qps, so using it in this context would + # almost certainly trigger errors. When/if that limit is relaxed, switch to + # batch delete. for uid in users: auth.delete_user(uid) +@pytest.fixture +def new_user_record_list() -> List[auth.UserRecord]: + uid1, email1 = _random_id() + uid2, email2 = _random_id() + uid3, email3 = _random_id() + users = [ + auth.create_user( + uid=uid1, email=email1, password='password', phone_number=_random_phone()), + auth.create_user( + uid=uid2, email=email2, password='password', phone_number=_random_phone()), + auth.create_user( + uid=uid3, email=email3, password='password', phone_number=_random_phone()), + ] + yield users + for user in users: + auth.delete_user(user.uid) + +@pytest.fixture +def new_user_with_provider() -> auth.UserRecord: + uid4, email4 = _random_id() + google_uid, google_email = _random_id() + import_user1 = auth.ImportUserRecord( + uid=uid4, + email=email4, + provider_data=[ + auth.UserProvider( + uid=google_uid, + provider_id='google.com', + email=google_email, + ) + ]) + user_import_result = auth.import_users([import_user1]) + assert user_import_result.success_count == 1 + assert user_import_result.failure_count == 0 + + user = auth.get_user(uid4) + yield user + auth.delete_user(user.uid) + @pytest.fixture def new_user_email_unverified(): random_id, email = _random_id() @@ -248,6 +292,87 @@ def test_get_user(new_user_with_params): provider_ids = sorted([provider.provider_id for provider in user.provider_data]) assert provider_ids == ['password', 'phone'] +class TestGetUsers: + @staticmethod + def _map_user_record_to_uid_email_phones(user_record): + return { + 'uid': user_record.uid, + 'email': user_record.email, + 'phone_number': user_record.phone_number + } + + def test_multiple_uid_types(self, new_user_record_list, new_user_with_provider): + get_users_results = auth.get_users([ + auth.UidIdentifier(new_user_record_list[0].uid), + auth.EmailIdentifier(new_user_record_list[1].email), + auth.PhoneIdentifier(new_user_record_list[2].phone_number), + auth.ProviderIdentifier( + new_user_with_provider.provider_data[0].provider_id, + new_user_with_provider.provider_data[0].uid, + )]) + actual = sorted([ + self._map_user_record_to_uid_email_phones(user) + for user in get_users_results.users + ], key=lambda user: user['uid']) + expected = sorted([ + self._map_user_record_to_uid_email_phones(user) + for user in new_user_record_list + [new_user_with_provider] + ], key=lambda user: user['uid']) + + assert actual == expected + + def test_existing_and_non_existing_users(self, new_user_record_list): + get_users_results = auth.get_users([ + auth.UidIdentifier(new_user_record_list[0].uid), + auth.UidIdentifier('uid_that_doesnt_exist'), + auth.UidIdentifier(new_user_record_list[2].uid)]) + actual = sorted([ + self._map_user_record_to_uid_email_phones(user) + for user in get_users_results.users + ], key=lambda user: user['uid']) + expected = sorted([ + self._map_user_record_to_uid_email_phones(user) + for user in [new_user_record_list[0], new_user_record_list[2]] + ], key=lambda user: user['uid']) + + assert actual == expected + + def test_non_existing_users(self): + not_found_ids = [auth.UidIdentifier('non-existing user')] + get_users_results = auth.get_users(not_found_ids) + + assert get_users_results.users == [] + assert get_users_results.not_found == not_found_ids + + def test_de_dups_duplicate_users(self, new_user): + get_users_results = auth.get_users([ + auth.UidIdentifier(new_user.uid), + auth.UidIdentifier(new_user.uid)]) + actual = [ + self._map_user_record_to_uid_email_phones(user) + for user in get_users_results.users] + expected = [self._map_user_record_to_uid_email_phones(new_user)] + assert actual == expected + +def test_last_refresh_timestamp(new_user_with_params: auth.UserRecord, api_key): + # new users should not have a last_refresh_timestamp set + assert new_user_with_params.user_metadata.last_refresh_timestamp is None + + # login to cause the last_refresh_timestamp to be set + _sign_in_with_password(new_user_with_params.email, 'secret', api_key) + new_user_with_params = auth.get_user(new_user_with_params.uid) + + # Ensure the last refresh time occurred at approximately 'now'. (With a + # tolerance of up to 1 minute; we ideally want to ensure that any timezone + # considerations are handled properly, so as long as we're within an hour, + # we're in good shape.) + millis_per_second = 1000 + millis_per_minute = millis_per_second * 60 + + last_refresh_timestamp = new_user_with_params.user_metadata.last_refresh_timestamp + assert last_refresh_timestamp == pytest.approx( + time.time()*millis_per_second, 1*millis_per_minute) + def test_list_users(new_user_list): err_msg_template = ( 'Missing {field} field. A common cause would be forgetting to add the "Firebase ' + @@ -366,6 +491,36 @@ def test_delete_user(): with pytest.raises(auth.UserNotFoundError): auth.get_user(user.uid) + +class TestDeleteUsers: + def test_delete_multiple_users(self): + uid1 = auth.create_user(disabled=True).uid + uid2 = auth.create_user(disabled=False).uid + uid3 = auth.create_user(disabled=True).uid + + delete_users_result = auth.delete_users([uid1, uid2, uid3]) + assert delete_users_result.success_count == 3 + assert delete_users_result.failure_count == 0 + assert len(delete_users_result.errors) == 0 + + get_users_results = auth.get_users( + [auth.UidIdentifier(uid1), auth.UidIdentifier(uid2), auth.UidIdentifier(uid3)]) + assert len(get_users_results.users) == 0 + + def test_is_idempotent(self): + uid = auth.create_user().uid + + delete_users_result = auth.delete_users([uid]) + assert delete_users_result.success_count == 1 + assert delete_users_result.failure_count == 0 + + # Delete the user again, ensuring that everything still counts as a + # success. + delete_users_result = auth.delete_users([uid]) + assert delete_users_result.success_count == 1 + assert delete_users_result.failure_count == 0 + + def test_revoke_refresh_tokens(new_user): user = auth.get_user(new_user.uid) old_valid_after = user.tokens_valid_after_timestamp diff --git a/tests/test_rfc3339.py b/tests/test_rfc3339.py new file mode 100644 index 000000000..5a844b07e --- /dev/null +++ b/tests/test_rfc3339.py @@ -0,0 +1,67 @@ +# Copyright 2020 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. + +"""Test cases for the firebase_admin._rfc3339 module.""" + +import pytest + +from firebase_admin import _rfc3339 + +def test_epoch(): + expected = pytest.approx(0) + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00Z") == expected + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00z") == expected + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00+00:00") == expected + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00-00:00") == expected + assert _rfc3339.parse_to_epoch("1970-01-01T01:00:00+01:00") == expected + assert _rfc3339.parse_to_epoch("1969-12-31T23:00:00-01:00") == expected + +def test_pre_epoch(): + expected = -5617641600 + assert _rfc3339.parse_to_epoch("1791-12-26T00:00:00Z") == expected + assert _rfc3339.parse_to_epoch("1791-12-26T00:00:00+00:00") == expected + assert _rfc3339.parse_to_epoch("1791-12-26T00:00:00-00:00") == expected + assert _rfc3339.parse_to_epoch("1791-12-26T01:00:00+01:00") == expected + assert _rfc3339.parse_to_epoch("1791-12-25T23:00:00-01:00") == expected + +def test_post_epoch(): + expected = 904892400 + assert _rfc3339.parse_to_epoch("1998-09-04T07:00:00Z") == expected + assert _rfc3339.parse_to_epoch("1998-09-04T07:00:00+00:00") == expected + assert _rfc3339.parse_to_epoch("1998-09-04T08:00:00+01:00") == expected + assert _rfc3339.parse_to_epoch("1998-09-04T06:00:00-01:00") == expected + +def test_micros_millis(): + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00Z") == pytest.approx(0) + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00.1Z") == pytest.approx(0.1) + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00.001Z") == pytest.approx(0.001) + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00.000001Z") == pytest.approx(0.000001) + + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00+00:00") == pytest.approx(0) + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00.1+00:00") == pytest.approx(0.1) + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00.001+00:00") == pytest.approx(0.001) + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00.000001+00:00") == pytest.approx(0.000001) + +def test_nanos(): + assert _rfc3339.parse_to_epoch("1970-01-01T00:00:00.0000001Z") == pytest.approx(0) + +@pytest.mark.parametrize('datestr', [ + 'not a date string', + '1970-01-01 00:00:00Z', + '1970-01-01 00:00:00+00:00', + '1970-01-01T00:00:00', + ]) +def test_bad_datestrs(datestr): + with pytest.raises(ValueError): + _rfc3339.parse_to_epoch(datestr) diff --git a/tests/test_user_mgt.py b/tests/test_user_mgt.py index c7b2de496..79e23373f 100644 --- a/tests/test_user_mgt.py +++ b/tests/test_user_mgt.py @@ -322,6 +322,92 @@ def test_get_user_by_phone_http_error(self, user_mgt_app): assert excinfo.value.cause is not None +class TestGetUsers: + + @staticmethod + def _map_user_record_to_uid_email_phones(user_record): + return { + 'uid': user_record.uid, + 'email': user_record.email, + 'phone_number': user_record.phone_number + } + + def test_more_than_100_identifiers(self, user_mgt_app): + identifiers = [auth.UidIdentifier('id' + str(i)) for i in range(101)] + with pytest.raises(ValueError): + auth.get_users(identifiers, app=user_mgt_app) + + def test_no_identifiers(self, user_mgt_app): + get_users_results = auth.get_users([], app=user_mgt_app) + assert get_users_results.users == [] + assert get_users_results.not_found == [] + + def test_identifiers_that_do_not_exist(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, '{}') + not_found_ids = [auth.UidIdentifier('id that doesnt exist')] + get_users_results = auth.get_users(not_found_ids, app=user_mgt_app) + assert get_users_results.users == [] + assert get_users_results.not_found == not_found_ids + + def test_invalid_uid(self): + with pytest.raises(ValueError): + auth.UidIdentifier('too long ' + '.'*128) + + def test_invalid_email(self): + with pytest.raises(ValueError): + auth.EmailIdentifier('invalid email addr') + + def test_invalid_phone_number(self): + with pytest.raises(ValueError): + auth.PhoneIdentifier('invalid phone number') + + def test_invalid_provider(self): + with pytest.raises(ValueError): + auth.ProviderIdentifier(provider_id='', provider_uid='') + + def test_success(self, user_mgt_app): + mock_users = [{ + "localId": "uid1", + "email": "user1@example.com", + "phoneNumber": "+15555550001" + }, { + "localId": "uid2", + "email": "user2@example.com", + "phoneNumber": "+15555550002" + }, { + "localId": "uid3", + "email": "user3@example.com", + "phoneNumber": "+15555550003" + }, { + "localId": "uid4", + "email": "user4@example.com", + "phoneNumber": "+15555550004", + "providerUserInfo": [{ + "providerId": "google.com", + "rawId": "google_uid4" + }] + }] + _instrument_user_manager(user_mgt_app, 200, '{ "users": ' + json.dumps(mock_users) + '}') + + get_users_results = auth.get_users([ + auth.UidIdentifier('uid1'), + auth.EmailIdentifier('user2@example.com'), + auth.PhoneIdentifier('+15555550003'), + auth.ProviderIdentifier(provider_id='google.com', provider_uid='google_uid4'), + auth.UidIdentifier('this-user-doesnt-exist'), + ], app=user_mgt_app) + + actual = sorted( + [self._map_user_record_to_uid_email_phones(user) for user in get_users_results.users], + key=lambda user: user['uid']) + expected = sorted([ + self._map_user_record_to_uid_email_phones(auth.UserRecord(user)) + for user in mock_users + ], key=lambda user: user['uid']) + assert actual == expected + assert [u.uid for u in get_users_results.not_found] == ['this-user-doesnt-exist'] + + class TestCreateUser: already_exists_errors = { @@ -633,6 +719,54 @@ def test_delete_user_unexpected_response(self, user_mgt_app): assert isinstance(excinfo.value, exceptions.UnknownError) +class TestDeleteUsers: + + def test_empty_list(self, user_mgt_app): + delete_users_result = auth.delete_users([], app=user_mgt_app) + assert delete_users_result.success_count == 0 + assert delete_users_result.failure_count == 0 + assert len(delete_users_result.errors) == 0 + + def test_too_many_identifiers_should_fail(self, user_mgt_app): + ids = ['id' + str(i) for i in range(1001)] + with pytest.raises(ValueError): + auth.delete_users(ids, app=user_mgt_app) + + def test_invalid_id_should_fail(self, user_mgt_app): + ids = ['too long ' + '.'*128] + with pytest.raises(ValueError): + auth.delete_users(ids, app=user_mgt_app) + + def test_should_index_errors_correctly_in_results(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, """{ + "errors": [{ + "index": 0, + "localId": "uid1", + "message": "NOT_DISABLED : Disable the account before batch deletion." + }, { + "index": 2, + "localId": "uid3", + "message": "something awful" + }] + }""") + + delete_users_result = auth.delete_users(['uid1', 'uid2', 'uid3', 'uid4'], app=user_mgt_app) + assert delete_users_result.success_count == 2 + assert delete_users_result.failure_count == 2 + assert len(delete_users_result.errors) == 2 + assert delete_users_result.errors[0].index == 0 + assert delete_users_result.errors[0].reason.startswith('NOT_DISABLED') + assert delete_users_result.errors[1].index == 2 + assert delete_users_result.errors[1].reason == 'something awful' + + def test_success(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, '{}') + delete_users_result = auth.delete_users(['uid1', 'uid2', 'uid3'], app=user_mgt_app) + assert delete_users_result.success_count == 3 + assert delete_users_result.failure_count == 0 + assert len(delete_users_result.errors) == 0 + + class TestListUsers: @pytest.mark.parametrize('arg', [None, 'foo', list(), dict(), 0, -1, 1001, False])