diff --git a/aries_cloudagent/anoncreds/revocation.py b/aries_cloudagent/anoncreds/revocation.py index 42ad5107e6..13a9775fcc 100644 --- a/aries_cloudagent/anoncreds/revocation.py +++ b/aries_cloudagent/anoncreds/revocation.py @@ -21,7 +21,7 @@ RevocationRegistryDefinitionPrivate, RevocationStatusList, ) -from aries_askar.error import AskarError +from aries_askar.error import AskarError, AskarErrorCode from requests import RequestException, Session from aries_cloudagent.anoncreds.models.anoncreds_cred_def import CredDef @@ -58,6 +58,7 @@ CATEGORY_REV_REG_DEF = "revocation_reg_def" CATEGORY_REV_REG_DEF_PRIVATE = "revocation_reg_def_private" CATEGORY_REV_REG_ISSUER = "revocation_reg_def_issuer" +CATEGORY_REV_REG_DEF_LOCK = "revocation_reg_def_lock" STATE_REVOCATION_POSTED = "posted" STATE_REVOCATION_PENDING = "pending" REV_REG_DEF_STATE_ACTIVE = "active" @@ -91,6 +92,7 @@ def __init__(self, profile: Profile): """ self._profile = profile + self._background_tasks: set = set() @property def profile(self) -> AskarAnoncredsProfile: @@ -747,64 +749,90 @@ async def get_or_fetch_local_tails_path(self, rev_reg_def: RevRegDef) -> str: async def handle_full_registry(self, rev_reg_def_id: str): """Update the registry status and start the next registry generation.""" - async with self.profile.session() as session: - active_rev_reg_def = await session.handle.fetch( - CATEGORY_REV_REG_DEF, rev_reg_def_id + async with self.profile.transaction() as txn: + active_rev_reg_def = await txn.handle.fetch( + CATEGORY_REV_REG_DEF, rev_reg_def_id, for_update=True ) - if active_rev_reg_def: - # ok, we have an active rev reg. - # find the backup/fallover rev reg (finished and not active) - rev_reg_defs = await session.handle.fetch_all( - CATEGORY_REV_REG_DEF, - { - "active": json.dumps(False), - "cred_def_id": active_rev_reg_def.value_json["credDefId"], - "state": RevRegDefState.STATE_FINISHED, - }, - limit=1, - ) - if len(rev_reg_defs): - backup_rev_reg_def_id = rev_reg_defs[0].name - else: - # attempted to create and register here but fails in practical usage. - # the indexes and list do not get set properly (timing issue?) - # if max cred num = 4 for instance, will get - # Revocation status list does not have the index 4 - # in _create_credential calling Credential.create - raise AnonCredsRevocationError( - "Error handling full registry. No backup registry available." - ) - - # set the backup to active... - if backup_rev_reg_def_id: - await self.set_active_registry(backup_rev_reg_def_id) + if not active_rev_reg_def: + return - async with self.profile.transaction() as txn: - # re-fetch the old active (it's been updated), we need to mark as full - active_rev_reg_def = await txn.handle.fetch( - CATEGORY_REV_REG_DEF, rev_reg_def_id, for_update=True - ) - tags = active_rev_reg_def.tags - tags["state"] = RevRegDefState.STATE_FULL - await txn.handle.replace( - CATEGORY_REV_REG_DEF, - active_rev_reg_def.name, - active_rev_reg_def.value, - tags, - ) - await txn.commit() + if active_rev_reg_def.tags.get("state") == RevRegDefState.STATE_FULL: + LOGGER.info("Registry %s already marked as full by another instance, skipping rotation", rev_reg_def_id) + return - # create our next fallover/backup - backup_reg = await self.create_and_register_revocation_registry_definition( - issuer_id=active_rev_reg_def.value_json["issuerId"], - cred_def_id=active_rev_reg_def.value_json["credDefId"], - registry_type=active_rev_reg_def.value_json["revocDefType"], - tag=str(uuid4()), - max_cred_num=active_rev_reg_def.value_json["value"]["maxCredNum"], + cred_def_id = active_rev_reg_def.value_json["credDefId"] + + # Find backup + rev_reg_defs = await txn.handle.fetch_all( + CATEGORY_REV_REG_DEF, + { + "active": json.dumps(False), + "cred_def_id": cred_def_id, + "state": RevRegDefState.STATE_FINISHED, + }, + limit=1, + for_update=True, ) - LOGGER.info(f"previous rev_reg_def_id = {rev_reg_def_id}") - LOGGER.info(f"current rev_reg_def_id = {backup_rev_reg_def_id}") - LOGGER.info(f"backup reg = {backup_reg}") + + if not rev_reg_defs: + raise AnonCredsRevocationError("Error handling full registry. No backup registry available.") + + backup_rev_reg_def_id = rev_reg_defs[0].name + + # Mark active as FULL and False + tags = active_rev_reg_def.tags + tags["state"] = RevRegDefState.STATE_FULL + tags["active"] = json.dumps(False) + await txn.handle.replace( + CATEGORY_REV_REG_DEF, + active_rev_reg_def.name, + active_rev_reg_def.value, + tags, + ) + + # Mark backup as active + backup_entry = rev_reg_defs[0] + backup_tags = backup_entry.tags + backup_tags["active"] = json.dumps(True) + await txn.handle.replace( + CATEGORY_REV_REG_DEF, + backup_entry.name, + backup_entry.value, + backup_tags, + ) + + # Extract info for creating next backup + issuer_id = active_rev_reg_def.value_json["issuerId"] + registry_type = active_rev_reg_def.value_json["revocDefType"] + max_cred_num = active_rev_reg_def.value_json["value"]["maxCredNum"] + + await txn.commit() + + LOGGER.info(f"Rotated registry: previous={rev_reg_def_id}, current={backup_rev_reg_def_id}") + + # Launch background task to create new backup + # We do NOT await it here to avoid blocking the HTTP response and causing a timeout + async def _create_backup_with_retry(): + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + await self.create_and_register_revocation_registry_definition( + issuer_id=issuer_id, + cred_def_id=cred_def_id, + registry_type=registry_type, + tag=str(uuid4()), + max_cred_num=max_cred_num, + ) + LOGGER.info("Successfully created new backup registry in background.") + return + except Exception as e: + LOGGER.error("Failed to create backup registry in background (attempt %d/%d): %s", attempt, max_attempts, e) + if attempt < max_attempts: + await asyncio.sleep(10) + + task = asyncio.create_task(_create_backup_with_retry()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) async def decommission_registry(self, cred_def_id: str): """Decommission post-init registries and start the next registry generation.""" @@ -905,6 +933,7 @@ async def _create_credential( credential_values: dict, rev_reg_def_id: Optional[str] = None, tails_file_path: Optional[str] = None, + preallocated_rev_reg_index: Optional[int] = None, ) -> Tuple[str, str]: try: async with self.profile.session() as session: @@ -939,53 +968,68 @@ async def _create_credential( if rev_reg_def_id and tails_file_path: try: - async with self.profile.transaction() as txn: - rev_list = await txn.handle.fetch(CATEGORY_REV_LIST, rev_reg_def_id) - rev_reg_def = await txn.handle.fetch( - CATEGORY_REV_REG_DEF, rev_reg_def_id - ) - rev_key = await txn.handle.fetch( - CATEGORY_REV_REG_DEF_PRIVATE, rev_reg_def_id - ) - if not rev_list: - raise AnonCredsRevocationError("Revocation registry not found") - if not rev_reg_def: - raise AnonCredsRevocationError( - "Revocation registry definition not found" - ) - if not rev_key: - raise AnonCredsRevocationError( - "Revocation registry definition private data not found" + # If an index was already pre-allocated by the caller (using a distributed lock), + # we don't need a transaction to increment it here. We just fetch the current state. + if preallocated_rev_reg_index is not None: + async with self.profile.session() as session: + rev_list = await session.handle.fetch(CATEGORY_REV_LIST, rev_reg_def_id) + rev_reg_def = await session.handle.fetch(CATEGORY_REV_REG_DEF, rev_reg_def_id) + rev_key = await session.handle.fetch(CATEGORY_REV_REG_DEF_PRIVATE, rev_reg_def_id) + + if not rev_list or not rev_reg_def or not rev_key: + raise AnonCredsRevocationError("Revocation registry components not found") + + try: + rev_reg_def = RevocationRegistryDefinition.load(rev_reg_def.raw_value) + rev_list = RevocationStatusList.load(rev_list.value_json["rev_list"]) + except AnoncredsError as err: + raise AnonCredsRevocationError("Error loading revocation registry definition") from err + + rev_reg_index = preallocated_rev_reg_index + else: + # Fallback for backwards compatibility (e.g. tests) + async with self.profile.transaction() as txn: + rev_list = await txn.handle.fetch(CATEGORY_REV_LIST, rev_reg_def_id, for_update=True) + rev_reg_def = await txn.handle.fetch( + CATEGORY_REV_REG_DEF, rev_reg_def_id ) - # NOTE: we increment the index ahead of time to keep the - # transaction short. The revocation registry itself will NOT - # be updated because we always use ISSUANCE_BY_DEFAULT. - # If something goes wrong later, the index will be skipped. - # FIXME - double check issuance type in case of upgraded wallet? - rev_info = rev_list.value_json - rev_info_tags = rev_list.tags - rev_reg_index = rev_info["next_index"] - try: - rev_reg_def = RevocationRegistryDefinition.load( - rev_reg_def.raw_value + rev_key = await txn.handle.fetch( + CATEGORY_REV_REG_DEF_PRIVATE, rev_reg_def_id ) - rev_list = RevocationStatusList.load(rev_info["rev_list"]) - except AnoncredsError as err: - raise AnonCredsRevocationError( - "Error loading revocation registry definition" - ) from err - if rev_reg_index > rev_reg_def.max_cred_num: - raise AnonCredsRevocationRegistryFullError( - "Revocation registry is full" + if not rev_list: + raise AnonCredsRevocationError("Revocation registry not found") + if not rev_reg_def: + raise AnonCredsRevocationError( + "Revocation registry definition not found" + ) + if not rev_key: + raise AnonCredsRevocationError( + "Revocation registry definition private data not found" + ) + rev_info = rev_list.value_json + rev_info_tags = rev_list.tags + rev_reg_index = rev_info["next_index"] + try: + rev_reg_def = RevocationRegistryDefinition.load( + rev_reg_def.raw_value + ) + rev_list = RevocationStatusList.load(rev_info["rev_list"]) + except AnoncredsError as err: + raise AnonCredsRevocationError( + "Error loading revocation registry definition" + ) from err + if rev_reg_index > rev_reg_def.max_cred_num: + raise AnonCredsRevocationRegistryFullError( + "Revocation registry is full" + ) + rev_info["next_index"] = rev_reg_index + 1 + await txn.handle.replace( + CATEGORY_REV_LIST, + rev_reg_def_id, + value_json=rev_info, + tags=rev_info_tags, ) - rev_info["next_index"] = rev_reg_index + 1 - await txn.handle.replace( - CATEGORY_REV_LIST, - rev_reg_def_id, - value_json=rev_info, - tags=rev_info_tags, - ) - await txn.commit() + await txn.commit() except AskarError as err: raise AnonCredsRevocationError( "Error updating revocation registry index" @@ -1023,6 +1067,97 @@ async def _create_credential( return credential.to_json(), credential_revocation_id + async def _get_and_reserve_revocation_index( + self, cred_def_id: str + ) -> Tuple[bool, Optional[str], Optional[str], Optional[int]]: + """Atomically find active registry and reserve an index. + + Returns: + (success, rev_reg_def_id, tails_file_path, rev_reg_index) + """ + async with self.profile.transaction() as txn: + lock_key = f"lock_issue_{cred_def_id}" + try: + # Try to insert lock record if not exists + lock_entry = await txn.handle.fetch( + CATEGORY_REV_REG_DEF_LOCK, lock_key, for_update=True + ) + if not lock_entry: + await txn.handle.insert( + CATEGORY_REV_REG_DEF_LOCK, lock_key, value_json={"locked": True} + ) + lock_entry = await txn.handle.fetch( + CATEGORY_REV_REG_DEF_LOCK, lock_key, for_update=True + ) + except AskarError as err: + # A concurrent transaction may have inserted the lock record first; + # that's expected and we just need to wait for its row lock below. + # Any other Askar error is a real failure and must not be swallowed, + # since silently continuing here would mean proceeding without a lock. + if err.code != AskarErrorCode.DUPLICATE: + raise AnonCredsRevocationError( + "Error acquiring issuance lock" + ) from err + lock_entry = await txn.handle.fetch( + CATEGORY_REV_REG_DEF_LOCK, lock_key, for_update=True + ) + + if not lock_entry: + raise AnonCredsRevocationError("Error acquiring issuance lock") + + rev_reg_defs = await txn.handle.fetch_all( + CATEGORY_REV_REG_DEF, + { + "cred_def_id": cred_def_id, + "active": json.dumps(True), + }, + limit=1, + ) + + if not rev_reg_defs: + raise AnonCredsRevocationError("No active registry") + + entry = rev_reg_defs[0] + rev_reg_def_id = entry.name + + try: + rev_reg_def = RevRegDef.deserialize(entry.value_json) + anoncreds_rev_reg_def = RevocationRegistryDefinition.load(entry.raw_value) + except AnoncredsError as err: + raise AnonCredsRevocationError("Error loading revocation registry definition") from err + + if entry.tags.get("state") != RevRegDefState.STATE_FINISHED: + LOGGER.info("Active registry %s is not yet FINISHED, waiting...", rev_reg_def_id) + return False, None, None, None + + # Reserve index + rev_list_entry = await txn.handle.fetch(CATEGORY_REV_LIST, rev_reg_def_id) + if not rev_list_entry: + LOGGER.info("Revocation registry list not found for %s yet, waiting for event listener to create it...", rev_reg_def_id) + return False, None, None, None + + rev_info = rev_list_entry.value_json + rev_info_tags = rev_list_entry.tags + rev_reg_index = rev_info["next_index"] + + if rev_reg_index >= anoncreds_rev_reg_def.max_cred_num: + # It is genuinely full. Return the ID so create_credential triggers rotation. + return False, rev_reg_def_id, None, None + + # Increment and save + rev_info["next_index"] = rev_reg_index + 1 + await txn.handle.replace( + CATEGORY_REV_LIST, + rev_reg_def_id, + value_json=rev_info, + tags=rev_info_tags, + ) + + await txn.commit() + + tails_file_path = self.get_local_tails_path(rev_reg_def) + return True, rev_reg_def_id, tails_file_path, rev_reg_index + async def create_credential( self, credential_offer: dict, @@ -1060,23 +1195,25 @@ async def create_credential( ) await asyncio.sleep(2) - rev_reg_def_result = None if revocable: - rev_reg_def_result = await self.get_or_create_active_registry( - cred_def_id - ) - if ( - rev_reg_def_result.revocation_registry_definition_state.state - != STATE_FINISHED - ): + success, rev_reg_def_id, tails_file_path, rev_reg_index = await self._get_and_reserve_revocation_index(cred_def_id) + if not success: + # Registry is full or not finished + if rev_reg_def_id: + LOGGER.info("Registry %s is full or not ready, calling handle_full_registry", rev_reg_def_id) + try: + await self.handle_full_registry(rev_reg_def_id) + except AnonCredsRevocationError as rot_err: + LOGGER.warning( + "Failed to rotate full registry %s: %s", + rev_reg_def_id, + rot_err, + ) continue - rev_reg_def_id = rev_reg_def_result.rev_reg_def_id - tails_file_path = self.get_local_tails_path( - rev_reg_def_result.rev_reg_def - ) else: rev_reg_def_id = None tails_file_path = None + rev_reg_index = None try: cred_json, cred_rev_id = await self._create_credential( @@ -1087,21 +1224,12 @@ async def create_credential( credential_values, rev_reg_def_id, tails_file_path, + rev_reg_index, ) except AnonCredsRevocationRegistryFullError: - # unlucky, another instance filled the registry first + # Fallback just in case (should not happen with preallocation) continue - # cred rev id is zero based - # max cred num is one based - # however, if we wait until max cred num is reached, we are too late. - if rev_reg_def_result: - if ( - rev_reg_def_result.rev_reg_def.value.max_cred_num - <= int(cred_rev_id) + 1 - ): - await self.handle_full_registry(rev_reg_def_id) - return cred_json, cred_rev_id, rev_reg_def_id raise AnonCredsRevocationError( diff --git a/aries_cloudagent/anoncreds/tests/test_revocation.py b/aries_cloudagent/anoncreds/tests/test_revocation.py index 26a75d57c2..de9c6ac911 100644 --- a/aries_cloudagent/anoncreds/tests/test_revocation.py +++ b/aries_cloudagent/anoncreds/tests/test_revocation.py @@ -881,37 +881,68 @@ async def test_upload_tails_file(self): await self.revocation.upload_tails_file(rev_reg_def) @mock.patch.object(InMemoryProfileSession, "handle") - @mock.patch.object( - test_module.AnonCredsRevocation, "set_active_registry", return_value=None - ) + @mock.patch.object( test_module.AnonCredsRevocation, "create_and_register_revocation_registry_definition", return_value="backup", ) - async def test_handle_full_registry( - self, mock_create_and_register, mock_set_active_registry, mock_handle - ): - mock_handle.fetch = mock.CoroutineMock(return_value=MockRevRegDefEntry()) - mock_handle.fetch_all = mock.CoroutineMock( - return_value=[ - MockRevRegDefEntry(), - MockRevRegDefEntry(), - ] - ) - mock_handle.replace = mock.CoroutineMock(return_value=None) + async def test_handle_full_registry(self, mock_create_and_register, mock_handle): + def active_entry(state=RevRegDefState.STATE_ACTION): + return MockEntry( + name="active-rev-reg-def-id", + tags={"state": state, "active": json.dumps(True)}, + value_json={ + "credDefId": "test-cred-def-id", + "issuerId": "test-issuer-id", + "revocDefType": "CL_ACCUM", + "value": {"maxCredNum": 100}, + }, + ) + + def backup_entry(): + return MockEntry( + name="backup-rev-reg-def-id", + tags={ + "state": RevRegDefState.STATE_FINISHED, + "active": json.dumps(False), + }, + value_json={}, + ) + # active registry not found: nothing to rotate, no exception + mock_handle.fetch = mock.CoroutineMock(return_value=None) await self.revocation.handle_full_registry("test-rev-reg-def-id") - assert mock_create_and_register.called - assert mock_set_active_registry.called - assert mock_handle.fetch.call_count == 2 - assert mock_handle.fetch_all.called - assert mock_handle.replace.called - # no backup registry available + # active registry already marked full by another instance: no-op + mock_handle.fetch = mock.CoroutineMock( + return_value=active_entry(state=RevRegDefState.STATE_FULL) + ) + mock_handle.fetch_all = mock.CoroutineMock() + await self.revocation.handle_full_registry("test-rev-reg-def-id") + assert not mock_handle.fetch_all.called + + # no backup registry available: raises and rolls back, nothing scheduled + mock_handle.fetch = mock.CoroutineMock(return_value=active_entry()) mock_handle.fetch_all = mock.CoroutineMock(return_value=[]) with self.assertRaises(test_module.AnonCredsRevocationError): await self.revocation.handle_full_registry("test-rev-reg-def-id") + assert not self.revocation._background_tasks + + # happy path: swap active <-> backup atomically, then schedule + # background creation of the next backup registry + mock_handle.fetch = mock.CoroutineMock(return_value=active_entry()) + mock_handle.fetch_all = mock.CoroutineMock(return_value=[backup_entry()]) + mock_handle.replace = mock.CoroutineMock(return_value=None) + + await self.revocation.handle_full_registry("test-rev-reg-def-id") + + assert mock_handle.replace.call_count == 2 + assert self.revocation._background_tasks + for task in list(self.revocation._background_tasks): + await task + assert mock_create_and_register.called + assert not self.revocation._background_tasks @mock.patch.object(InMemoryProfileSession, "handle") async def test_decommission_registry(self, mock_handle): @@ -1146,6 +1177,110 @@ async def call_test_func(): with self.assertRaises(test_module.AnonCredsRevocationError): await call_test_func() + @mock.patch.object(InMemoryProfileSession, "handle") + @mock.patch.object( + RevocationRegistryDefinition, "load", return_value=rev_reg_def.value + ) + async def test_get_and_reserve_revocation_index( + self, mock_load_rev_reg_def, mock_handle + ): + lock_row = MockEntry( + name="lock_issue_test-cred-def-id", value_json={"locked": True} + ) + + def active_reg_entry(state=RevRegDefState.STATE_FINISHED): + return MockEntry( + name="test-rev-reg-def-id", + tags={"state": state, "active": json.dumps(True)}, + value_json=rev_reg_def.to_json(), + ) + + # no active registry -> hard failure, nothing to retry towards + mock_handle.fetch = mock.CoroutineMock(return_value=lock_row) + mock_handle.fetch_all = mock.CoroutineMock(return_value=[]) + with self.assertRaises(test_module.AnonCredsRevocationError): + await self.revocation._get_and_reserve_revocation_index( + "test-cred-def-id" + ) + + # active registry not yet FINISHED -> soft failure (caller retries) + mock_handle.fetch = mock.CoroutineMock(return_value=lock_row) + mock_handle.fetch_all = mock.CoroutineMock( + return_value=[active_reg_entry(state=RevRegDefState.STATE_ACTION)] + ) + result = await self.revocation._get_and_reserve_revocation_index( + "test-cred-def-id" + ) + assert result == (False, None, None, None) + + # revocation list not created yet -> soft failure (caller retries) + mock_handle.fetch = mock.CoroutineMock(side_effect=[lock_row, None]) + mock_handle.fetch_all = mock.CoroutineMock( + return_value=[active_reg_entry()] + ) + result = await self.revocation._get_and_reserve_revocation_index( + "test-cred-def-id" + ) + assert result == (False, None, None, None) + + # registry genuinely full -> id is returned so caller can rotate it + mock_handle.fetch = mock.CoroutineMock( + side_effect=[lock_row, MockEntry(value_json={"next_index": 100})] + ) + mock_handle.fetch_all = mock.CoroutineMock( + return_value=[active_reg_entry()] + ) + result = await self.revocation._get_and_reserve_revocation_index( + "test-cred-def-id" + ) + assert result == (False, "test-rev-reg-def-id", None, None) + + # index reserved successfully + mock_handle.fetch = mock.CoroutineMock( + side_effect=[lock_row, MockEntry(value_json={"next_index": 5})] + ) + mock_handle.fetch_all = mock.CoroutineMock( + return_value=[active_reg_entry()] + ) + mock_handle.replace = mock.CoroutineMock(return_value=None) + success, rev_reg_def_id, tails_file_path, rev_reg_index = ( + await self.revocation._get_and_reserve_revocation_index( + "test-cred-def-id" + ) + ) + assert success is True + assert rev_reg_def_id == "test-rev-reg-def-id" + assert rev_reg_index == 5 + assert tails_file_path is not None + assert mock_handle.replace.called + + # lock insert races with another instance (duplicate key): recovers + # by re-fetching the row instead of failing the whole operation + mock_handle.fetch = mock.CoroutineMock( + side_effect=[None, lock_row, MockEntry(value_json={"next_index": 5})] + ) + mock_handle.insert = mock.CoroutineMock( + side_effect=AskarError(code=AskarErrorCode.DUPLICATE, message="dup") + ) + mock_handle.fetch_all = mock.CoroutineMock( + return_value=[active_reg_entry()] + ) + mock_handle.replace = mock.CoroutineMock(return_value=None) + success, *_ = await self.revocation._get_and_reserve_revocation_index( + "test-cred-def-id" + ) + assert success is True + + # a genuine Askar failure acquiring the lock must not be swallowed + mock_handle.fetch = mock.CoroutineMock(return_value=None) + mock_handle.insert = mock.CoroutineMock( + side_effect=AskarError(code=AskarErrorCode.UNEXPECTED, message="boom") + ) + with self.assertRaises(test_module.AnonCredsRevocationError): + await self.revocation._get_and_reserve_revocation_index( + "test-cred-def-id" + ) + @mock.patch.object( AnonCredsIssuer, "cred_def_supports_revocation", return_value=True ) @@ -1167,17 +1302,8 @@ async def test_create_credential(self, mock_supports_revocation): ) ) ) - self.revocation.get_or_create_active_registry = mock.CoroutineMock( - return_value=RevRegDefResult( - job_id="test-job-id", - revocation_registry_definition_state=RevRegDefState( - state=RevRegDefState.STATE_FINISHED, - revocation_registry_definition_id="active-reg-reg", - revocation_registry_definition=rev_reg_def, - ), - registration_metadata={}, - revocation_registry_definition_metadata={}, - ) + self.revocation._get_and_reserve_revocation_index = mock.CoroutineMock( + return_value=(True, "active-reg-reg", "tails-file-path", 98) ) # Test private funtion seperately - very large @@ -1196,9 +1322,61 @@ async def test_create_credential(self, mock_supports_revocation): credential_values={}, ) - assert isinstance(result, tuple) + assert result == ({"cred": "cred"}, 98, "active-reg-reg") assert mock_supports_revocation.call_count == 1 + @mock.patch.object(test_module.asyncio, "sleep", return_value=None) + @mock.patch.object( + AnonCredsIssuer, "cred_def_supports_revocation", return_value=True + ) + async def test_create_credential_rotates_full_registry_then_succeeds( + self, mock_supports_revocation, mock_sleep + ): + self.profile.inject = mock.Mock( + return_value=mock.MagicMock( + get_schema=mock.CoroutineMock( + return_value=GetSchemaResult( + schema_id="CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3", + schema=AnonCredsSchema( + issuer_id="CsQY9MGeD3CQP4EyuVFo5m", + name="MYCO Biomarker:0.0.3", + version="1.0", + attr_names=["attr1", "attr2"], + ), + schema_metadata={}, + resolution_metadata={}, + ) + ) + ) + ) + # First attempt finds the registry full and triggers a rotation. + # Second attempt reserves an index in the newly-activated backup. + self.revocation._get_and_reserve_revocation_index = mock.CoroutineMock( + side_effect=[ + (False, "full-reg-reg", None, None), + (True, "active-reg-reg", "tails-file-path", 0), + ] + ) + self.revocation.handle_full_registry = mock.CoroutineMock(return_value=None) + self.revocation._create_credential = mock.CoroutineMock( + return_value=({"cred": "cred"}, 0) + ) + + result = await self.revocation.create_credential( + credential_offer={ + "schema_id": "CsQY9MGeD3CQP4EyuVFo5m:2:MYCO Biomarker:0.0.3", + "cred_def_id": "CsQY9MGeD3CQP4EyuVFo5m:3:CL:14951:MYCO_Biomarker", + "key_correctness_proof": {}, + "nonce": "nonce", + }, + credential_request={}, + credential_values={}, + ) + + assert result == ({"cred": "cred"}, 0, "active-reg-reg") + self.revocation.handle_full_registry.assert_called_once_with("full-reg-reg") + assert self.revocation._get_and_reserve_revocation_index.call_count == 2 + @mock.patch.object(InMemoryProfileSession, "handle") @mock.patch.object(RevList, "to_native") @mock.patch.object(RevList, "from_native", return_value=None) diff --git a/pyproject.toml b/pyproject.toml index 58133cff2d..8a5340a9e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,7 +127,6 @@ testpaths = "aries_cloudagent" addopts = """ --quiet --junitxml=./test-reports/junit.xml --cov-config .coveragerc --cov=aries_cloudagent --cov-report term --cov-report xml - --ruff """ markers = [ "anoncreds: Tests specifically relating to AnonCreds support",