From 935a2bf734513b3ab1aad7946b97cf8a3683268f Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 20 Mar 2020 11:51:11 -0700 Subject: [PATCH 1/3] Create tenant API --- firebase_admin/_auth_utils.py | 6 +++++ firebase_admin/tenant_mgt.py | 45 +++++++++++++++++++++++++++++++++++ tests/test_tenant_mgt.py | 33 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index 7048616bb..764f71de0 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -157,6 +157,12 @@ def validate_int(value, label, low=None, high=None): raise ValueError('{0} must not be larger than {1}.'.format(label, high)) return val_int +def validate_boolean(value, label): + """Validates that the given value is a boolean.""" + if not isinstance(value, bool): + raise ValueError('Invalid type for {0}: {1}.'.format(label, value)) + return value + def validate_custom_claims(custom_claims, required=False): """Validates the specified custom claims. diff --git a/firebase_admin/tenant_mgt.py b/firebase_admin/tenant_mgt.py index 43d22ea50..925d5a77e 100644 --- a/firebase_admin/tenant_mgt.py +++ b/firebase_admin/tenant_mgt.py @@ -59,6 +59,31 @@ def get_tenant(tenant_id, app=None): return tenant_mgt_service.get_tenant(tenant_id) +def create_tenant( + display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None, app=None): + """Creates a new tenant from the given options. + + Args: + display_name: Display name string for the new tenant (optional). + allow_password_sign_up: A boolean indicating whether to enable or disable the email sign-in + provider. + enable_email_link_sign_in: A boolean indicating whether to enable or disable email link + sign-in. Disabling this makes the password required for email sign-in. + app: An App instance (optional). + + Returns: + Tenant: A Tenant object. + + Raises: + ValueError: If any of the given arguments are invalid. + FirebaseError: If an error occurs while creating the tenant. + """ + tenant_mgt_service = _get_tenant_mgt_service(app) + return tenant_mgt_service.create_tenant( + display_name=display_name, allow_password_sign_up=allow_password_sign_up, + enable_email_link_sign_in=enable_email_link_sign_in) + + def delete_tenant(tenant_id, app=None): """Deletes the tenant corresponding to the given ``tenant_id``. @@ -141,6 +166,26 @@ def get_tenant(self, tenant_id): else: return Tenant(body) + def create_tenant( + self, display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None): + """Creates a new tenant from the given parameters.""" + payload = {} + if display_name is not None: + payload['displayName'] = _auth_utils.validate_display_name(display_name) + if allow_password_sign_up is not None: + payload['allowPasswordSignup'] = _auth_utils.validate_boolean( + allow_password_sign_up, 'allowPasswordSignup') + if enable_email_link_sign_in is not None: + payload['enableEmailLinkSignin'] = _auth_utils.validate_boolean( + enable_email_link_sign_in, 'enableEmailLinkSignin') + + try: + body = self.client.body('post', '/tenants', data=payload) + except requests.exceptions.RequestException as error: + raise _auth_utils.handle_auth_backend_error(error) + else: + return Tenant(body) + def delete_tenant(self, tenant_id): """Deletes the tenant corresponding to the given ``tenant_id``.""" if not isinstance(tenant_id, str) or not tenant_id: diff --git a/tests/test_tenant_mgt.py b/tests/test_tenant_mgt.py index 7d71ca59d..387b02055 100644 --- a/tests/test_tenant_mgt.py +++ b/tests/test_tenant_mgt.py @@ -120,6 +120,39 @@ def test_tenant_not_found(self, tenant_mgt_app): assert excinfo.value.cause is not None +class TestCreateTenant: + + @pytest.mark.parametrize('display_name', ['', True, False, 1, 0, list(), tuple(), dict()]) + def test_invalid_display_name(self, display_name): + with pytest.raises(ValueError): + tenant_mgt.create_tenant(display_name=display_name) + + def test_create_tenant_minimal(self, tenant_mgt_app): + _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) + tenant = tenant_mgt.create_tenant(app=tenant_mgt_app) + assert tenant.tenant_id == 'tenant-id' + assert tenant.display_name == 'Test Tenant' + assert tenant.allow_password_sign_up is True + assert tenant.enable_email_link_sign_in is True + + assert len(recorder) == 1 + req = recorder[0] + assert req.method == 'POST' + assert req.url == '{0}/tenants'.format(TENANT_MGT_URL_PREFIX) + assert req.body == 'foo' + + def test_error(self, tenant_mgt_app): + _instrument_tenant_mgt(tenant_mgt_app, 500, '{}') + with pytest.raises(exceptions.InternalError) as excinfo: + tenant_mgt.create_tenant(app=tenant_mgt_app) + + error_msg = 'Unexpected error response: {}' + assert excinfo.value.code == exceptions.INTERNAL + assert str(excinfo.value) == error_msg + assert excinfo.value.http_response is not None + assert excinfo.value.cause is not None + + class TestDeleteTenant: @pytest.mark.parametrize('tenant_id', INVALID_TENANT_IDS) From e91575a09bd7dfae741b4614ef333375b13acf69 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 20 Mar 2020 14:21:30 -0700 Subject: [PATCH 2/3] Added update tenant API --- firebase_admin/_auth_utils.py | 18 ++++ firebase_admin/tenant_mgt.py | 62 ++++++++++++- tests/test_tenant_mgt.py | 169 ++++++++++++++++++++++++++++++---- 3 files changed, 230 insertions(+), 19 deletions(-) diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index 764f71de0..d867ac1ff 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -157,6 +157,12 @@ def validate_int(value, label, low=None, high=None): raise ValueError('{0} must not be larger than {1}.'.format(label, high)) return val_int +def validate_string(value, label): + """Validates that the given value is a string.""" + if not isinstance(value, str): + raise ValueError('Invalid type for {0}: {1}.'.format(label, value)) + return value + def validate_boolean(value, label): """Validates that the given value is a boolean.""" if not isinstance(value, bool): @@ -198,6 +204,18 @@ def validate_action_type(action_type): Valid values are {1}'.format(action_type, ', '.join(VALID_EMAIL_ACTION_TYPES))) return action_type +def build_update_mask(params): + mask = [] + for key, value in params.items(): + if isinstance(value, dict): + child_mask = build_update_mask(value) + for child in child_mask: + mask.append('{0}.{1}'.format(key, child)) + else: + mask.append(key) + + return sorted(mask) + class UidAlreadyExistsError(exceptions.AlreadyExistsError): """The user with the provided uid already exists.""" diff --git a/firebase_admin/tenant_mgt.py b/firebase_admin/tenant_mgt.py index 925d5a77e..0dd439b81 100644 --- a/firebase_admin/tenant_mgt.py +++ b/firebase_admin/tenant_mgt.py @@ -84,6 +84,34 @@ def create_tenant( enable_email_link_sign_in=enable_email_link_sign_in) +def update_tenant( + tenant_id, display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None, + app=None): + """Updates an existing tenant with the given options. + + Args: + tenant_id: ID of the tenant to update. + display_name: Display name string for the new tenant (optional). + allow_password_sign_up: A boolean indicating whether to enable or disable the email sign-in + provider. + enable_email_link_sign_in: A boolean indicating whether to enable or disable email link + sign-in. Disabling this makes the password required for email sign-in. + app: An App instance (optional). + + Returns: + Tenant: The updated Tenant object. + + Raises: + ValueError: If any of the given arguments are invalid. + TenantNotFoundError: If no tenant exists by the given ID. + FirebaseError: If an error occurs while creating the tenant. + """ + tenant_mgt_service = _get_tenant_mgt_service(app) + return tenant_mgt_service.update_tenant( + tenant_id, display_name=display_name, allow_password_sign_up=allow_password_sign_up, + enable_email_link_sign_in=enable_email_link_sign_in) + + def delete_tenant(tenant_id, app=None): """Deletes the tenant corresponding to the given ``tenant_id``. @@ -171,7 +199,7 @@ def create_tenant( """Creates a new tenant from the given parameters.""" payload = {} if display_name is not None: - payload['displayName'] = _auth_utils.validate_display_name(display_name) + payload['displayName'] = _auth_utils.validate_string(display_name, 'displayName') if allow_password_sign_up is not None: payload['allowPasswordSignup'] = _auth_utils.validate_boolean( allow_password_sign_up, 'allowPasswordSignup') @@ -180,7 +208,37 @@ def create_tenant( enable_email_link_sign_in, 'enableEmailLinkSignin') try: - body = self.client.body('post', '/tenants', data=payload) + body = self.client.body('post', '/tenants', json=payload) + except requests.exceptions.RequestException as error: + raise _auth_utils.handle_auth_backend_error(error) + else: + return Tenant(body) + + def update_tenant( + self, tenant_id, display_name=None, allow_password_sign_up=None, + enable_email_link_sign_in=None): + """Updates the specified tenant with the given parameters.""" + if not isinstance(tenant_id, str) or not tenant_id: + raise ValueError('Tenant ID must be a non-empty string.') + + payload = {} + if display_name is not None: + payload['displayName'] = _auth_utils.validate_string(display_name, 'displayName') + if allow_password_sign_up is not None: + payload['allowPasswordSignup'] = _auth_utils.validate_boolean( + allow_password_sign_up, 'allowPasswordSignup') + if enable_email_link_sign_in is not None: + payload['enableEmailLinkSignin'] = _auth_utils.validate_boolean( + enable_email_link_sign_in, 'enableEmailLinkSignin') + + if not payload: + raise ValueError('At least one parameter must be specified for update.') + + url = '/tenants/{0}'.format(tenant_id) + update_mask = ','.join(_auth_utils.build_update_mask(payload)) + params = 'updateMask={0}'.format(update_mask) + try: + body = self.client.body('patch', url, json=payload, params=params) except requests.exceptions.RequestException as error: raise _auth_utils.handle_auth_backend_error(error) else: diff --git a/tests/test_tenant_mgt.py b/tests/test_tenant_mgt.py index 387b02055..2c1b26d15 100644 --- a/tests/test_tenant_mgt.py +++ b/tests/test_tenant_mgt.py @@ -14,6 +14,8 @@ """Test cases for the firebase_admin.tenant_mgt module.""" +import json + import pytest import firebase_admin @@ -36,6 +38,7 @@ }""" INVALID_TENANT_IDS = [None, '', 0, 1, True, False, list(), tuple(), dict()] +INVALID_BOOLEANS = ['', 1, 0, list(), tuple(), dict()] TENANT_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v2beta1/projects/mock-project-id' @@ -98,11 +101,8 @@ def test_invalid_tenant_id(self, tenant_id): def test_get_tenant(self, tenant_mgt_app): _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) tenant = tenant_mgt.get_tenant('tenant-id', app=tenant_mgt_app) - assert tenant.tenant_id == 'tenant-id' - assert tenant.display_name == 'Test Tenant' - assert tenant.allow_password_sign_up is True - assert tenant.enable_email_link_sign_in is True + _assert_tenant(tenant) assert len(recorder) == 1 req = recorder[0] assert req.method == 'GET' @@ -122,24 +122,56 @@ def test_tenant_not_found(self, tenant_mgt_app): class TestCreateTenant: - @pytest.mark.parametrize('display_name', ['', True, False, 1, 0, list(), tuple(), dict()]) - def test_invalid_display_name(self, display_name): - with pytest.raises(ValueError): - tenant_mgt.create_tenant(display_name=display_name) + @pytest.mark.parametrize('display_name', [True, False, 1, 0, list(), tuple(), dict()]) + def test_invalid_display_name(self, display_name, tenant_mgt_app): + with pytest.raises(ValueError) as excinfo: + tenant_mgt.create_tenant(display_name=display_name, app=tenant_mgt_app) + assert str(excinfo.value).startswith('Invalid type for displayName') + + @pytest.mark.parametrize('allow', INVALID_BOOLEANS) + def test_invalid_allow_password_sign_up(self, allow, tenant_mgt_app): + with pytest.raises(ValueError) as excinfo: + tenant_mgt.create_tenant(allow_password_sign_up=allow, app=tenant_mgt_app) + assert str(excinfo.value).startswith('Invalid type for allowPasswordSignup') + + @pytest.mark.parametrize('enable', INVALID_BOOLEANS) + def test_invalid_enable_email_link_sign_in(self, enable, tenant_mgt_app): + with pytest.raises(ValueError) as excinfo: + tenant_mgt.create_tenant(enable_email_link_sign_in=enable, app=tenant_mgt_app) + assert str(excinfo.value).startswith('Invalid type for enableEmailLinkSignin') + + def test_create_tenant(self, tenant_mgt_app): + _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) + tenant = tenant_mgt.create_tenant( + display_name='My Tenant', allow_password_sign_up=True, enable_email_link_sign_in=True, + app=tenant_mgt_app) + + _assert_tenant(tenant) + self._assert_request(recorder, { + 'displayName': 'My Tenant', + 'allowPasswordSignup': True, + 'enableEmailLinkSignin': True, + }) + + def test_create_tenant_false_values(self, tenant_mgt_app): + _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) + tenant = tenant_mgt.create_tenant( + display_name='', allow_password_sign_up=False, enable_email_link_sign_in=False, + app=tenant_mgt_app) + + _assert_tenant(tenant) + self._assert_request(recorder, { + 'displayName': '', + 'allowPasswordSignup': False, + 'enableEmailLinkSignin': False, + }) def test_create_tenant_minimal(self, tenant_mgt_app): _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) tenant = tenant_mgt.create_tenant(app=tenant_mgt_app) - assert tenant.tenant_id == 'tenant-id' - assert tenant.display_name == 'Test Tenant' - assert tenant.allow_password_sign_up is True - assert tenant.enable_email_link_sign_in is True - assert len(recorder) == 1 - req = recorder[0] - assert req.method == 'POST' - assert req.url == '{0}/tenants'.format(TENANT_MGT_URL_PREFIX) - assert req.body == 'foo' + _assert_tenant(tenant) + self._assert_request(recorder, {}) def test_error(self, tenant_mgt_app): _instrument_tenant_mgt(tenant_mgt_app, 500, '{}') @@ -152,6 +184,102 @@ def test_error(self, tenant_mgt_app): assert excinfo.value.http_response is not None assert excinfo.value.cause is not None + def _assert_request(self, recorder, body): + assert len(recorder) == 1 + req = recorder[0] + assert req.method == 'POST' + assert req.url == '{0}/tenants'.format(TENANT_MGT_URL_PREFIX) + got = json.loads(req.body.decode()) + assert got == body + + +class TestUpdateTenant: + + @pytest.mark.parametrize('tenant_id', INVALID_TENANT_IDS) + def test_invalid_tenant_id(self, tenant_id, tenant_mgt_app): + with pytest.raises(ValueError) as excinfo: + tenant_mgt.update_tenant(tenant_id, display_name='My Tenant', app=tenant_mgt_app) + assert str(excinfo.value).startswith('Tenant ID must be a non-empty string') + + @pytest.mark.parametrize('display_name', [True, False, 1, 0, list(), tuple(), dict()]) + def test_invalid_display_name(self, display_name, tenant_mgt_app): + with pytest.raises(ValueError) as excinfo: + tenant_mgt.update_tenant('tenant-id', display_name=display_name, app=tenant_mgt_app) + assert str(excinfo.value).startswith('Invalid type for displayName') + + @pytest.mark.parametrize('allow', INVALID_BOOLEANS) + def test_invalid_allow_password_sign_up(self, allow, tenant_mgt_app): + with pytest.raises(ValueError) as excinfo: + tenant_mgt.update_tenant('tenant-id', allow_password_sign_up=allow, app=tenant_mgt_app) + assert str(excinfo.value).startswith('Invalid type for allowPasswordSignup') + + @pytest.mark.parametrize('enable', INVALID_BOOLEANS) + def test_invalid_enable_email_link_sign_in(self, enable, tenant_mgt_app): + with pytest.raises(ValueError) as excinfo: + tenant_mgt.update_tenant( + 'tenant-id', enable_email_link_sign_in=enable, app=tenant_mgt_app) + assert str(excinfo.value).startswith('Invalid type for enableEmailLinkSignin') + + def test_update_tenant(self, tenant_mgt_app): + _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) + tenant = tenant_mgt.update_tenant( + 'tenant-id', display_name='My Tenant', allow_password_sign_up=True, + enable_email_link_sign_in=True, app=tenant_mgt_app) + + _assert_tenant(tenant) + body = { + 'displayName': 'My Tenant', + 'allowPasswordSignup': True, + 'enableEmailLinkSignin': True, + } + mask = ['allowPasswordSignup', 'displayName', 'enableEmailLinkSignin'] + self._assert_request(recorder, body, mask) + + def test_update_tenant_false_values(self, tenant_mgt_app): + _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) + tenant = tenant_mgt.update_tenant( + 'tenant-id', display_name='', allow_password_sign_up=False, + enable_email_link_sign_in=False, app=tenant_mgt_app) + + _assert_tenant(tenant) + body = { + 'displayName': '', + 'allowPasswordSignup': False, + 'enableEmailLinkSignin': False, + } + mask = ['allowPasswordSignup', 'displayName', 'enableEmailLinkSignin'] + self._assert_request(recorder, body, mask) + + def test_update_tenant_minimal(self, tenant_mgt_app): + _, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, GET_TENANT_RESPONSE) + tenant = tenant_mgt.update_tenant( + 'tenant-id', display_name='My Tenant', app=tenant_mgt_app) + + _assert_tenant(tenant) + body = {'displayName': 'My Tenant'} + mask = ['displayName'] + self._assert_request(recorder, body, mask) + + def test_tenant_not_found_error(self, tenant_mgt_app): + _instrument_tenant_mgt(tenant_mgt_app, 500, TENANT_NOT_FOUND_RESPONSE) + with pytest.raises(tenant_mgt.TenantNotFoundError) as excinfo: + tenant_mgt.update_tenant('tenant', display_name='My Tenant', app=tenant_mgt_app) + + error_msg = 'No tenant found for the given identifier (TENANT_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 _assert_request(self, recorder, body, mask): + assert len(recorder) == 1 + req = recorder[0] + assert req.method == 'PATCH' + assert req.url == '{0}/tenants/tenant-id?updateMask={1}'.format( + TENANT_MGT_URL_PREFIX, ','.join(mask)) + got = json.loads(req.body.decode()) + assert got == body + class TestDeleteTenant: @@ -179,3 +307,10 @@ def test_tenant_not_found(self, tenant_mgt_app): assert str(excinfo.value) == error_msg assert excinfo.value.http_response is not None assert excinfo.value.cause is not None + + +def _assert_tenant(tenant): + assert tenant.tenant_id == 'tenant-id' + assert tenant.display_name == 'Test Tenant' + assert tenant.allow_password_sign_up is True + assert tenant.enable_email_link_sign_in is True From 54c579aafd8e6a5d18355cf527593c19450e6054 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 20 Mar 2020 14:25:51 -0700 Subject: [PATCH 3/3] Added docstring to fix lint error --- firebase_admin/_auth_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index d867ac1ff..0b91fe46e 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -205,6 +205,7 @@ def validate_action_type(action_type): return action_type def build_update_mask(params): + """Creates an update mask list from the given dictionary.""" mask = [] for key, value in params.items(): if isinstance(value, dict):