diff --git a/aries_cloudagent/core/conductor.py b/aries_cloudagent/core/conductor.py index 6e8bc6bc06..ebd9b52a62 100644 --- a/aries_cloudagent/core/conductor.py +++ b/aries_cloudagent/core/conductor.py @@ -300,7 +300,8 @@ async def outbound_message_router( if self.inbound_transport_manager.return_to_session(outbound): return - await self.queue_outbound(context, outbound, inbound) + if not outbound.to_session_only: + await self.queue_outbound(context, outbound, inbound) def handle_not_returned(self, context: InjectionContext, outbound: OutboundMessage): """Handle a message that failed delivery via an inbound session.""" diff --git a/aries_cloudagent/ledger/indy.py b/aries_cloudagent/ledger/indy.py index d2ea91aedc..7f9e148e69 100644 --- a/aries_cloudagent/ledger/indy.py +++ b/aries_cloudagent/ledger/indy.py @@ -3,7 +3,6 @@ import asyncio import json import logging -import re import tempfile from hashlib import sha256 @@ -486,6 +485,22 @@ async def send_credential_definition(self, schema_id: str, tag: str = None): """ + async def cred_def_in_wallet(wallet_handle, cred_def_id) -> bool: + """Create an offer to check whether cred def id is in wallet.""" + try: + await indy.anoncreds.issuer_create_credential_offer( + wallet_handle, + cred_def_id + ) + return True + except IndyError as error: + if error.error_code not in ( + ErrorCode.CommonInvalidStructure, ErrorCode.WalletItemNotFound, + ): + raise IndyErrorHandler.wrap_error(error) from error + # recognized error signifies no such cred def in wallet: pass + return False + public_info = await self.wallet.get_public_did() if not public_info: raise BadLedgerRequestError( @@ -493,88 +508,87 @@ async def send_credential_definition(self, schema_id: str, tag: str = None): ) schema = await self.get_schema(schema_id) - credential_definition_json = None - - # TODO: add support for tag, sig type, and config - try: - ( - credential_definition_id, - credential_definition_json, - ) = await indy.anoncreds.issuer_create_and_store_credential_def( - self.wallet.handle, - public_info.did, - json.dumps(schema), - tag or "default", - "CL", - json.dumps({"support_revocation": False}), + if not schema: + raise LedgerError(f"Ledger {self.pool_name} has no schema {schema_id}") + + # check if cred def is on ledger already + for test_tag in [tag, ] if tag else ["tag", "default"]: + credential_definition_id = ( + f"{public_info.did}:3:CL:{str(schema['seqNo'])}:{test_tag}" + ) + ledger_cred_def = await self.fetch_credential_definition( + credential_definition_id ) - # If the cred def already exists in the wallet, we need some way of obtaining - # that cred def id (from schema id passed) since we can now assume we can use - # it in future operations. - except IndyError as error: - if error.error_code == ErrorCode.AnoncredsCredDefAlreadyExistsError: - try: - credential_definition_id = re.search( - r"\w*:3:CL:(([1-9][0-9]*)|(.{21,22}:2:.+:[0-9.]+)):\w*", - error.message, - ).group(0) - # The regex search failed so let the error bubble up - except AttributeError: + if ledger_cred_def: + self.logger.warning( + "Credential definition %s already exists on ledger %s", + credential_definition_id, + self.pool_name, + ) + if not await cred_def_in_wallet( + self.wallet.handle, credential_definition_id + ): raise LedgerError( - "Previous credential definition exists, but ID could " - "not be extracted" + f"Credential definition {credential_definition_id} is on " + f"ledger {self.pool_name} but not in wallet {self.wallet.name}" ) - else: - raise IndyErrorHandler.wrap_error(error) from error - - # check if the cred def already exists on the ledger - created_cred_def = json.loads( - credential_definition_json - ) if credential_definition_json else None - exist_def = await self.fetch_credential_definition(credential_definition_id) - - if created_cred_def: - if exist_def: - if exist_def["value"] != created_cred_def["value"]: - self.logger.warning( - "Ledger definition of cred def %s will be replaced", - credential_definition_id, - ) - exist_def = None - else: - if not exist_def: + break + else: # no such cred def on ledger + if await cred_def_in_wallet(self.wallet.handle, credential_definition_id): raise LedgerError( - f"Wallet {self.wallet.name} " - f"does not pertain to ledger on pool {self.pool_name}" + f"Credential definition {credential_definition_id} is in wallet " + f"{self.wallet.name} but not on ledger {self.pool_name}" ) - if not exist_def: - with IndyErrorHandler("Exception when building cred def request"): - request_json = await indy.ledger.build_cred_def_request( - public_info.did, credential_definition_json + # Cred def is neither on ledger nor in wallet: create and send it + try: + ( + credential_definition_id, + credential_definition_json, + ) = await indy.anoncreds.issuer_create_and_store_credential_def( + self.wallet.handle, + public_info.did, + json.dumps(schema), + tag or "default", + "CL", + json.dumps({"support_revocation": False}), ) - await self._submit(request_json, True, public_did=public_info.did) - else: - self.logger.warning( - "Ledger definition of cred def %s already exists", - credential_definition_id, - ) + except IndyError as error: + raise IndyErrorHandler.wrap_error(error) from error + else: # created cred def in wallet OK + wallet_cred_def = json.loads(credential_definition_json) + with IndyErrorHandler("Exception when building cred def request"): + request_json = await indy.ledger.build_cred_def_request( + public_info.did, credential_definition_json + ) + await self._submit(request_json, True, public_did=public_info.did) + ledger_cred_def = await self.fetch_credential_definition( + credential_definition_id + ) + assert wallet_cred_def["value"] == ledger_cred_def["value"] - schema_id_parts = schema_id.split(":") - cred_def_tags = { - "schema_id": schema_id, - "schema_issuer_did": schema_id_parts[0], - "schema_name": schema_id_parts[-2], - "schema_version": schema_id_parts[-1], - "issuer_did": public_info.did, - "cred_def_id": credential_definition_id, - "epoch": str(int(time())), - } - record = StorageRecord( - CRED_DEF_SENT_RECORD_TYPE, credential_definition_id, cred_def_tags, - ) + # Add non-secrets records if not yet present storage = self.get_indy_storage() - await storage.add_record(record) + found = await storage.search_records( + type_filter=CRED_DEF_SENT_RECORD_TYPE, + tag_query={"cred_def_id": credential_definition_id} + ).fetch_all() + + if not found: + schema_id_parts = schema_id.split(":") + cred_def_tags = { + "schema_id": schema_id, + "schema_issuer_did": schema_id_parts[0], + "schema_name": schema_id_parts[-2], + "schema_version": schema_id_parts[-1], + "issuer_did": public_info.did, + "cred_def_id": credential_definition_id, + "epoch": str(int(time())), + } + record = StorageRecord( + CRED_DEF_SENT_RECORD_TYPE, credential_definition_id, cred_def_tags, + ) + await storage.add_record(record) return credential_definition_id diff --git a/aries_cloudagent/ledger/tests/test_indy.py b/aries_cloudagent/ledger/tests/test_indy.py index cb2011afe6..8387cbec57 100644 --- a/aries_cloudagent/ledger/tests/test_indy.py +++ b/aries_cloudagent/ledger/tests/test_indy.py @@ -133,7 +133,6 @@ async def test_aenter_aexit( mock_close_pool.assert_called_once() assert ledger.pool_handle == None - ''' insufficient for now: needs to cover nested context management with keepalive @async_mock.patch("indy.pool.set_protocol_version") @async_mock.patch("indy.pool.open_pool_ledger") @async_mock.patch("indy.pool.close_pool_ledger") @@ -151,16 +150,15 @@ async def test_aenter_aexit_nested_keepalive( mock_close_pool.assert_not_called() assert led0.pool_handle == mock_open_ledger.return_value - async with ledger as led1: - assert ledger.ref_count == 2 + async with ledger as led1: + assert ledger.ref_count == 1 mock_close_pool.assert_not_called() # it's a future assert ledger.pool_handle - await asyncio.sleep(1) + await asyncio.sleep(1.01) mock_close_pool.assert_called_once() assert ledger.pool_handle == None - ''' @async_mock.patch("indy.pool.set_protocol_version") @async_mock.patch("indy.pool.create_pool_ledger_config") @@ -815,14 +813,18 @@ async def test_get_schema_by_wrong_seq_no( "aries_cloudagent.ledger.indy.IndyLedger.fetch_credential_definition" ) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit") + @async_mock.patch("aries_cloudagent.storage.indy.IndyStorage.search_records") @async_mock.patch("aries_cloudagent.storage.indy.IndyStorage.add_record") @async_mock.patch("indy.anoncreds.issuer_create_and_store_credential_def") + @async_mock.patch("indy.anoncreds.issuer_create_credential_offer") @async_mock.patch("indy.ledger.build_cred_def_request") async def test_send_credential_definition( self, mock_build_cred_def, + mock_create_offer, mock_create_store_cred_def, mock_add_record, + mock_search_records, mock_submit, mock_fetch_cred_def, mock_close, @@ -832,36 +834,43 @@ async def test_send_credential_definition( mock_wallet = async_mock.MagicMock() mock_wallet.WALLET_TYPE = "indy" - mock_get_schema.return_value = "{}" + mock_search_records.return_value.fetch_all = async_mock.CoroutineMock( + return_value=[] + ) + + mock_create_offer.side_effect = IndyError( + error_code=ErrorCode.CommonInvalidStructure + ) + mock_get_schema.return_value = {'seqNo': 999} cred_def_id = f"{self.test_did}:3:CL:999:default" - cred_def_json = json.dumps( - { - "ver": "1.0", - "id": cred_def_id, - "schemaId": "999", - "type": "CL", - "tag": "default", - "value": { - "primary": { - "n": "...", - "s": "...", - "r": "...", - "revocation": None - } - } + cred_def_value = { + "primary": { + "n": "...", + "s": "...", + "r": "...", + "revocation": None } - ) + } + cred_def = { + "ver": "1.0", + "id": cred_def_id, + "schemaId": "999", + "type": "CL", + "tag": "default", + "value": cred_def_value + } + cred_def_json = json.dumps(cred_def) + mock_create_store_cred_def.return_value = (cred_def_id, cred_def_json) - mock_fetch_cred_def.return_value = None + mock_fetch_cred_def.side_effect = [None, cred_def] ledger = IndyLedger("name", mock_wallet) schema_id = "schema_issuer_did:name:1.0" - tag = "tag" + tag = "default" async with ledger: - mock_wallet.get_public_did = async_mock.CoroutineMock() mock_wallet.get_public_did.return_value = None @@ -869,6 +878,11 @@ async def test_send_credential_definition( await ledger.send_credential_definition(schema_id, tag) mock_wallet.get_public_did = async_mock.CoroutineMock() + mock_wallet.get_public_did.return_value = DIDInfo( + self.test_did, + self.test_verkey, + None + ) mock_did = mock_wallet.get_public_did.return_value result_id = await ledger.send_credential_definition(schema_id, tag) @@ -879,6 +893,31 @@ async def test_send_credential_definition( mock_build_cred_def.assert_called_once_with(mock_did.did, cred_def_json) + @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") + @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") + @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close") + async def test_send_credential_definition_no_such_schema( + self, + mock_close, + mock_open, + mock_get_schema, + ): + mock_wallet = async_mock.MagicMock() + mock_wallet.WALLET_TYPE = "indy" + + mock_get_schema.return_value = {} + + ledger = IndyLedger("name", mock_wallet) + + schema_id = "schema_issuer_did:name:1.0" + tag = "default" + + async with ledger: + mock_wallet.get_public_did = async_mock.CoroutineMock() + + with self.assertRaises(LedgerError): + await ledger.send_credential_definition(schema_id, tag) + @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close") @@ -886,14 +925,18 @@ async def test_send_credential_definition( "aries_cloudagent.ledger.indy.IndyLedger.fetch_credential_definition" ) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit") + @async_mock.patch("aries_cloudagent.storage.indy.IndyStorage.search_records") @async_mock.patch("aries_cloudagent.storage.indy.IndyStorage.add_record") @async_mock.patch("indy.anoncreds.issuer_create_and_store_credential_def") + @async_mock.patch("indy.anoncreds.issuer_create_credential_offer") @async_mock.patch("indy.ledger.build_cred_def_request") - async def test_send_credential_definition_replace_on_ledger( + async def test_send_credential_definition_offer_exception( self, mock_build_cred_def, + mock_create_offer, mock_create_store_cred_def, mock_add_record, + mock_search_records, mock_submit, mock_fetch_cred_def, mock_close, @@ -903,54 +946,26 @@ async def test_send_credential_definition_replace_on_ledger( mock_wallet = async_mock.MagicMock() mock_wallet.WALLET_TYPE = "indy" - mock_get_schema.return_value = "{}" - cred_def_id = f"{self.test_did}:3:CL:999:default" - cred_def_json = json.dumps( - { - "ver": "1.0", - "id": cred_def_id, - "schemaId": "999", - "type": "CL", - "tag": "default", - "value": { - "primary": { - "n": "...", - "s": "...", - "r": "...", - "revocation": None - } - } - } + mock_search_records.return_value.fetch_all = async_mock.CoroutineMock( + return_value=[] ) - mock_create_store_cred_def.return_value = (cred_def_id, cred_def_json) - mock_fetch_cred_def.return_value = { - "value": { - "primary": { - "n": "... distinct value ...", - "s": "... distinct value ...", - "r": "... distinct value ...", - "revocation": None - } - } - } + mock_create_offer.side_effect = IndyError( + error_code=ErrorCode.CommonIOError, + error_details={"message": "cover indy error message wrapping"} + ) + mock_get_schema.return_value = {'seqNo': 999} ledger = IndyLedger("name", mock_wallet) schema_id = "schema_issuer_did:name:1.0" - tag = "tag" + tag = "default" async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock() - mock_did = mock_wallet.get_public_did.return_value - result_id = await ledger.send_credential_definition(schema_id, tag) - assert result_id == cred_def_id - - mock_wallet.get_public_did.assert_called_once_with() - mock_get_schema.assert_called_once_with(schema_id) - - mock_build_cred_def.assert_called_once_with(mock_did.did, cred_def_json) + with self.assertRaises(LedgerError): + await ledger.send_credential_definition(schema_id, tag) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @@ -958,16 +973,10 @@ async def test_send_credential_definition_replace_on_ledger( @async_mock.patch( "aries_cloudagent.ledger.indy.IndyLedger.fetch_credential_definition" ) - @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit") - @async_mock.patch("aries_cloudagent.storage.indy.IndyStorage.add_record") - @async_mock.patch("indy.anoncreds.issuer_create_and_store_credential_def") - @async_mock.patch("indy.ledger.build_cred_def_request") - async def test_send_credential_definition_exists_on_ledger( + @async_mock.patch("indy.anoncreds.issuer_create_credential_offer") + async def test_send_credential_definition_cred_def_in_wallet_not_ledger( self, - mock_build_cred_def, - mock_create_store_cred_def, - mock_add_record, - mock_submit, + mock_create_offer, mock_fetch_cred_def, mock_close, mock_open, @@ -976,55 +985,50 @@ async def test_send_credential_definition_exists_on_ledger( mock_wallet = async_mock.MagicMock() mock_wallet.WALLET_TYPE = "indy" - mock_get_schema.return_value = "{}" + mock_get_schema.return_value = {'seqNo': 999} cred_def_id = f"{self.test_did}:3:CL:999:default" - cred_def_json = json.dumps( - { - "ver": "1.0", - "id": cred_def_id, - "schemaId": "999", - "type": "CL", - "tag": "default", - "value": { - "primary": { - "n": "...", - "s": "...", - "r": "...", - "revocation": None - } - } + cred_def_value = { + "primary": { + "n": "...", + "s": "...", + "r": "...", + "revocation": None } - ) - mock_create_store_cred_def.return_value = (cred_def_id, cred_def_json) + } + cred_def = { + "ver": "1.0", + "id": cred_def_id, + "schemaId": "999", + "type": "CL", + "tag": "default", + "value": cred_def_value + } + cred_def_json = json.dumps(cred_def) - mock_fetch_cred_def.return_value = json.loads(cred_def_json) + mock_fetch_cred_def.return_value = {} ledger = IndyLedger("name", mock_wallet) schema_id = "schema_issuer_did:name:1.0" - tag = "tag" + tag = "default" async with ledger: mock_wallet.get_public_did = async_mock.CoroutineMock() - mock_did = mock_wallet.get_public_did.return_value - result_id = await ledger.send_credential_definition(schema_id, tag) - assert result_id == cred_def_id - - mock_wallet.get_public_did.assert_called_once_with() - mock_get_schema.assert_called_once_with(schema_id) - - mock_build_cred_def.assert_not_called() + with self.assertRaises(LedgerError): + await ledger.send_credential_definition(schema_id, tag) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close") - @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit") - @async_mock.patch("indy.anoncreds.issuer_create_and_store_credential_def") - async def test_send_credential_definition_wallet_has_cred_def_bad_cred_def_id( + @async_mock.patch( + "aries_cloudagent.ledger.indy.IndyLedger.fetch_credential_definition" + ) + @async_mock.patch("indy.anoncreds.issuer_create_credential_offer") + async def test_send_credential_definition_cred_def_on_ledger_not_in_wallet( self, - mock_create_store_cred_def, - mock_submit, + mock_create_offer, + mock_fetch_cred_def, mock_close, mock_open, mock_get_schema, @@ -1032,23 +1036,39 @@ async def test_send_credential_definition_wallet_has_cred_def_bad_cred_def_id( mock_wallet = async_mock.MagicMock() mock_wallet.WALLET_TYPE = "indy" - mock_get_schema.return_value = "{}" - mock_create_store_cred_def.side_effect = IndyError( - error_code=ErrorCode.AnoncredsCredDefAlreadyExistsError, - error_details={'message': 'no cred def id in this message'} + mock_get_schema.return_value = {'seqNo': 999} + cred_def_id = f"{self.test_did}:3:CL:999:default" + cred_def_value = { + "primary": { + "n": "...", + "s": "...", + "r": "...", + "revocation": None + } + } + cred_def = { + "ver": "1.0", + "id": cred_def_id, + "schemaId": "999", + "type": "CL", + "tag": "default", + "value": cred_def_value + } + cred_def_json = json.dumps(cred_def) + + mock_fetch_cred_def.return_value = cred_def + + mock_create_offer.side_effect = IndyError( + error_code=ErrorCode.CommonInvalidStructure ) ledger = IndyLedger("name", mock_wallet) schema_id = "schema_issuer_did:name:1.0" - tag = "tag" + tag = "default" async with ledger: - mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) - ) + mock_wallet.get_public_did = async_mock.CoroutineMock() with self.assertRaises(LedgerError): await ledger.send_credential_definition(schema_id, tag) @@ -1056,12 +1076,24 @@ async def test_send_credential_definition_wallet_has_cred_def_bad_cred_def_id( @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close") + @async_mock.patch( + "aries_cloudagent.ledger.indy.IndyLedger.fetch_credential_definition" + ) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit") + @async_mock.patch("aries_cloudagent.storage.indy.IndyStorage.search_records") + @async_mock.patch("aries_cloudagent.storage.indy.IndyStorage.add_record") @async_mock.patch("indy.anoncreds.issuer_create_and_store_credential_def") - async def test_send_credential_definition_wallet_mystery_error_on_create_cred_def( + @async_mock.patch("indy.anoncreds.issuer_create_credential_offer") + @async_mock.patch("indy.ledger.build_cred_def_request") + async def test_send_credential_definition_on_ledger_in_wallet( self, + mock_build_cred_def, + mock_create_offer, mock_create_store_cred_def, + mock_add_record, + mock_search_records, mock_submit, + mock_fetch_cred_def, mock_close, mock_open, mock_get_schema, @@ -1069,40 +1101,83 @@ async def test_send_credential_definition_wallet_mystery_error_on_create_cred_de mock_wallet = async_mock.MagicMock() mock_wallet.WALLET_TYPE = "indy" - mock_get_schema.return_value = "{}" - mock_create_store_cred_def.side_effect = IndyError( - error_code=ErrorCode.CommonInvalidStructure, - error_details={'message': 'A suffusion of yellow'} + mock_search_records.return_value.fetch_all = async_mock.CoroutineMock( + return_value=[] ) + mock_get_schema.return_value = {'seqNo': 999} + cred_def_id = f"{self.test_did}:3:CL:999:default" + cred_def_value = { + "primary": { + "n": "...", + "s": "...", + "r": "...", + "revocation": None + } + } + cred_def = { + "ver": "1.0", + "id": cred_def_id, + "schemaId": "999", + "type": "CL", + "tag": "default", + "value": cred_def_value + } + cred_def_json = json.dumps(cred_def) + + mock_create_store_cred_def.return_value = (cred_def_id, cred_def_json) + + mock_fetch_cred_def.return_value = cred_def + ledger = IndyLedger("name", mock_wallet) schema_id = "schema_issuer_did:name:1.0" - tag = "tag" + tag = "default" async with ledger: - mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) - ) + mock_wallet.get_public_did = async_mock.CoroutineMock() + mock_wallet.get_public_did.return_value = None - with self.assertRaises(LedgerError): + with self.assertRaises(BadLedgerRequestError): await ledger.send_credential_definition(schema_id, tag) + mock_wallet.get_public_did = async_mock.CoroutineMock() + mock_wallet.get_public_did.return_value = DIDInfo( + self.test_did, + self.test_verkey, + None + ) + mock_did = mock_wallet.get_public_did.return_value + + result_id = await ledger.send_credential_definition(schema_id, tag) + assert result_id == cred_def_id + + mock_wallet.get_public_did.assert_called_once_with() + mock_get_schema.assert_called_once_with(schema_id) + + mock_build_cred_def.assert_not_called() + @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger.get_schema") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_open") @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._context_close") + @async_mock.patch( + "aries_cloudagent.ledger.indy.IndyLedger.fetch_credential_definition" + ) @async_mock.patch("aries_cloudagent.ledger.indy.IndyLedger._submit") + @async_mock.patch("aries_cloudagent.storage.indy.IndyStorage.search_records") + @async_mock.patch("aries_cloudagent.storage.indy.IndyStorage.add_record") @async_mock.patch("indy.anoncreds.issuer_create_and_store_credential_def") + @async_mock.patch("indy.anoncreds.issuer_create_credential_offer") @async_mock.patch("indy.ledger.build_cred_def_request") - @async_mock.patch("indy.ledger.parse_get_cred_def_response") - async def test_send_credential_definition_wallet_ledger_mismatch( + async def test_send_credential_definition_create_cred_def_exception( self, - mock_parse_get_cred_def_resp, mock_build_cred_def, + mock_create_offer, mock_create_store_cred_def, + mock_add_record, + mock_search_records, mock_submit, + mock_fetch_cred_def, mock_close, mock_open, mock_get_schema, @@ -1110,24 +1185,50 @@ async def test_send_credential_definition_wallet_ledger_mismatch( mock_wallet = async_mock.MagicMock() mock_wallet.WALLET_TYPE = "indy" - mock_get_schema.return_value = "{}" + mock_search_records.return_value.fetch_all = async_mock.CoroutineMock( + return_value=[] + ) + + mock_create_offer.side_effect = IndyError( + error_code=ErrorCode.CommonInvalidStructure + ) + mock_get_schema.return_value = {'seqNo': 999} + cred_def_id = f"{self.test_did}:3:CL:999:default" + cred_def_value = { + "primary": { + "n": "...", + "s": "...", + "r": "...", + "revocation": None + } + } + cred_def = { + "ver": "1.0", + "id": cred_def_id, + "schemaId": "999", + "type": "CL", + "tag": "default", + "value": cred_def_value + } + cred_def_json = json.dumps(cred_def) + mock_create_store_cred_def.side_effect = IndyError( - error_code=ErrorCode.AnoncredsCredDefAlreadyExistsError, - error_details={'message': f'{self.test_did}:3:CL:999:default'} + error_code=ErrorCode.CommonInvalidStructure ) - mock_parse_get_cred_def_resp.return_value = (None, "{}") + mock_fetch_cred_def.return_value = None ledger = IndyLedger("name", mock_wallet) schema_id = "schema_issuer_did:name:1.0" - tag = "tag" + tag = "default" async with ledger: - mock_wallet.get_public_did = async_mock.CoroutineMock( - return_value = DIDInfo( - self.test_did, self.test_verkey, None - ) + mock_wallet.get_public_did = async_mock.CoroutineMock() + mock_wallet.get_public_did.return_value = DIDInfo( + self.test_did, + self.test_verkey, + None ) with self.assertRaises(LedgerError): diff --git a/aries_cloudagent/messaging/responder.py b/aries_cloudagent/messaging/responder.py index f36ed1cb18..f4b3820a5b 100644 --- a/aries_cloudagent/messaging/responder.py +++ b/aries_cloudagent/messaging/responder.py @@ -44,6 +44,7 @@ async def create_outbound( reply_to_verkey: str = None, target: ConnectionTarget = None, target_list: Sequence[ConnectionTarget] = None, + to_session_only: bool = False ) -> OutboundMessage: """Create an OutboundMessage from a message payload.""" if isinstance(message, AgentMessage): @@ -63,6 +64,7 @@ async def create_outbound( reply_to_verkey=reply_to_verkey, target=target, target_list=target_list, + to_session_only=to_session_only ) async def send(self, message: Union[AgentMessage, str, bytes], **kwargs): diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/handlers/credential_request_handler.py b/aries_cloudagent/protocols/issue_credential/v1_0/handlers/credential_request_handler.py index 0341d05f9b..b98c8cf63d 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/handlers/credential_request_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/handlers/credential_request_handler.py @@ -40,15 +40,25 @@ async def handle(self, context: RequestContext, responder: BaseResponder): # If auto_issue is enabled, respond immediately if cred_exchange_rec.auto_issue: - ( - cred_exchange_rec, - credential_issue_message, - ) = await credential_manager.issue_credential( - credential_exchange_record=cred_exchange_rec, - comment=context.message.comment, - credential_values=CredentialProposal.deserialize( - cred_exchange_rec.credential_proposal_dict - ).credential_proposal.attr_dict(), - ) - - await responder.send_reply(credential_issue_message) + if ( + cred_exchange_rec.credential_proposal_dict and + "credential_proposal" in cred_exchange_rec.credential_proposal_dict + ): + ( + cred_exchange_rec, + credential_issue_message, + ) = await credential_manager.issue_credential( + credential_exchange_record=cred_exchange_rec, + comment=context.message.comment, + credential_values=CredentialProposal.deserialize( + cred_exchange_rec.credential_proposal_dict + ).credential_proposal.attr_dict(), + ) + + await responder.send_reply(credential_issue_message) + else: + self._logger.warning( + "Operation set for auto-issue but credential exchange record " + f"{cred_exchange_rec.credential_exchange_id} " + "has no attribute values" + ) diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/handlers/tests/test_credential_request_handler.py b/aries_cloudagent/protocols/issue_credential/v1_0/handlers/tests/test_credential_request_handler.py index 00370a9ecd..45fd6f27de 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/handlers/tests/test_credential_request_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/handlers/tests/test_credential_request_handler.py @@ -9,8 +9,12 @@ from ......transport.inbound.receipt import MessageReceipt from ...messages.credential_request import CredentialRequest +from ...messages.inner.credential_preview import CredAttrSpec, CredentialPreview +from ...models.credential_exchange import V10CredentialExchange + from .. import credential_request_handler as handler +CD_ID = "LjgpST2rjsoxYegQDRm7EL:3:CL:18:tag" class TestCredentialRequestHandler(AsyncTestCase): async def test_called(self): @@ -39,13 +43,23 @@ async def test_called_auto_issue(self): request_context.message_receipt = MessageReceipt() request_context.connection_record = async_mock.MagicMock() + ATTR_DICT = {"test": "123", "hello": "world"} + cred_ex_rec = V10CredentialExchange( + credential_proposal_dict={ + "credential_proposal": CredentialPreview( + attributes=(CredAttrSpec.list_plain(ATTR_DICT)) + ).serialize(), + "cred_def_id": CD_ID + } + ) + with async_mock.patch.object( handler, "CredentialManager", autospec=True ) as mock_cred_mgr, async_mock.patch.object( handler, "CredentialProposal", autospec=True ) as mock_cred_proposal: mock_cred_mgr.return_value.receive_request = async_mock.CoroutineMock( - return_value=async_mock.MagicMock() + return_value=cred_ex_rec ) mock_cred_mgr.return_value.receive_request.return_value.auto_issue = True mock_cred_mgr.return_value.issue_credential = async_mock.CoroutineMock( @@ -55,12 +69,19 @@ async def test_called_auto_issue(self): return_value=mock_cred_proposal ) mock_cred_proposal.credential_proposal = async_mock.MagicMock() - mock_cred_proposal.credential_proposal.attr_dict = async_mock.MagicMock() + mock_cred_proposal.credential_proposal.attr_dict = async_mock.MagicMock( + return_value=ATTR_DICT + ) request_context.message = CredentialRequest() request_context.connection_ready = True handler_inst = handler.CredentialRequestHandler() responder = MockResponder() await handler_inst.handle(request_context, responder) + mock_cred_mgr.return_value.issue_credential.assert_called_once_with( + credential_exchange_record=cred_ex_rec, + comment=None, + credential_values=ATTR_DICT + ) mock_cred_mgr.assert_called_once_with(request_context) mock_cred_mgr.return_value.receive_request.assert_called_once_with() @@ -70,6 +91,45 @@ async def test_called_auto_issue(self): assert result == "credential_issue_message" assert target == {} + async def test_called_auto_issue_no_preview(self): + request_context = RequestContext() + request_context.message_receipt = MessageReceipt() + request_context.connection_record = async_mock.MagicMock() + + cred_ex_rec = V10CredentialExchange( + credential_proposal_dict={ + "cred_def_id": CD_ID + } + ) + + with async_mock.patch.object( + handler, "CredentialManager", autospec=True + ) as mock_cred_mgr, async_mock.patch.object( + handler, "CredentialProposal", autospec=True + ) as mock_cred_proposal: + mock_cred_mgr.return_value.receive_request = async_mock.CoroutineMock( + return_value=cred_ex_rec + ) + mock_cred_mgr.return_value.receive_request.return_value.auto_issue = True + mock_cred_mgr.return_value.issue_credential = async_mock.CoroutineMock( + return_value=(None, "credential_issue_message") + ) + mock_cred_proposal.deserialize = async_mock.MagicMock( + return_value=mock_cred_proposal + ) + mock_cred_proposal.credential_proposal = async_mock.MagicMock() + + request_context.message = CredentialRequest() + request_context.connection_ready = True + handler_inst = handler.CredentialRequestHandler() + responder = MockResponder() + await handler_inst.handle(request_context, responder) + mock_cred_mgr.return_value.issue_credential.assert_not_called() + + mock_cred_mgr.assert_called_once_with(request_context) + mock_cred_mgr.return_value.receive_request.assert_called_once_with() + assert not responder.messages + async def test_called_not_ready(self): request_context = RequestContext() request_context.message_receipt = MessageReceipt() diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/manager.py b/aries_cloudagent/protocols/issue_credential/v1_0/manager.py index cba7bfac12..fe8b9ea425 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/manager.py @@ -68,7 +68,9 @@ async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: return max(found, key=lambda r: int(r.tags["epoch"])).tags["cred_def_id"] async def prepare_send( - self, connection_id: str, credential_proposal: CredentialProposal + self, + connection_id: str, + credential_proposal: CredentialProposal ) -> Tuple[V10CredentialExchange, CredentialOffer]: """ Set up a new credential exchange for an automated send. @@ -130,10 +132,6 @@ async def create_proposal( Resulting credential exchange record including credential proposal """ - # Credential preview must be present - if not credential_preview: - raise CredentialManagerError("credential_preview is not set") - credential_proposal_message = CredentialProposal( comment=comment, credential_proposal=credential_preview, @@ -539,15 +537,23 @@ async def store_credential( ) holder: BaseHolder = await self.context.inject(BaseHolder) + if ( + credential_exchange_record.credential_proposal_dict and + "credential_proposal" in credential_exchange_record.credential_proposal_dict + ): + mime_types = CredentialPreview.deserialize( + credential_exchange_record.credential_proposal_dict[ + "credential_proposal" + ] + ).mime_types() + else: + mime_types = None + credential_id = await holder.store_credential( credential_definition, raw_credential, credential_exchange_record.credential_request_metadata, - CredentialPreview.deserialize( - credential_exchange_record.credential_proposal_dict[ - "credential_proposal" - ] - ).mime_types(), + mime_types ) credential = await holder.get_credential(credential_id) diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_proposal.py b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_proposal.py index dea0ad3834..2c6502fbcb 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_proposal.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/messages/credential_proposal.py @@ -60,9 +60,7 @@ def __init__( """ super().__init__(_id, **kwargs) self.comment = comment - self.credential_proposal = ( - credential_proposal if credential_proposal else CredentialPreview() - ) + self.credential_proposal = credential_proposal self.schema_id = schema_id self.schema_issuer_did = schema_issuer_did self.schema_name = schema_name @@ -80,7 +78,11 @@ class Meta: model_class = CredentialProposal comment = fields.Str(required=False, allow_none=False) - credential_proposal = fields.Nested(CredentialPreviewSchema, required=True) + credential_proposal = fields.Nested( + CredentialPreviewSchema, + required=False, + allow_none=False, + ) schema_id = fields.Str( required=False, allow_none=False, diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/messages/tests/test_credential_proposal.py b/aries_cloudagent/protocols/issue_credential/v1_0/messages/tests/test_credential_proposal.py index dd6610222e..cec6b14fd6 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/messages/tests/test_credential_proposal.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/messages/tests/test_credential_proposal.py @@ -84,6 +84,26 @@ def test_serialize(self): "cred_def_id": "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag", } + def test_serialize_no_proposal(self): + """Test serialization.""" + + cred_proposal = CredentialProposal( + comment="Hello World", + credential_proposal=None, + schema_id="GMm4vMw8LLrLJjp81kRRLp:2:ahoy:1560364003.0", + cred_def_id="GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag", + ) + + cred_proposal_dict = cred_proposal.serialize() + cred_proposal_dict.pop("@id") + + assert cred_proposal_dict == { + "@type": CREDENTIAL_PROPOSAL, + "comment": "Hello World", + "schema_id": "GMm4vMw8LLrLJjp81kRRLp:2:ahoy:1560364003.0", + "cred_def_id": "GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag", + } + class TestCredentialProposalSchema(TestCase): """Test credential cred proposal schema.""" diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/routes.py b/aries_cloudagent/protocols/issue_credential/v1_0/routes.py index b401313adb..a5a56ed4c2 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/routes.py @@ -43,8 +43,8 @@ class V10CredentialExchangeListResultSchema(Schema): ) -class V10CredentialProposalRequestSchema(Schema): - """Request schema for sending credential proposal admin message.""" +class V10CredentialProposalRequestSchemaBase(Schema): + """Base class for request schema for sending credential proposal admin message.""" connection_id = fields.UUID( description="Connection identifier", @@ -82,6 +82,17 @@ class V10CredentialProposalRequestSchema(Schema): **INDY_DID, ) comment = fields.Str(description="Human-readable comment", required=False) + + +class V10CredentialProposalRequestOptSchema(V10CredentialProposalRequestSchemaBase): + """Request schema for sending credential proposal on optional proposal preview.""" + + credential_proposal = fields.Nested(CredentialPreviewSchema, required=False) + + +class V10CredentialProposalRequestMandSchema(V10CredentialProposalRequestSchemaBase): + """Request schema for sending credential proposal on mandatory proposal preview.""" + credential_proposal = fields.Nested(CredentialPreviewSchema, required=True) @@ -192,8 +203,11 @@ async def credential_exchange_retrieve(request: web.BaseRequest): return web.json_response(record.serialize()) -@docs(tags=["issue-credential"], summary="Send credential, automating entire flow") -@request_schema(V10CredentialProposalRequestSchema()) +@docs( + tags=["issue-credential"], + summary="Send holder a credential, automating entire flow" +) +@request_schema(V10CredentialProposalRequestMandSchema()) @response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_send(request: web.BaseRequest): """ @@ -217,9 +231,7 @@ async def credential_exchange_send(request: web.BaseRequest): comment = body.get("comment") connection_id = body.get("connection_id") - preview_spec = body.get("credential_proposal") - if not preview_spec: - raise web.HTTPBadRequest(reason="credential_proposal must be provided.") + preview = CredentialPreview.deserialize(body.get("credential_proposal")) try: connection_record = await ConnectionRecord.retrieve_by_id( @@ -233,7 +245,7 @@ async def credential_exchange_send(request: web.BaseRequest): credential_proposal = CredentialProposal( comment=comment, - credential_proposal=CredentialPreview.deserialize(preview_spec), + credential_proposal=preview, **{t: body.get(t) for t in CRED_DEF_TAGS if body.get(t)}, ) @@ -243,17 +255,19 @@ async def credential_exchange_send(request: web.BaseRequest): credential_exchange_record, credential_offer_message, ) = await credential_manager.prepare_send( - connection_id, credential_proposal=credential_proposal + connection_id, + credential_proposal=credential_proposal ) await outbound_handler( - credential_offer_message, connection_id=credential_exchange_record.connection_id + credential_offer_message, + connection_id=credential_exchange_record.connection_id ) return web.json_response(credential_exchange_record.serialize()) @docs(tags=["issue-credential"], summary="Send issuer a credential proposal") -@request_schema(V10CredentialProposalRequestSchema()) +@request_schema(V10CredentialProposalRequestOptSchema()) @response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_send_proposal(request: web.BaseRequest): """ @@ -274,8 +288,7 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): connection_id = body.get("connection_id") comment = body.get("comment") preview_spec = body.get("credential_proposal") - if not preview_spec: - raise web.HTTPBadRequest(reason="credential_proposal must be provided.") + preview = CredentialPreview.deserialize(preview_spec) if preview_spec else None try: connection_record = await ConnectionRecord.retrieve_by_id( @@ -287,14 +300,12 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): if not connection_record.is_ready: raise web.HTTPForbidden() - credential_preview = CredentialPreview.deserialize(preview_spec) - credential_manager = CredentialManager(context) credential_exchange_record = await credential_manager.create_proposal( connection_id, comment=comment, - credential_preview=credential_preview, + credential_preview=preview, **{t: body.get(t) for t in CRED_DEF_TAGS if body.get(t)}, ) @@ -310,7 +321,7 @@ async def credential_exchange_send_proposal(request: web.BaseRequest): @docs( tags=["issue-credential"], - summary="Send holder a credential offer, free from reference to any proposal", + summary="Send holder a credential offer, independent of any proposal with preview", ) @request_schema(V10CredentialOfferRequestSchema()) @response_schema(V10CredentialExchangeSchema(), 200) @@ -318,8 +329,8 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): """ Request handler for sending free credential offer. - An issuer initiates a such a credential offer, which is free any - holder-initiated corresponding proposal. + An issuer initiates a such a credential offer, free from any + holder-initiated corresponding credential proposal with preview. Args: request: aiohttp request object @@ -396,7 +407,7 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): @docs( tags=["issue-credential"], - summary="Send holder a credential offer in reference to a proposal", + summary="Send holder a credential offer in reference to a proposal with preview", ) @response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_send_bound_offer(request: web.BaseRequest): @@ -448,7 +459,7 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): return web.json_response(credential_exchange_record.serialize()) -@docs(tags=["issue-credential"], summary="Send a credential request") +@docs(tags=["issue-credential"], summary="Send issuer a credential request") @response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_send_request(request: web.BaseRequest): """ @@ -497,7 +508,7 @@ async def credential_exchange_send_request(request: web.BaseRequest): return web.json_response(credential_exchange_record.serialize()) -@docs(tags=["issue-credential"], summary="Send a credential") +@docs(tags=["issue-credential"], summary="Send holder a credential") @request_schema(V10CredentialIssueRequestSchema()) @response_schema(V10CredentialExchangeSchema(), 200) async def credential_exchange_issue(request: web.BaseRequest): diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_manager.py b/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_manager.py index 327101ad9f..1e62ab5568 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_manager.py @@ -72,15 +72,6 @@ async def test_create_proposal(self): return_value=schema_id ) - with self.assertRaises(CredentialManagerError): - await self.manager.create_proposal( - connection_id, - auto_offer=True, - comment=comment, - credential_preview=None, - cred_def_id=cred_def_id, - ) - with async_mock.patch.object( V10CredentialExchange, "save", autospec=True ) as save_ex: @@ -99,14 +90,46 @@ async def test_create_proposal(self): comment=comment, credential_preview=preview, cred_def_id=None, - ) # OK to leave open until offer + ) # OK to leave underspecified until offer + + proposal = CredentialProposal.deserialize(exchange.credential_proposal_dict) + + assert exchange.auto_offer + assert exchange.connection_id == connection_id + assert not exchange.credential_definition_id # leave underspecified until offer + assert not exchange.schema_id # leave underspecified until offer + assert exchange.thread_id == proposal._thread_id + assert exchange.role == exchange.ROLE_HOLDER + assert exchange.state == V10CredentialExchange.STATE_PROPOSAL_SENT + + async def test_create_proposal_no_preview(self): + schema_id = "LjgpST2rjsoxYegQDRm7EL:2:bc-reg:1.0" + cred_def_id = "LjgpST2rjsoxYegQDRm7EL:3:CL:18:tag" + connection_id = "test_conn_id" + comment = "comment" + + self.ledger.credential_definition_id2schema_id = async_mock.CoroutineMock( + return_value=schema_id + ) + + with async_mock.patch.object( + V10CredentialExchange, "save", autospec=True + ) as save_ex: + exchange: V10CredentialExchange = await self.manager.create_proposal( + connection_id, + auto_offer=True, + comment=comment, + credential_preview=None, + cred_def_id=cred_def_id, + ) + save_ex.assert_called_once() proposal = CredentialProposal.deserialize(exchange.credential_proposal_dict) assert exchange.auto_offer assert exchange.connection_id == connection_id - assert not exchange.credential_definition_id # leave open until offer - assert not exchange.schema_id # leave open until offer + assert not exchange.credential_definition_id # leave underspecified until offer + assert not exchange.schema_id # leave underspecified until offer assert exchange.thread_id == proposal._thread_id assert exchange.role == exchange.ROLE_HOLDER assert exchange.state == V10CredentialExchange.STATE_PROPOSAL_SENT @@ -723,6 +746,63 @@ async def test_store_credential(self): assert ret_exchange.state == V10CredentialExchange.STATE_ACKED assert ret_cred_ack._thread_id == thread_id + async def test_store_credential_no_preview(self): + schema_id = "LjgpST2rjsoxYegQDRm7EL:2:bc-reg:1.0" + cred_def_id = "LjgpST2rjsoxYegQDRm7EL:3:CL:18:tag" + connection_id = "test_conn_id" + cred = {"cred_def_id": cred_def_id} + cred_req_meta = {"req": "meta"} + thread_id = "thread-id" + + stored_exchange = V10CredentialExchange( + connection_id=connection_id, + credential_definition_id=cred_def_id, + credential_request_metadata=cred_req_meta, + credential_proposal_dict=None, + raw_credential=cred, + initiator=V10CredentialExchange.INITIATOR_EXTERNAL, + role=V10CredentialExchange.ROLE_HOLDER, + thread_id=thread_id, + ) + + cred_def = object() + self.ledger.get_credential_definition = async_mock.CoroutineMock( + return_value=cred_def + ) + + cred_id = "cred-id" + holder = async_mock.MagicMock() + holder.store_credential = async_mock.CoroutineMock(return_value=cred_id) + stored_cred = {"stored": "cred"} + holder.get_credential = async_mock.CoroutineMock(return_value=stored_cred) + self.context.injector.bind_instance(BaseHolder, holder) + + with async_mock.patch.object( + V10CredentialExchange, "save", autospec=True + ) as save_ex: + + ret_exchange, ret_cred_ack = await self.manager.store_credential( + stored_exchange + ) + + save_ex.assert_called_once() + + self.ledger.get_credential_definition.assert_called_once_with(cred_def_id) + + holder.store_credential.assert_called_once_with( + cred_def, + cred, + cred_req_meta, + None + ) + + holder.get_credential.assert_called_once_with(cred_id) + + assert ret_exchange.credential_id == cred_id + assert ret_exchange.credential == stored_cred + assert ret_exchange.state == V10CredentialExchange.STATE_ACKED + assert ret_cred_ack._thread_id == thread_id + async def test_credential_ack(self): connection_id = "connection-id" stored_exchange = V10CredentialExchange( diff --git a/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_routes.py b/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_routes.py index 1d3734f03e..d3fe5ef846 100644 --- a/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/issue_credential/v1_0/tests/test_routes.py @@ -137,26 +137,14 @@ async def test_credential_exchange_send(self): mock_cred_ex_record.serialize.return_value ) - async def test_credential_exchange_send_no_proposal(self): - mock = async_mock.MagicMock() - mock.json = async_mock.CoroutineMock() - mock.json.return_value = { - "comment": "comment", - "connection_id": "dummy", - } - - mock.app = { - "outbound_message_router": async_mock.CoroutineMock(), - "request_context": "context", - } - - with self.assertRaises(test_module.web.HTTPBadRequest): - await test_module.credential_exchange_send(mock) - async def test_credential_exchange_send_no_conn_record(self): - mock = async_mock.MagicMock() - mock.json = async_mock.CoroutineMock() + conn_id = "connection-id" + preview_spec = {"attributes": [{"name": "attr", "value": "value"}]} + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock( + return_value={"connection_id": conn_id, "credential_proposal": preview_spec} + ) mock.app = { "outbound_message_router": async_mock.CoroutineMock(), "request_context": "context", @@ -180,9 +168,13 @@ async def test_credential_exchange_send_no_conn_record(self): await test_module.credential_exchange_send(mock) async def test_credential_exchange_send_not_ready(self): - mock = async_mock.MagicMock() - mock.json = async_mock.CoroutineMock() + conn_id = "connection-id" + preview_spec = {"attributes": [{"name": "attr", "value": "value"}]} + mock = async_mock.MagicMock() + mock.json = async_mock.CoroutineMock( + return_value={"connection_id": conn_id, "credential_proposal": preview_spec} + ) mock.app = { "outbound_message_router": async_mock.CoroutineMock(), "request_context": "context", @@ -244,22 +236,6 @@ async def test_credential_exchange_send_proposal(self): mock_proposal_deserialize.return_value, connection_id=conn_id ) - async def test_credential_exchange_send_proposal_no_proposal(self): - mock = async_mock.MagicMock() - mock.json = async_mock.CoroutineMock() - mock.json.return_value = { - "comment": "comment", - "connection_id": "dummy", - } - - mock.app = { - "outbound_message_router": async_mock.CoroutineMock(), - "request_context": "context", - } - - with self.assertRaises(test_module.web.HTTPBadRequest): - await test_module.credential_exchange_send_proposal(mock) - async def test_credential_exchange_send_proposal_no_conn_record(self): mock = async_mock.MagicMock() mock.json = async_mock.CoroutineMock() diff --git a/aries_cloudagent/protocols/present_proof/v1_0/messages/inner/presentation_preview.py b/aries_cloudagent/protocols/present_proof/v1_0/messages/inner/presentation_preview.py index e27d060ab2..72677533a2 100644 --- a/aries_cloudagent/protocols/present_proof/v1_0/messages/inner/presentation_preview.py +++ b/aries_cloudagent/protocols/present_proof/v1_0/messages/inner/presentation_preview.py @@ -98,6 +98,7 @@ def __init__( cred_def_id: str = None, mime_type: str = None, value: str = None, + referent: str = None, **kwargs, ): """ @@ -110,6 +111,7 @@ def __init__( mime_type: MIME type value: attribute value as credential stores it (None for unrevealed attribute) + referent: credential referent """ super().__init__(**kwargs) @@ -117,22 +119,29 @@ def __init__( self.cred_def_id = cred_def_id self.mime_type = mime_type.lower() if mime_type else None self.value = value + self.referent = referent @staticmethod - def list_plain(plain: dict, cred_def_id: str): + def list_plain(plain: dict, cred_def_id: str, referent: str = None): """ Return a list of `PresAttrSpec` on input cred def id. Args: plain: dict mapping names to values - + cred_def_id: credential definition identifier to specify + referent: single referent to use, omit for none Returns: List of PresAttrSpec on input cred def id with no MIME types """ return [ - PresAttrSpec(name=k, cred_def_id=cred_def_id, value=plain[k]) for k in plain + PresAttrSpec( + name=k, + cred_def_id=cred_def_id, + value=plain[k], + referent=referent + ) for k in plain ] @property @@ -178,6 +187,9 @@ def __eq__(self, other): if self.mime_type != other.mime_type: return False # distinct MIME types + if self.referent != other.referent: + return False # distinct credential referents + return self.b64_decoded_value() == other.b64_decoded_value() @@ -200,6 +212,11 @@ class Meta: example="image/jpeg", ) value = fields.Str(description="Attribute value", required=False, example="martini") + referent = fields.Str( + description="Credential referent", + required=False, + example="0" + ) class PresentationPreview(BaseModel): @@ -304,6 +321,7 @@ def non_revo(cred_def_id: str): "requested_predicates": {}, } + attr_specs_names = {} for attr_spec in self.attributes: if attr_spec.posture == PresAttrSpec.Posture.SELF_ATTESTED: proof_req["requested_attributes"][ @@ -321,19 +339,44 @@ def non_revo(cred_def_id: str): ) timestamp = non_revo(attr_spec.cred_def_id) - proof_req["requested_attributes"][ - "{}_{}_uuid".format( - len(proof_req["requested_attributes"]), - canon(attr_spec.name)) - ] = { - "name": canon(attr_spec.name), - "restrictions": [{"cred_def_id": cd_id}], - **{ - "non_revoked": {"from": timestamp, "to": timestamp} - for _ in [""] - if revo_support - }, - } + + if attr_spec.referent: + if attr_spec.referent in attr_specs_names: + attr_specs_names[attr_spec.referent]["names"].append( + canon(attr_spec.name) + ) + else: + attr_specs_names[attr_spec.referent] = { + "names": [canon(attr_spec.name)], + "restrictions": [{"cred_def_id": cd_id}], + **{ + "non_revoked": {"from": timestamp, "to": timestamp} + for _ in [""] + if revo_support + }, + } + else: + proof_req["requested_attributes"][ + "{}_{}_uuid".format( + len(proof_req["requested_attributes"]), + canon(attr_spec.name) + ) + ] = { + "name": canon(attr_spec.name), + "restrictions": [{"cred_def_id": cd_id}], + **{ + "non_revoked": {"from": timestamp, "to": timestamp} + for _ in [""] + if revo_support + }, + } + for (reft, attr_spec) in attr_specs_names.items(): + proof_req["requested_attributes"][ + "{}_{}_uuid".format( + len(proof_req["requested_attributes"]), + canon(attr_spec["names"][0]) + ) + ] = attr_spec for pred_spec in self.predicates: cd_id = pred_spec.cred_def_id diff --git a/aries_cloudagent/protocols/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py b/aries_cloudagent/protocols/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py index 59d7dcbe4d..5441949db7 100644 --- a/aries_cloudagent/protocols/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py +++ b/aries_cloudagent/protocols/present_proof/v1_0/messages/inner/tests/test_presentation_preview.py @@ -24,18 +24,23 @@ NOW_8601 = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat(" ", "seconds") NOW_EPOCH = str_to_epoch(NOW_8601) -S_ID = "NcYxiDXkpYi6ov5FcYDi1e:2:vidya:1.0" -CD_ID = f"NcYxiDXkpYi6ov5FcYDi1e:3:CL:{S_ID}:tag1" +S_ID = { + "score": "NcYxiDXkpYi6ov5FcYDi1e:2:score:1.0", + "membership": "NcYxiDXkpYi6ov5FcYDi1e:2:membership:1.0" +} +CD_ID = { + name: f"NcYxiDXkpYi6ov5FcYDi1e:3:CL:{S_ID[name]}:tag1" for name in S_ID +} PRES_PREVIEW = PresentationPreview( attributes=[ PresAttrSpec( name="player", - cred_def_id=CD_ID, + cred_def_id=CD_ID['score'], value="Richie Knucklez" ), PresAttrSpec( name="screenCapture", - cred_def_id=CD_ID, + cred_def_id=CD_ID['score'], mime_type="image/png", value="aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" ) @@ -43,12 +48,41 @@ predicates=[ PresPredSpec( name="highScore", - cred_def_id=CD_ID, + cred_def_id=CD_ID['score'], predicate=">=", threshold=1000000 ) ] ) +PRES_PREVIEW_ATTR_NAMES = PresentationPreview( + attributes=[ + PresAttrSpec( + name="player", + cred_def_id=CD_ID['score'], + value="Richie Knucklez", + referent="reft-0" + ), + PresAttrSpec( + name="screenCapture", + cred_def_id=CD_ID['score'], + mime_type="image/png", + value="aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl", + referent="reft-0" + ), + PresAttrSpec( + name="member", + cred_def_id=CD_ID['membership'], + value="Richard Hand", + referent="reft-1" + ), + PresAttrSpec( + name="since", + cred_def_id=CD_ID['membership'], + value="2020-01-01", + referent="reft-1" + ) + ] +) INDY_PROOF_REQ = json.loads(f"""{{ "name": "proof-req", "version": "1.0", @@ -58,7 +92,7 @@ "name": "player", "restrictions": [ {{ - "cred_def_id": "{CD_ID}" + "cred_def_id": "{CD_ID['score']}" }} ] }}, @@ -66,7 +100,7 @@ "name": "screenCapture", "restrictions": [ {{ - "cred_def_id": "{CD_ID}" + "cred_def_id": "{CD_ID['score']}" }} ] }} @@ -78,12 +112,36 @@ "p_value": 1000000, "restrictions": [ {{ - "cred_def_id": "{CD_ID}" + "cred_def_id": "{CD_ID['score']}" }} ] }} }} }}""") +INDY_PROOF_REQ_ATTR_NAMES = json.loads(f"""{{ + "name": "proof-req", + "version": "1.0", + "nonce": "12345", + "requested_attributes": {{ + "0_player_uuid": {{ + "names": ["player", "screenCapture"], + "restrictions": [ + {{ + "cred_def_id": "{CD_ID['score']}" + }} + ] + }}, + "1_member_uuid": {{ + "names": ["member", "since"], + "restrictions": [ + {{ + "cred_def_id": "{CD_ID['membership']}" + }} + ] + }} + }}, + "requested_predicates": {{}} +}}""") class TestPresAttrSpec(TestCase): @@ -99,14 +157,14 @@ def test_posture(self): revealed = PresAttrSpec( name="ident", - cred_def_id=CD_ID, + cred_def_id=CD_ID["score"], value="655321" ) assert revealed.posture == PresAttrSpec.Posture.REVEALED_CLAIM unrevealed = PresAttrSpec( name="ident", - cred_def_id=CD_ID + cred_def_id=CD_ID["score"] ) assert unrevealed.posture == PresAttrSpec.Posture.UNREVEALED_CLAIM @@ -119,17 +177,17 @@ def test_list_plain(self): "ident": "655321", " Given Name ": "Alexander DeLarge" }, - cred_def_id=CD_ID + cred_def_id=CD_ID["score"] ) explicit = [ PresAttrSpec( name="ident", - cred_def_id=CD_ID, + cred_def_id=CD_ID["score"], value="655321" ), PresAttrSpec( name="givenname", - cred_def_id=CD_ID, + cred_def_id=CD_ID["score"], value="Alexander DeLarge" ) ] @@ -139,6 +197,35 @@ def test_list_plain(self): assert any(xp == listp for xp in explicit) assert len(explicit) == len(by_list) + def test_list_plain_share_referent(self): + by_list = PresAttrSpec.list_plain( + plain={ + "ident": "655321", + " Given Name ": "Alexander DeLarge" + }, + cred_def_id=CD_ID["score"], + referent="dummy" + ) + explicit = [ + PresAttrSpec( + name="ident", + cred_def_id=CD_ID["score"], + value="655321", + referent="dummy" + ), + PresAttrSpec( + name="givenname", + cred_def_id=CD_ID["score"], + value="Alexander DeLarge", + referent="dummy" + ) + ] + + # order could be askew + for listp in by_list: + assert any(xp == listp for xp in explicit) + assert len(explicit) == len(by_list) + def test_eq(self): attr_specs_none_plain = [ PresAttrSpec( @@ -181,7 +268,19 @@ def test_eq(self): value="dmFsdWU=", mime_type=None ), - PresAttrSpec(name="name") + PresAttrSpec(name="name"), + PresAttrSpec( + name="name", + value="value", + cred_def_id="cred_def_id", + referent="reft-0" + ), + PresAttrSpec( + name="name", + value="value", + cred_def_id="cred_def_id", + referent="reft-1" + ) ] for lhs in attr_specs_none_plain: @@ -200,7 +299,7 @@ def test_deserialize(self): """Test deserialization.""" dump = json.dumps({ "name": "PLAYER", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "value": "Richie Knucklez" }) @@ -208,16 +307,35 @@ def test_deserialize(self): assert type(attr_spec) == PresAttrSpec assert attr_spec.name == "player" + dump = json.dumps({ + "name": "PLAYER", + "cred_def_id": CD_ID["score"], + "value": "Richie Knucklez", + "referent": "0" + }) + + attr_spec = PresAttrSpec.deserialize(dump) + assert type(attr_spec) == PresAttrSpec + assert attr_spec.name == "player" + def test_serialize(self): """Test serialization.""" attr_spec_dict = PRES_PREVIEW.attributes[0].serialize() assert attr_spec_dict == { "name": "player", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "value": "Richie Knucklez" } + attr_spec_dict = PRES_PREVIEW_ATTR_NAMES.attributes[0].serialize() + assert attr_spec_dict == { + "name": "player", + "cred_def_id": CD_ID["score"], + "value": "Richie Knucklez", + "referent": "reft-0" + } + class TestPredicate(TestCase): """Predicate tests for coverage""" @@ -268,7 +386,7 @@ def test_deserialize(self): """Test deserialization.""" dump = json.dumps({ "name": "HIGH SCORE", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "predicate": ">=", "threshold": 1000000 }) @@ -283,7 +401,7 @@ def test_serialize(self): pred_spec_dict = PRES_PREVIEW.predicates[0].serialize() assert pred_spec_dict == { "name": "highscore", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "predicate": ">=", "threshold": 1000000 } @@ -293,13 +411,13 @@ def test_eq(self): pred_spec_a = PresPredSpec( name="a", - cred_def_id=CD_ID, + cred_def_id=CD_ID["score"], predicate=Predicate.GE.value.math, threshold=0, ) pred_spec_b = PresPredSpec( name="b", - cred_def_id=CD_ID, + cred_def_id=CD_ID["score"], predicate=Predicate.GE.value.math, threshold=0, ) @@ -340,6 +458,22 @@ async def test_to_indy_proof_request(self): assert indy_proof_req == CANON_INDY_PROOF_REQ + @pytest.mark.asyncio + async def test_to_indy_proof_request_attr_names(self): + """Test presentation preview to indy proof request.""" + + CANON_INDY_PROOF_REQ_ATTR_NAMES = deepcopy(INDY_PROOF_REQ_ATTR_NAMES) + for spec in CANON_INDY_PROOF_REQ_ATTR_NAMES["requested_attributes"].values(): + spec["names"] = [canon(name) for name in spec["names"]] + + pres_preview = deepcopy(PRES_PREVIEW_ATTR_NAMES) + + indy_proof_req = await pres_preview.indy_proof_request( + **{k: INDY_PROOF_REQ_ATTR_NAMES[k] for k in ("name", "version", "nonce")} + ) + + assert indy_proof_req == CANON_INDY_PROOF_REQ_ATTR_NAMES + async def test_to_indy_proof_request_self_attested(self): """Test presentation preview to inty proof request with self-attested values.""" @@ -362,20 +496,20 @@ async def test_satisfaction(self): pred_spec = PresPredSpec( name="highScore", - cred_def_id=CD_ID, + cred_def_id=CD_ID["score"], predicate=Predicate.GE.value.math, threshold=1000000 ) attr_spec = PresAttrSpec( name="HIGHSCORE", - cred_def_id=CD_ID, + cred_def_id=CD_ID["score"], value=1234567 ) assert attr_spec.satisfies(pred_spec) attr_spec = PresAttrSpec( name="HIGHSCORE", - cred_def_id=CD_ID, + cred_def_id=CD_ID["score"], value=985260 ) assert not attr_spec.satisfies(pred_spec) @@ -401,12 +535,12 @@ def test_deserialize(self): "attributes": [ { "name": "player", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "value": "Richie Knucklez" }, { "name": "screencapture", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "mime-type": "image/png", "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" } @@ -414,7 +548,7 @@ def test_deserialize(self): "predicates": [ { "name": "highscore", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "predicate": ">=", "threshold": 1000000 } @@ -433,12 +567,12 @@ def test_serialize(self): "attributes": [ { "name": "player", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "value": "Richie Knucklez" }, { "name": "screencapture", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "mime-type": "image/png", "value": "aW1hZ2luZSBhIHNjcmVlbiBjYXB0dXJl" } @@ -446,7 +580,7 @@ def test_serialize(self): "predicates": [ { "name": "highscore", - "cred_def_id": CD_ID, + "cred_def_id": CD_ID["score"], "predicate": ">=", "threshold": 1000000 } diff --git a/aries_cloudagent/transport/outbound/message.py b/aries_cloudagent/transport/outbound/message.py index c946906db9..eb722ea372 100644 --- a/aries_cloudagent/transport/outbound/message.py +++ b/aries_cloudagent/transport/outbound/message.py @@ -21,6 +21,7 @@ def __init__( reply_from_verkey: str = None, target: ConnectionTarget = None, target_list: Sequence[ConnectionTarget] = None, + to_session_only: bool = False, ): """Initialize an outgoing message.""" self.connection_id = connection_id @@ -33,6 +34,7 @@ def __init__( self.reply_from_verkey = reply_from_verkey self.target = target self.target_list = list(target_list) if target_list else [] + self.to_session_only = to_session_only def __repr__(self) -> str: """