diff --git a/queue_services/entity-digital-credentials/q_cli.py b/queue_services/entity-digital-credentials/q_cli.py index 8da8d69f1e..c893b85cda 100644 --- a/queue_services/entity-digital-credentials/q_cli.py +++ b/queue_services/entity-digital-credentials/q_cli.py @@ -87,7 +87,6 @@ def subscription_options(): payload = { 'specversion': '1.x-wip', - 'type': f'bc.registry.business.{filing_type}', 'source': f'/businesses/{identifier}', 'id': str(uuid.uuid4()), 'time': datetime.utcfromtimestamp(time.time()).replace(tzinfo=timezone.utc).isoformat(), @@ -95,12 +94,19 @@ def subscription_options(): 'identifier': identifier, 'data': { 'filing': { - 'header': {'filingId': filing_id}, 'business': {'identifier': identifier} } } } + if filing_type == 'admin.revoke': + payload['type'] = 'bc.registry.admin.revoke' + else: + payload['type'] = f'bc.registry.business.{filing_type}' + + if filing_id is not None: + payload['data']['filing']['header'] = {'filingId': filing_id} + await sc.publish(subject=subscription_options().get('subject'), payload=json.dumps(payload).encode('utf-8')) @@ -114,6 +120,7 @@ def subscription_options(): except getopt.GetoptError: print('q_cli.py -i -f -t ') sys.exit(2) + filing_id = None for opt, arg in opts: if opt == '-h': print('q_cli.py -i -f -t ') diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py index 508338a5b9..7cb1ef1f69 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py @@ -75,6 +75,7 @@ class _Config(): # pylint: disable=too-few-public-methods DB_NAME = os.getenv('ENTITY_DATABASE_NAME', '') DB_HOST = os.getenv('ENTITY_DATABASE_HOST', '') DB_PORT = os.getenv('ENTITY_DATABASE_PORT', '5432') + # pylint: disable=consider-using-f-string SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( user=DB_USER, password=DB_PASSWORD, @@ -141,6 +142,7 @@ class TestConfig(_Config): # pylint: disable=too-few-public-methods DB_HOST = os.getenv('DATABASE_TEST_HOST', '') DB_PORT = os.getenv('DATABASE_TEST_PORT', '5432') DEPLOYMENT_ENV = 'testing' + # pylint: disable=consider-using-f-string SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( user=DB_USER, password=DB_PASSWORD, diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/__init__.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/__init__.py new file mode 100644 index 0000000000..755475504e --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/__init__.py @@ -0,0 +1,17 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""The Entity Digital Credentials service. + +This module contains processors for issuing and revoking digital credentials for entity related events. +""" diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/admin_revoke.py similarity index 97% rename from queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py rename to queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/admin_revoke.py index 19688b4174..f381d92854 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/manual.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/admin_revoke.py @@ -11,7 +11,7 @@ # 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. -"""Processing manual actions.""" +"""Processing admin revocation actions.""" from entity_queue_common.service_utils import logger from legal_api.models import Business, DCRevocationReason diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py index dfb8cea9e6..9619955f5f 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py @@ -31,7 +31,7 @@ async def process(business: Business, filing_sub_type: str): logger.warning('No issued credentials found for business: %s', business.identifier) return None - if filing_sub_type == 'voluntary': + if filing_sub_type == 'voluntary': # pylint: disable=no-else-return reason = DCRevocationReason.VOLUNTARY_DISSOLUTION return replace_issued_digital_credential(business=business, issued_credential=issued_credentials[0], @@ -43,4 +43,4 @@ async def process(business: Business, filing_sub_type: str): issued_credential=issued_credentials[0], reason=reason) else: - raise Exception('Invalid filing sub type.') + raise Exception('Invalid filing sub type.') # pylint: disable=broad-exception-raised diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py index a239e0bdf4..7fe8ec49ad 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py @@ -31,13 +31,15 @@ def get_issued_digital_credentials(business: Business): try: # pylint: disable=superfluous-parens if not (connection := DCConnection.find_active_by(business_id=business.id)): - raise Exception(f'{Business.identifier} active connection not found.') + # pylint: disable=broad-exception-raised + raise Exception(f'{business.identifier} active connection not found.') # pylint: disable=superfluous-parens if not (issued_credentials := DCIssuedCredential.find_by(dc_connection_id=connection.id)): return [] return issued_credentials + # pylint: disable=broad-exception-raised except Exception as err: # noqa: B902 raise err @@ -48,11 +50,13 @@ def issue_digital_credential(business: Business, user: User, credential_type: DC if not (definition := DCDefinition.find_by(DCDefinition.CredentialType[credential_type], digital_credentials.business_schema_id, digital_credentials.business_cred_def_id)): + # pylint: disable=broad-exception-raised raise Exception(f'Definition not found for credential type: {credential_type}') # pylint: disable=superfluous-parens if not (connection := DCConnection.find_active_by(business_id=business.id)): - raise Exception(f'{Business.identifier} active connection not found.') + # pylint: disable=broad-exception-raised + raise Exception(f'{business.identifier} active connection not found.') credential_data = DigitalCredentialsHelpers.get_digital_credential_data(business, user, @@ -62,7 +66,7 @@ def issue_digital_credential(business: Business, user: User, credential_type: DC if not (response := digital_credentials.issue_credential(connection_id=connection.connection_id, definition=definition, data=credential_data)): - raise Exception('Failed to issue credential.') + raise Exception('Failed to issue credential.') # pylint: disable=broad-exception-raised issued_credential = DCIssuedCredential( dc_definition_id=definition.id, @@ -73,6 +77,7 @@ def issue_digital_credential(business: Business, user: User, credential_type: DC issued_credential.save() return issued_credential + # pylint: disable=broad-exception-raised except Exception as err: # noqa: B902 raise err @@ -83,22 +88,25 @@ def revoke_issued_digital_credential(business: Business, """Revoke an issued digital credential for a business.""" try: if not issued_credential.is_issued or issued_credential.is_revoked: + # pylint: disable=broad-exception-raised raise Exception('Credential is not issued yet or is revoked already.') # pylint: disable=superfluous-parens if not (connection := DCConnection.find_active_by(business_id=business.id)): - raise Exception(f'{Business.identifier} active connection not found.') + # pylint: disable=broad-exception-raised + raise Exception(f'{business.identifier} active connection not found.') if (revoked := digital_credentials.revoke_credential(connection.connection_id, issued_credential.credential_revocation_id, issued_credential.revocation_registry_id, reason) is None): - raise Exception('Failed to revoke credential.') + raise Exception('Failed to revoke credential.') # pylint: disable=broad-exception-raised issued_credential.is_revoked = True issued_credential.save() return revoked + # pylint: disable=broad-exception-raised except Exception as err: # noqa: B902 raise err @@ -116,17 +124,20 @@ def replace_issued_digital_credential(business: Business, issued_credential.credential_exchange_id) is not None and digital_credentials.remove_credential_exchange_record( issued_credential.credential_exchange_id) is None): - raise Exception('Failed to remove credential exchange record.') + raise Exception('Failed to remove credential exchange record.') # pylint: disable=broad-exception-raised if not (issued_business_user_credential := DCIssuedBusinessUserCredential.find_by_id( dc_issued_business_user_id=issued_credential.credential_id)): + # pylint: disable=broad-exception-raised raise Exception('Unable to find buisness user for issued credential.') if not (user := User.find_by_id(issued_business_user_credential.user_id)): # pylint: disable=superfluous-parens + # pylint: disable=broad-exception-raised raise Exception('Unable to find user for issued business user credential.') issued_credential.delete() return issue_digital_credential(business, user, credential_type) + # pylint: disable=broad-exception-raised except Exception as err: # noqa: B902 raise err diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py index f75618fc9c..03decbfc55 100644 --- a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -27,6 +27,7 @@ """ import json import os +from enum import Enum import nats from entity_queue_common.service import QueueServiceManager @@ -40,10 +41,10 @@ from entity_digital_credentials import config from entity_digital_credentials.digital_credentials_processors import ( + admin_revoke, business_number, change_of_registration, dissolution, - manual, put_back_on, ) @@ -62,15 +63,30 @@ flags.init_app(FLASK_APP) +class AdminMessage(Enum): + """Entity Digital Credential admin message type.""" + + REVOKE = 'bc.registry.admin.revoke' + + +class BusinessMessage(Enum): + """Entity Digital Credential business message type.""" + + BN = 'bc.registry.business.bn' + CHANGE_OF_REGISTRATION = f'bc.registry.business.{FilingCore.FilingTypes.CHANGEOFREGISTRATION.value}' + DISSOLUTION = f'bc.registry.business.{FilingCore.FilingTypes.DISSOLUTION.value}' + PUT_BACK_ON = f'bc.registry.business.{FilingCore.FilingTypes.PUTBACKON.value}' + + async def process_digital_credential(dc_msg: dict, flask_app: Flask): # pylint: disable=too-many-branches, too-many-statements """Process any digital credential messages in queue.""" if not dc_msg or dc_msg.get('type') not in [ - f'bc.registry.business.{FilingCore.FilingTypes.CHANGEOFREGISTRATION.value}', - f'bc.registry.business.{FilingCore.FilingTypes.DISSOLUTION.value}', - f'bc.registry.business.{FilingCore.FilingTypes.PUTBACKON.value}', - 'bc.registry.admin.bn', - 'bc.registry.admin.manual' + BusinessMessage.CHANGE_OF_REGISTRATION.value, + BusinessMessage.DISSOLUTION.value, + BusinessMessage.PUT_BACK_ON.value, + BusinessMessage.BN.value, + AdminMessage.REVOKE.value ]: return None @@ -80,26 +96,27 @@ async def process_digital_credential(dc_msg: dict, flask_app: Flask): with flask_app.app_context(): logger.debug('Attempting to process digital credential message: %s', dc_msg) - if dc_msg['type'] in ('bc.registry.business.bn', 'bc.registry.business.manual'): + if dc_msg['type'] in (BusinessMessage.BN.value, AdminMessage.REVOKE.value): # When a BN is added or changed or there is a manuak administrative update the queue message does not have # a data object. We queue the business information using the identifier and revoke/reissue the credential # immediately. - if dc_msg['identifier'] is None: + if dc_msg.get('identifier') is None: raise QueueException('Digital credential message is missing identifier') identifier = dc_msg['identifier'] if not (business := Business.find_by_identifier(identifier)): # pylint: disable=superfluous-parens + # pylint: disable=broad-exception-raised raise Exception(f'Business with identifier: {identifier} not found.') - if dc_msg['type'] == 'bc.registry.business.bn': + if dc_msg['type'] == BusinessMessage.BN.value: await business_number.process(business) - elif dc_msg['type'] == 'bc.registry.business.manual': - await manual.process(business) + elif dc_msg['type'] == AdminMessage.REVOKE.value: + await admin_revoke.process(business) else: - if dc_msg['data'] is None \ - or dc_msg['data']['filing'] is None \ - or dc_msg['data']['filing']['header'] is None \ - or dc_msg['data']['filing']['header']['filingId'] is None: + if dc_msg.get('data') is None \ + or dc_msg.get('data').get('filing') is None \ + or dc_msg.get('data').get('filing').get('header') is None \ + or dc_msg.get('data').get('filing').get('header').get('filingId') is None: raise QueueException('Digital credential message is missing data.') filing_id = dc_msg['data']['filing']['header']['filingId'] @@ -115,6 +132,7 @@ async def process_digital_credential(dc_msg: dict, flask_app: Flask): business_id = filing.business_id if not (business := Business.find_by_internal_id(business_id)): # pylint: disable=superfluous-parens + # pylint: disable=broad-exception-raised raise Exception(f'Business with internal id: {business_id} not found.') # Process individual filing events @@ -140,6 +158,6 @@ async def cb_subscription_handler(msg: nats.aio.client.Msg): logger.error('Queue Blocked - Database Issue: %s', json.dumps(dc_msg), exc_info=True) raise err # We don't want to handle the error, as a DB down would drain the queue - except (QueueException, Exception) as err: # noqa B902; pylint: disable=W0703; + except (QueueException, Exception) as err: # noqa B902; pylint: disable=W0703, disable=unused-variable # Catch Exception so that any error is still caught and the message is removed from the queue logger.error('Queue Error: %s', json.dumps(dc_msg), exc_info=True) diff --git a/queue_services/entity-digital-credentials/tests/__init__.py b/queue_services/entity-digital-credentials/tests/__init__.py new file mode 100644 index 0000000000..b3df612f9e --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""The Test Suites to ensure that the service is built and operating correctly.""" diff --git a/queue_services/entity-digital-credentials/tests/conftest.py b/queue_services/entity-digital-credentials/tests/conftest.py new file mode 100644 index 0000000000..dd58b16c81 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/conftest.py @@ -0,0 +1,121 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""Fixutres for the digital credentials queue are contained here.""" + +import os + +import pytest +from flask import Flask +from flask_migrate import Migrate, upgrade +from legal_api import db as _db +from sqlalchemy import event, text +from sqlalchemy.schema import MetaData + +from entity_digital_credentials.config import get_named_config + + +# Fixtures +@pytest.fixture(scope='session') +def app(): + """Return a session-wide application configured in TEST mode.""" + _app = Flask(__name__) + _app.config.from_object(get_named_config('testing')) + _db.init_app(_app) + + return _app + + +@pytest.fixture(scope='session') +def db(app): # pylint: disable=redefined-outer-name, invalid-name + """Return a session-wide initialised database. + + Drops all existing tables - Meta follows Postgres FKs + """ + with app.app_context(): + # Clear out any existing tables + metadata = MetaData(_db.engine) + metadata.reflect() + metadata.drop_all() + _db.drop_all() + + sequence_sql = """SELECT sequence_name FROM information_schema.sequences + WHERE sequence_schema='public' + """ + + sess = _db.session() + for seq in [name for (name,) in sess.execute(text(sequence_sql))]: + try: + sess.execute(text('DROP SEQUENCE public.%s ;' % seq)) + print('DROP SEQUENCE public.%s ' % seq) + except Exception as err: # pylint: disable=broad-except; # noqa: B902 + print(f'Error: {err}') + sess.commit() + + # ############################################ + # There are 2 approaches, an empty database, or the same one that the app will use + # create the tables + # _db.create_all() + # or + # Use Alembic to load all of the DB revisions including supporting lookup data + # This is the path we'll use in legal_api!! + + # even though this isn't referenced directly, it sets up the internal configs that upgrade needs + legal_api_dir = os.path.abspath('..').replace('queue_services', 'legal-api') + legal_api_dir = os.path.join(legal_api_dir, 'migrations') + Migrate(app, _db, directory=legal_api_dir) + upgrade() + + return _db + + +@pytest.fixture(scope='function') +def session(app, db): # pylint: disable=redefined-outer-name, invalid-name + """Return a function-scoped session.""" + with app.app_context(): + conn = db.engine.connect() + txn = conn.begin() + + options = dict(bind=conn, binds={}) + sess = db.create_scoped_session(options=options) + + # For those who have local databases on bare metal in local time. + # Otherwise some of the returns will come back in local time and unit tests will fail. + # The current DEV database uses UTC. + sess.execute("SET TIME ZONE 'UTC';") + sess.commit() + + # establish a SAVEPOINT just before beginning the test + # (http://docs.sqlalchemy.org/en/latest/orm/session_transaction.html#using-savepoint) + sess.begin_nested() + + @event.listens_for(sess(), 'after_transaction_end') + def restart_savepoint(sess2, trans): # pylint: disable=unused-variable + # Detecting whether this is indeed the nested transaction of the test + if trans.nested and not trans._parent.nested: # pylint: disable=protected-access + # Handle where test DOESN'T session.commit(), + sess2.expire_all() + sess.begin_nested() + + db.session = sess + + sql = text('select 1') + sess.execute(sql) + + yield sess + + # Cleanup + sess.remove() + # This instruction rollsback any commit that were executed in the tests. + txn.rollback() + conn.close() diff --git a/queue_services/entity-digital-credentials/tests/unit/__init__.py b/queue_services/entity-digital-credentials/tests/unit/__init__.py new file mode 100644 index 0000000000..ed8c88bf3d --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/__init__.py @@ -0,0 +1,49 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""The Unit Tests and the helper routines.""" + +from legal_api.models import Business, Filing +from sqlalchemy_continuum import versioning_manager + + +def create_business(identifier): + """Return a test business.""" + business = Business() + business.identifier = identifier + business.legal_type = Business.LegalTypes.SOLE_PROP + business.legal_name = 'test_business' + business.save() + return business + + +def create_filing(session, business_id=None, + filing_json=None, filing_type=None, + filing_status=Filing.Status.COMPLETED.value): + """Return a test filing.""" + filing = Filing() + filing._filing_type = filing_type + filing._filing_sub_type = 'test' + filing._status = filing_status + + if filing_status == Filing.Status.COMPLETED.value: + uow = versioning_manager.unit_of_work(session) + transaction = uow.create_transaction(session) + filing.transaction_id = transaction.id + if filing_json: + filing.filing_json = filing_json + if business_id: + filing.business_id = business_id + + filing.save() + return filing diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_admin_revoke.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_admin_revoke.py new file mode 100644 index 0000000000..517e3d2479 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_admin_revoke.py @@ -0,0 +1,65 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""Tests for the admin revocation processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCRevocationReason + +from entity_digital_credentials.digital_credentials_processors.admin_revoke import process +from tests.unit import create_business + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.logger') +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.revoke_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_revoke_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_revoke_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.revoke_issued_digital_credential') +async def test_processor_revokes_issued_credential(mock_revoke_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor revokes the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_revoke_issued_digital_credential.assert_called_once_with(business=business, + issued_credential={'id': 1}, + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_business_number.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_business_number.py new file mode 100644 index 0000000000..5a2ca49aa5 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_business_number.py @@ -0,0 +1,67 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""Tests for the business number processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCDefinition, DCRevocationReason + +from entity_digital_credentials.digital_credentials_processors.business_number import process +from tests.unit import create_business + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.business_number.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.business_number.logger') +@patch('entity_digital_credentials.digital_credentials_processors.business_number.replace_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_replace_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_replace_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.business_number.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.business_number.replace_issued_digital_credential') +async def test_processor_replaces_issued_credential(mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor replaces the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_replace_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_change_of_registration.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_change_of_registration.py new file mode 100644 index 0000000000..897b73605f --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_change_of_registration.py @@ -0,0 +1,122 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""Tests for the change of registration processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCDefinition, DCRevocationReason, Filing + +from entity_digital_credentials.digital_credentials_processors.change_of_registration import process +from tests.unit import create_business, create_filing + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.change_of_registration.logger') +@patch('entity_digital_credentials.digital_credentials_processors.' + + 'change_of_registration.replace_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_replace_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + filing = create_filing(session, business.id, { + 'filing': { + 'header': { + 'name': 'changeOfRegistration', + 'filingId': None + }, + 'changeOfRegistration': { + 'nameRequest': {} + } + }}, 'test', Filing.Status.COMPLETED.value) + + # Act + await process(business, filing) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_replace_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.replace_issued_digital_credential') +async def test_processor_does_not_run_if_invalid_typel(mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if not the right type.""" + # Arrange + business = create_business(identifier='FM0000001') + filing = create_filing(session, business.id, { + 'filing': { + 'header': { + 'name': 'changeOfRegistration', + 'filingId': None + }, + 'changeOfRegistration': { + 'test': {} + } + }}, 'changeOfRegistration', Filing.Status.COMPLETED.value) + + # Act + await process(business, filing) + + # Assert + mock_get_issued_digital_credentials.assert_not_called() + mock_replace_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.replace_issued_digital_credential') +async def test_processor_replaces_issued_credential(mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor replaces the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + filing = create_filing(session, business.id, { + 'filing': { + 'header': { + 'name': 'changeOfRegistration', + 'filingId': None + }, + 'changeOfRegistration': { + 'nameRequest': {} + } + }}, 'changeOfRegistration', Filing.Status.COMPLETED.value) + + # Act + await process(business, filing) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_replace_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_dissolution.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_dissolution.py new file mode 100644 index 0000000000..1af35dd441 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_dissolution.py @@ -0,0 +1,122 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""Tests for the dissolution processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCDefinition, DCRevocationReason + +from entity_digital_credentials.digital_credentials_processors.dissolution import process +from tests.unit import create_business + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.logger') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.replace_issued_digital_credential') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.revoke_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_revoke_issued_digital_credential, + mock_replace_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business, 'test') + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_replace_issued_digital_credential.assert_not_called() + mock_revoke_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.replace_issued_digital_credential') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.revoke_issued_digital_credential') +async def test_processor_does_not_run_if_invalid_sub_type(mock_revoke_issued_digital_credential, + mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if an invalid sub type provided.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + with pytest.raises(Exception) as excinfo: + await process(business, 'test') + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_replace_issued_digital_credential.assert_not_called() + mock_revoke_issued_digital_credential.assert_not_called() + assert 'Invalid filing sub type.' in str(excinfo) + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.replace_issued_digital_credential') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.revoke_issued_digital_credential') +async def test_processor_replaces_issued_credential(mock_revoke_issued_digital_credential, + mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor replaces the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business, 'voluntary') + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_replace_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.VOLUNTARY_DISSOLUTION) + mock_revoke_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.replace_issued_digital_credential') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.revoke_issued_digital_credential') +async def test_processor_revokes_issued_credential(mock_revoke_issued_digital_credential, + mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor revokes the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business, 'administrative') + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_revoke_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + reason=DCRevocationReason.ADMINISTRATIVE_DISSOLUTION) + mock_replace_issued_digital_credential.assert_not_called() diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_put_back_on.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_put_back_on.py new file mode 100644 index 0000000000..ac6e84d096 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_put_back_on.py @@ -0,0 +1,66 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""Tests for the put back on processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCRevocationReason + +from entity_digital_credentials.digital_credentials_processors.put_back_on import process +from tests.unit import create_business + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.logger') +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.revoke_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_revoke_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_revoke_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.revoke_issued_digital_credential') +async def test_processor_revokes_issued_credential(mock_revoke_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor revokes the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_revoke_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + reason=DCRevocationReason.PUT_BACK_ON) diff --git a/queue_services/entity-digital-credentials/tests/unit/test_worker.py b/queue_services/entity-digital-credentials/tests/unit/test_worker.py new file mode 100644 index 0000000000..4baafb89da --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/test_worker.py @@ -0,0 +1,225 @@ +# Copyright © 2023 Province of British Columbia +# +# 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. +"""Tests for the queue worker are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import Filing + +from entity_digital_credentials.worker import process_digital_credential +from tests.unit import create_business, create_filing + + +ADMIN_REVOKE = 'bc.registry.admin.revoke' +BUSINESS_NUMBER = 'bc.registry.business.bn' +CHANGE_OF_REGISTRATION = 'bc.registry.business.changeOfRegistration' +DISSOLUTION = 'bc.registry.business.dissolution' +PUT_BACK_ON = 'bc.registry.business.putBackOn' + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.process') +@patch('entity_digital_credentials.digital_credentials_processors.business_number.process') +@patch('entity_digital_credentials.digital_credentials_processors.change_of_registration.process') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.process') +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.process') +async def test_processes_not_run(mock_put_back_on, mock_dissolution, mock_change_of_registration, + mock_business_number, mock_admin_revoke, app, session): + """Assert processors are not called if message type is not supported.""" + # Arrange + dc_msg = {'type': 'bc.registry.business.test', 'identifier': 'FM0000001'} + + # Act + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + mock_admin_revoke.assert_not_called() + mock_business_number.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_dissolution.assert_not_called() + mock_put_back_on.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dc_msg', [{ + 'type': ADMIN_REVOKE, + 'identifier': 'FM0000001' +}, { + 'type': BUSINESS_NUMBER, + 'identifier': 'FM0000002' +}]) +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.process') +@patch('entity_digital_credentials.digital_credentials_processors.business_number.process') +@patch('entity_digital_credentials.digital_credentials_processors.change_of_registration.process') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.process') +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.process') +async def test_processes_no_filing_required(mock_put_back_on, mock_dissolution, mock_change_of_registration, + mock_business_number, mock_admin_revoke, dc_msg, app, session): + """Assert processor runs if given the right message type.""" + # Arrange + business = create_business(dc_msg['identifier']) + + # Act + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + if dc_msg['type'] == ADMIN_REVOKE: + mock_admin_revoke.assert_called_once() + assert business.identifier == 'FM0000001' + mock_admin_revoke.assert_called_with(business) + + # Other processors should not be called + mock_business_number.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_dissolution.assert_not_called() + mock_put_back_on.assert_not_called() + elif dc_msg['type'] == BUSINESS_NUMBER: + mock_business_number.assert_called_once() + assert business.identifier == 'FM0000002' + mock_business_number.assert_called_with(business) + + # Other processors should not be called + mock_admin_revoke.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_dissolution.assert_not_called() + mock_put_back_on.assert_not_called() + else: + assert False + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dc_msg', [{ + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001', + 'data': {'filing': {'header': {'filingId': None}}} +}, { + 'type': DISSOLUTION, + 'identifier': 'FM0000002', + 'data': {'filing': {'header': {'filingId': None}}} +}, { + 'type': PUT_BACK_ON, + 'identifier': 'FM0000003', + 'data': {'filing': {'header': {'filingId': None}}} +}]) +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.process') +@patch('entity_digital_credentials.digital_credentials_processors.business_number.process') +@patch('entity_digital_credentials.digital_credentials_processors.change_of_registration.process') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.process') +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.process') +async def test_processes_filing_required(mock_put_back_on, mock_dissolution, mock_change_of_registration, + mock_business_number, mock_admin_revoke, dc_msg, app, session): + """Assert processor runs if given the right message type.""" + # Arrange + business = create_business(dc_msg['identifier']) + filing_type = dc_msg['type'].replace('bc.registry.business.', '') + filing = create_filing(session, business.id, None, filing_type, Filing.Status.COMPLETED.value) + dc_msg['data']['filing']['header']['filingId'] = filing.id + + # Act + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + if dc_msg['type'] == CHANGE_OF_REGISTRATION: + mock_change_of_registration.assert_called_once() + assert business.identifier == 'FM0000001' + mock_change_of_registration.assert_called_with(business, filing) + + # Other processors should not be called + mock_admin_revoke.assert_not_called() + mock_business_number.assert_not_called() + mock_dissolution.assert_not_called() + mock_put_back_on.assert_not_called() + elif dc_msg['type'] == DISSOLUTION: + mock_dissolution.assert_called_once() + assert business.identifier == 'FM0000002' + mock_dissolution.assert_called_with(business, 'test') + + # Other processors should not be called + mock_admin_revoke.assert_not_called() + mock_business_number.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_put_back_on.assert_not_called() + elif dc_msg['type'] == PUT_BACK_ON: + mock_put_back_on.assert_called_once() + assert business.identifier == 'FM0000003' + mock_put_back_on.assert_called_with(business) + + # Other processors should not be called + mock_admin_revoke.assert_not_called() + mock_business_number.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_dissolution.assert_not_called() + else: + assert False + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dc_msg', [{ + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001' +}, { + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001', + 'data': {} +}, { + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001', + 'data': {'filing': {}} +}, { + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001', + 'data': {'filing': {'header': {}}} +}]) +async def test_process_failure_filing_required(app, session, dc_msg): + """Assert processor throws QueueException if filing data not in message.""" + # Arrange + from entity_queue_common.service_utils import QueueException + + # Act + with pytest.raises(QueueException) as excinfo: + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + assert 'Digital credential message is missing data.' in str(excinfo) + + +@pytest.mark.asyncio +async def test_process_failure_no_identifier_no_filing_required(app, session): + """Assert processor throws QueueException if no idenfiier in message.""" + # Arrange + from entity_queue_common.service_utils import QueueException + dc_msg = {'type': ADMIN_REVOKE} + + # Act + with pytest.raises(QueueException) as excinfo: + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + assert 'Digital credential message is missing identifier' in str(excinfo) + + +@pytest.mark.asyncio +async def test_process_failure_no_business_no_filing_required(app, session): + """Assert processor throws Exception if idenfiier in message but business not found.""" + # Arrange + identifier = 'FM0000001' + dc_msg = {'type': ADMIN_REVOKE, 'identifier': identifier} + + # Act + with pytest.raises(Exception) as excinfo: + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + assert f'Business with identifier: {identifier} not found.' in str(excinfo)