diff --git a/.github/workflows/publish-indy.yml b/.github/workflows/publish-indy.yml index 874851d5c9..44000535c6 100644 --- a/.github/workflows/publish-indy.yml +++ b/.github/workflows/publish-indy.yml @@ -2,7 +2,7 @@ name: Publish ACA-Py Image (Indy) run-name: Publish ACA-Py ${{ inputs.tag || github.event.release.tag_name }} Image (Indy ${{ inputs.indy_version || '1.16.0' }}) on: release: - types: [released] + types: [published] workflow_dispatch: inputs: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6ee9378c61..f9ecc4ebe3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,7 @@ name: Publish ACA-Py Image run-name: Publish ACA-Py ${{ inputs.tag || github.event.release.tag_name }} Image on: release: - types: [released] + types: [published] workflow_dispatch: inputs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e90d82bd2..0af211ba2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,109 +1,204 @@ -# 1.0.0-rc1 - -## November 6, 2022 - -1.0.0 is a breaking update to ACA-Py whose version is intended to indicate the -maturity of the implementation. The final 1.0.0 release will be Aries Interop -Profile 2.0-complete, and based on Python 3.7 or higher. - -### Breaking Changes - -As of release candidate 1.0.0-rc1, the only identified breaking change is the -handling of "unrevealed attributes" during verification (see -[\#1913](https://github.com/hyperledger/aries-cloudagent-python/pull/1913) for -details). As few implementations of Aries Wallets support unrevealed attributes -in an AnonCreds presentation, this is unlikely to impact any deployments. - -### Categorized List of Pull Requests - -In rc1, there are not a lot of new features, as the focus is on cleanup and -optimization. The biggest is the inclusion with ACA-Py of a universal resolver -interface, allowing an instance to have both local resolvers for some DID -Methods and a call out to an external universal resolver for other DID Methods. -Another significant feature is full support for Hyperledger Indy transaction -endorsement for Authors and Endorsers. A new repo +# 0.8.0-rc0 + +## February 8, 2023 + +0.8.0 is a breaking change that contains all updates since release 0.7.5. It +extends the previously tagged `1.0.0-rc1` release because it is not clear when +that release will be finalized. Many of the PRs in this release were previously +included in the `1.0.0-rc1` release. The categorized list of PRs separates those +that are new from those in the `1.0.0-rc1` release candidate. + +There are not a lot of new Aries Framework features in this release, as the +focus has been on cleanup and optimization. The biggest addition is the +inclusion with ACA-Py of a universal resolver interface, allowing an instance to +have both local resolvers for some DID Methods and a call out to an external +universal resolver for other DID Methods. Another significant new capability is +full support for Hyperledger Indy transaction endorsement for Authors and +Endorsers. A new repo [aries-endorser-service](https://github.com/hyperledger/aries-endorser-service) has been created that is a pre-configured instance of ACA-Py for use as an -Endorser service. While some work has been done on moving the default Python -version beyond 3.6, more work is still to be done on that before the final -v1.0.0 release. +Endorser service. + +### Container Publishing Updated + +With this release, a new automated process publishes container images in the +Hyperledger container image repository. New images for the release are +automatically published by the GitHubAction Workflows: [publish.yml] and +[publish-indy.yml]. The actions are triggered when a release is tagged, so no +manual action is needed. The images are published in the [Hyperledger Package +Repository under aries-cloudagent-python] and a link to the packages added to +the repositories main page (under "Packages"). Additional information about the +container image publication process can be found in the document [Container +Images and Github Actions]. + +The ACA-Py container images are based on [Python 3.6 and 3.9 `slim-bullseye` +images](https://hub.docker.com/_/python), and are built to support `linux/386 +(x86)`, `linux/amd64 (x64)`, and `linux/arm64`. There are two flavors of image +built for each Python version. One contains only the Indy/Aries Shared Libraries +only ([Aries Askar](https://github.com/hyperledger/aries-askar), [Indy +VDR](https://github.com/hyperledger/indy-vdr) and [Indy Shared +RS](https://github.com/hyperledger/indy-shared-rs), supporting only the use of +`--wallet-type askar`). The other (labelled `indy`) contains the Indy/Aries +shared libraries and the Indy SDK (considered deprecated). For new deployments, +we recommend using the Python 3.9 Shared Library images. For existing +deployments, we recommend migrating to those images. For those migrating an Indy +SDK deployment, a new secure storage database migration capability from Indy SDK +to Aries Askar is available--contact the ACA-Py maintainers on Hyperledger +Discord for details. + +Those currently using the container images published by [BC Gov on Docker +Hub](https://hub.docker.com/r/bcgovimages/aries-cloudagent) should change to use +those published to the [Hyperledger Package Repository under +aries-cloudagent-python]. + +[Hyperledger Package Repository under aries-cloudagent-python]: https://github.com/orgs/hyperledger/packages?repo_name=aries-cloudagent-python +[publish.yml]: https://github.com/hyperledger/aries-cloudagent-python/blob/main/.github/workflows/publish.yml +[publish-indy.yml]: https://github.com/hyperledger/aries-cloudagent-python/blob/main/.github/workflows/publish-indy.yml +[Container Images and Github Actions]: https://github.com/hyperledger/aries-cloudagent-python/blob/main/ContainerImagesAndGithubActions.md + + +## Breaking Changes + +### PR [\#2034](https://github.com/hyperledger/aries-cloudagent-python/pull/2034) -- Implicit connections + +The break impacts existing deployments that support implicit connections, those +initiated by another agent using a Public DID for this instance instead of an +explicit invitation. Such deployments need to add the configuration parameter +`--requests-through-public-did` to continue to support that feature. The use +case is that an ACA-Py instance publishes a public DID on a ledger with a +DIDComm `service` in the DIDDoc. Other agents resolve that DID, and attempt to +establish a connection with the ACA-Py instance using the `service` endpoint. +This is called an "implicit" connection in [RFC 0023 DID +Exchange](https://github.com/hyperledger/aries-rfcs/blob/main/features/0023-did-exchange/README.md). + +### PR [\#1913](https://github.com/hyperledger/aries-cloudagent-python/pull/1913) -- Unrevealed attributes in presentations + +Updates the handling of "unrevealed attributes" during verification of AnonCreds +presentations, allowing them to be used in a presentation, with additional data +that can be checked if for unrevealed attributes. As few implementations of +Aries wallets support unrevealed attributes in an AnonCreds presentation, this +is unlikely to impact any deployments. ### Categorized List of Pull Requests - Verifiable credential, presentation and revocation handling updates - - Refactor ledger correction code and insert into revocation error handling [\#1892](https://github.com/hyperledger/aries-cloudagent-python/pull/1892) ([ianco](https://github.com/ianco)) - - Indy ledger fixes and cleanups [\#1870](https://github.com/hyperledger/aries-cloudagent-python/pull/1870) ([andrewwhitehead](https://github.com/andrewwhitehead)) - - Refactoring of revocation registry creation [\#1813](https://github.com/hyperledger/aries-cloudagent-python/pull/1813) ([andrewwhitehead](https://github.com/andrewwhitehead)) - - Fix: the type of tails file path to string. [\#1925](https://github.com/hyperledger/aries-cloudagent-python/pull/1925) ([baegjae](https://github.com/baegjae)) - - Pre-populate revoc\_reg\_id on IssuerRevRegRecord [\#1924](https://github.com/hyperledger/aries-cloudagent-python/pull/1924) ([andrewwhitehead](https://github.com/andrewwhitehead)) - - Leave credentialStatus element in the LD credential [\#1921](https://github.com/hyperledger/aries-cloudagent-python/pull/1921) ([tsabolov](https://github.com/tsabolov)) - - **BREAKING:** Remove aca-py check for unrevealed revealed attrs on proof validation [\#1913](https://github.com/hyperledger/aries-cloudagent-python/pull/1913) ([ianco](https://github.com/ianco)) - - Send webhooks upon record/credential deletion [\#1906](https://github.com/hyperledger/aries-cloudagent-python/pull/1906) ([frostyfrog](https://github.com/frostyfrog)) - -- Out of Band (OOB) and DID Exchange / Connection Handling - - Fix: `--mediator-invitation` with OOB invitation + cleanup [\#1970](https://github.com/hyperledger/aries-cloudagent-python/pull/1970) ([shaangill025](https://github.com/shaangill025)) - - include image\_url in oob invitation [\#1966](https://github.com/hyperledger/aries-cloudagent-python/pull/1966) ([Zzocker](https://github.com/Zzocker)) - - feat: 00B v1.1 support [\#1962](https://github.com/hyperledger/aries-cloudagent-python/pull/1962) ([shaangill025](https://github.com/shaangill025)) - - Fix: OOB - Handling of minor versions [\#1940](https://github.com/hyperledger/aries-cloudagent-python/pull/1940) ([shaangill025](https://github.com/shaangill025)) - - fix: failed connectionless proof request on some case [\#1933](https://github.com/hyperledger/aries-cloudagent-python/pull/1933) ([kukgini](https://github.com/kukgini)) - - fix: propagate endpoint from mediation record [\#1922](https://github.com/hyperledger/aries-cloudagent-python/pull/1922) ([cjhowland](https://github.com/cjhowland)) - - Feat/public did endpoints for agents behind mediators [\#1899](https://github.com/hyperledger/aries-cloudagent-python/pull/1899) ([cjhowland](https://github.com/cjhowland)) + - Feature: enabled handling VPs \(request, creation, verification\) with different VCs [\#1956](https://github.com/hyperledger/aries-cloudagent-python/pull/1956) ([teanas](https://github.com/teanas)) + - fix: update issue-credential endpoint summaries [\#1997](https://github.com/hyperledger/aries-cloudagent-python/pull/1997) ([PeterStrob](https://github.com/PeterStrob)) + - fix claim format designation in presentation submission [\#2013](https://github.com/hyperledger/aries-cloudagent-python/pull/2013) ([rmnre](https://github.com/rmnre)) + - \#2041 - Issue JSON-LD has invalid Admin API documentation [\#2046](https://github.com/hyperledger/aries-cloudagent-python/pull/2046) ([jfblier-amplitude](https://github.com/jfblier-amplitude)) + - Previously flagged in release 1.0.0-rc1 + - Refactor ledger correction code and insert into revocation error handling [\#1892](https://github.com/hyperledger/aries-cloudagent-python/pull/1892) ([ianco](https://github.com/ianco)) + - Indy ledger fixes and cleanups [\#1870](https://github.com/hyperledger/aries-cloudagent-python/pull/1870) ([andrewwhitehead](https://github.com/andrewwhitehead)) + - Refactoring of revocation registry creation [\#1813](https://github.com/hyperledger/aries-cloudagent-python/pull/1813) ([andrewwhitehead](https://github.com/andrewwhitehead)) + - Fix: the type of tails file path to string. [\#1925](https://github.com/hyperledger/aries-cloudagent-python/pull/1925) ([baegjae](https://github.com/baegjae)) + - Pre-populate revoc\_reg\_id on IssuerRevRegRecord [\#1924](https://github.com/hyperledger/aries-cloudagent-python/pull/1924) ([andrewwhitehead](https://github.com/andrewwhitehead)) + - Leave credentialStatus element in the LD credential [\#1921](https://github.com/hyperledger/aries-cloudagent-python/pull/1921) ([tsabolov](https://github.com/tsabolov)) + - **BREAKING:** Remove aca-py check for unrevealed revealed attrs on proof validation [\#1913](https://github.com/hyperledger/aries-cloudagent-python/pull/1913) ([ianco](https://github.com/ianco)) + - Send webhooks upon record/credential deletion [\#1906](https://github.com/hyperledger/aries-cloudagent-python/pull/1906) ([frostyfrog](https://github.com/frostyfrog)) + +- Out of Band (OOB) and DID Exchange / Connection Handling / Mediator + - fix: public did mediator routing keys as did keys [\#1977](https://github.com/hyperledger/aries-cloudagent-python/pull/1977) ([dbluhm](https://github.com/dbluhm)) + - Fix for mediator load testing race condition when scaling horizontally [\#2009](https://github.com/hyperledger/aries-cloudagent-python/pull/2009) ([ianco](https://github.com/ianco)) + - BREAKING: Allow multi-use public invites and public invites with metadata [\#2034](https://github.com/hyperledger/aries-cloudagent-python/pull/2034) ([mepeltier](https://github.com/mepeltier)) + - Do not reject OOB invitation with unknown handshake protocol\(s\) [\#2060](https://github.com/hyperledger/aries-cloudagent-python/pull/2060) ([andrewwhitehead](https://github.com/andrewwhitehead)) + - fix: fix connection timing bug [\#2099](https://github.com/hyperledger/aries-cloudagent-python/pull/2099) ([reflectivedevelopment](https://github.com/reflectivedevelopment)) + - Previously flagged in release 1.0.0-rc1 + - Fix: `--mediator-invitation` with OOB invitation + cleanup [\#1970](https://github.com/hyperledger/aries-cloudagent-python/pull/1970) ([shaangill025](https://github.com/shaangill025)) + - include image\_url in oob invitation [\#1966](https://github.com/hyperledger/aries-cloudagent-python/pull/1966) ([Zzocker](https://github.com/Zzocker)) + - feat: 00B v1.1 support [\#1962](https://github.com/hyperledger/aries-cloudagent-python/pull/1962) ([shaangill025](https://github.com/shaangill025)) + - Fix: OOB - Handling of minor versions [\#1940](https://github.com/hyperledger/aries-cloudagent-python/pull/1940) ([shaangill025](https://github.com/shaangill025)) + - fix: failed connectionless proof request on some case [\#1933](https://github.com/hyperledger/aries-cloudagent-python/pull/1933) ([kukgini](https://github.com/kukgini)) + - fix: propagate endpoint from mediation record [\#1922](https://github.com/hyperledger/aries-cloudagent-python/pull/1922) ([cjhowland](https://github.com/cjhowland)) + - Feat/public did endpoints for agents behind mediators [\#1899](https://github.com/hyperledger/aries-cloudagent-python/pull/1899) ([cjhowland](https://github.com/cjhowland)) - DID Registration and Resolution related updates - - feat: add universal resolver [\#1866](https://github.com/hyperledger/aries-cloudagent-python/pull/1866) ([dbluhm](https://github.com/dbluhm)) - - fix: resolve dids following new endpoint rules [\#1863](https://github.com/hyperledger/aries-cloudagent-python/pull/1863) ([dbluhm](https://github.com/dbluhm)) - - fix: didx request cannot be accepted [\#1881](https://github.com/hyperledger/aries-cloudagent-python/pull/1881) ([rmnre](https://github.com/rmnre)) - - did method & key type registry [\#1986](https://github.com/hyperledger/aries-cloudagent-python/pull/1986) ([burdettadam](https://github.com/burdettadam)) - - Fix/endpoint attrib structure [\#1934](https://github.com/hyperledger/aries-cloudagent-python/pull/1934) ([cjhowland](https://github.com/cjhowland)) - - Simple did registry [\#1920](https://github.com/hyperledger/aries-cloudagent-python/pull/1920) ([burdettadam](https://github.com/burdettadam)) - - Use did:key for recipient keys [\#1886](https://github.com/hyperledger/aries-cloudagent-python/pull/1886) ([frostyfrog](https://github.com/frostyfrog)) + - feat: enable creation of DIDs for all registered methods [\#2067](https://github.com/hyperledger/aries-cloudagent-python/pull/2067) ([chumbert](https://github.com/chumbert)) + - fix: create local DID return schema [\#2086](https://github.com/hyperledger/aries-cloudagent-python/pull/2086) ([chumbert](https://github.com/chumbert)) + - feat: universal resolver - configurable authentication [\#2095](https://github.com/hyperledger/aries-cloudagent-python/pull/2095) ([chumbert](https://github.com/chumbert)) + - Previously flagged in release 1.0.0-rc1 + - feat: add universal resolver [\#1866](https://github.com/hyperledger/aries-cloudagent-python/pull/1866) ([dbluhm](https://github.com/dbluhm)) + - fix: resolve dids following new endpoint rules [\#1863](https://github.com/hyperledger/aries-cloudagent-python/pull/1863) ([dbluhm](https://github.com/dbluhm)) + - fix: didx request cannot be accepted [\#1881](https://github.com/hyperledger/aries-cloudagent-python/pull/1881) ([rmnre](https://github.com/rmnre)) + - did method & key type registry [\#1986](https://github.com/hyperledger/aries-cloudagent-python/pull/1986) ([burdettadam](https://github.com/burdettadam)) + - Fix/endpoint attrib structure [\#1934](https://github.com/hyperledger/aries-cloudagent-python/pull/1934) ([cjhowland](https://github.com/cjhowland)) + - Simple did registry [\#1920](https://github.com/hyperledger/aries-cloudagent-python/pull/1920) ([burdettadam](https://github.com/burdettadam)) + - Use did:key for recipient keys [\#1886](https://github.com/hyperledger/aries-cloudagent-python/pull/1886) ([frostyfrog](https://github.com/frostyfrog)) - Hyperledger Indy Endorser/Author Transaction Handling - - Fix/txn job setting [\#1994](https://github.com/hyperledger/aries-cloudagent-python/pull/1994) ([ianco](https://github.com/ianco)) - - chore: fix ACAPY\_PROMOTE-AUTHOR-DID flag [\#1978](https://github.com/hyperledger/aries-cloudagent-python/pull/1978) ([morrieinmaas](https://github.com/morrieinmaas)) - - - Endorser write DID transaction [\#1938](https://github.com/hyperledger/aries-cloudagent-python/pull/1938) ([ianco](https://github.com/ianco)) - - Endorser doc updates and some bug fixes [\#1926](https://github.com/hyperledger/aries-cloudagent-python/pull/1926) ([ianco](https://github.com/ianco)) + - Special handling for the write ledger [\#2030](https://github.com/hyperledger/aries-cloudagent-python/pull/2030) ([ianco](https://github.com/ianco)) + - Previously flagged in release 1.0.0-rc1 + - Fix/txn job setting [\#1994](https://github.com/hyperledger/aries-cloudagent-python/pull/1994) ([ianco](https://github.com/ianco)) + - chore: fix ACAPY\_PROMOTE-AUTHOR-DID flag [\#1978](https://github.com/hyperledger/aries-cloudagent-python/pull/1978) ([morrieinmaas](https://github.com/morrieinmaas)) + - Endorser write DID transaction [\#1938](https://github.com/hyperledger/aries-cloudagent-python/pull/1938) ([ianco](https://github.com/ianco)) + - Endorser doc updates and some bug fixes [\#1926](https://github.com/hyperledger/aries-cloudagent-python/pull/1926) ([ianco](https://github.com/ianco)) - Startup Command Line / Environment / YAML Parameter Updates - - Add seed command line parameter but use only if also an "allow insecure seed" parameter is set [\#1714](https://github.com/hyperledger/aries-cloudagent-python/pull/1714) ([DaevMithran](https://github.com/DaevMithran)) + - Add missing --mediator-connections-invite cmd arg info to docs [\#2051](https://github.com/hyperledger/aries-cloudagent-python/pull/2051) ([matrixik](https://github.com/matrixik)) + - Issue \#2068 boolean flag change to support HEAD requests to default route [\#2077](https://github.com/hyperledger/aries-cloudagent-python/pull/2077) ([johnekent](https://github.com/johnekent)) + - Previously flagged in release 1.0.0-rc1 + - Add seed command line parameter but use only if also an "allow insecure seed" parameter is set [\#1714](https://github.com/hyperledger/aries-cloudagent-python/pull/1714) ([DaevMithran](https://github.com/DaevMithran)) - Internal Aries framework data handling updates - - fix: update RouteManager methods use to pass profile as parameter [\#1902](https://github.com/hyperledger/aries-cloudagent-python/pull/1902) ([chumbert](https://github.com/chumbert)) - - Allow fully qualified class names for profile managers [\#1880](https://github.com/hyperledger/aries-cloudagent-python/pull/1880) ([chumbert](https://github.com/chumbert)) - - fix: unable to use askar with in memory db [\#1878](https://github.com/hyperledger/aries-cloudagent-python/pull/1878) ([dbluhm](https://github.com/dbluhm)) - - Enable manually triggering keylist updates during connection [\#1851](https://github.com/hyperledger/aries-cloudagent-python/pull/1851) ([dbluhm](https://github.com/dbluhm)) - - feat: make base wallet route access configurable [\#1836](https://github.com/hyperledger/aries-cloudagent-python/pull/1836) ([dbluhm](https://github.com/dbluhm)) - - feat: event and webhook on keylist update stored [\#1769](https://github.com/hyperledger/aries-cloudagent-python/pull/1769) ([dbluhm](https://github.com/dbluhm)) - - fix: Safely shutdown when root\_profile uninitialized [\#1960](https://github.com/hyperledger/aries-cloudagent-python/pull/1960) ([frostyfrog](https://github.com/frostyfrog)) - - feat: include connection ids in keylist update webhook [\#1914](https://github.com/hyperledger/aries-cloudagent-python/pull/1914) ([dbluhm](https://github.com/dbluhm)) - - fix: incorrect response schema for discover features [\#1912](https://github.com/hyperledger/aries-cloudagent-python/pull/1912) ([dbluhm](https://github.com/dbluhm)) - - Fix: SchemasInputDescriptorFilter: broken deserialization renders generated clients unusable [\#1894](https://github.com/hyperledger/aries-cloudagent-python/pull/1894) ([rmnre](https://github.com/rmnre)) - - fix: schema class can set Meta.unknown [\#1885](https://github.com/hyperledger/aries-cloudagent-python/pull/1885) ([dbluhm](https://github.com/dbluhm)) - -- Unit, Integration and Aries Agent Test Harness Test updates - - Fixes a few AATH failures [\#1897](https://github.com/hyperledger/aries-cloudagent-python/pull/1897) ([ianco](https://github.com/ianco)) - - fix: warnings in tests from IndySdkProfile [\#1865](https://github.com/hyperledger/aries-cloudagent-python/pull/1865) ([dbluhm](https://github.com/dbluhm)) - - Unit test fixes for python 3.9 [\#1858](https://github.com/hyperledger/aries-cloudagent-python/pull/1858) ([andrewwhitehead](https://github.com/andrewwhitehead)) - - Update pip-audit.yml [\#1945](https://github.com/hyperledger/aries-cloudagent-python/pull/1945) ([ryjones](https://github.com/ryjones)) - - Update pip-audit.yml [\#1944](https://github.com/hyperledger/aries-cloudagent-python/pull/1944) ([ryjones](https://github.com/ryjones)) - -- Dependency Updates - - feat: update pynacl version from 1.4.0 to 1.50 [\#1981](https://github.com/hyperledger/aries-cloudagent-python/pull/1981) ([morrieinmaas](https://github.com/morrieinmaas)) - - Fix: web.py dependency - integration tests & demos [\#1973](https://github.com/hyperledger/aries-cloudagent-python/pull/1973) ([shaangill025](https://github.com/shaangill025)) - - chore: update pydid [\#1915](https://github.com/hyperledger/aries-cloudagent-python/pull/1915) ([dbluhm](https://github.com/dbluhm)) + - fix: resolver api schema inconsistency [\#2112](https://github.com/hyperledger/aries-cloudagent-python/pull/2112) ([TimoGlastra](https://github.com/chumbert)) + - fix: return if return route but no response [\#1853](https://github.com/hyperledger/aries-cloudagent-python/pull/1853) ([TimoGlastra](https://github.com/TimoGlastra)) + - Multi-ledger/Multi-tenant issues [\#2022](https://github.com/hyperledger/aries-cloudagent-python/pull/2022) ([ianco](https://github.com/ianco)) + - fix: Correct typo in model -- required spelled incorrectly [\#2031](https://github.com/hyperledger/aries-cloudagent-python/pull/2031) ([swcurran](https://github.com/swcurran)) + - Code formatting [\#2053](https://github.com/hyperledger/aries-cloudagent-python/pull/2053) ([ianco](https://github.com/ianco)) + - Improved validation of record state attributes [\#2071](https://github.com/hyperledger/aries-cloudagent-python/pull/2071) ([rmnre](https://github.com/rmnre)) + - Previously flagged in release 1.0.0-rc1 + - fix: update RouteManager methods use to pass profile as parameter [\#1902](https://github.com/hyperledger/aries-cloudagent-python/pull/1902) ([chumbert](https://github.com/chumbert)) + - Allow fully qualified class names for profile managers [\#1880](https://github.com/hyperledger/aries-cloudagent-python/pull/1880) ([chumbert](https://github.com/chumbert)) + - fix: unable to use askar with in memory db [\#1878](https://github.com/hyperledger/aries-cloudagent-python/pull/1878) ([dbluhm](https://github.com/dbluhm)) + - Enable manually triggering keylist updates during connection [\#1851](https://github.com/hyperledger/aries-cloudagent-python/pull/1851) ([dbluhm](https://github.com/dbluhm)) + - feat: make base wallet route access configurable [\#1836](https://github.com/hyperledger/aries-cloudagent-python/pull/1836) ([dbluhm](https://github.com/dbluhm)) + - feat: event and webhook on keylist update stored [\#1769](https://github.com/hyperledger/aries-cloudagent-python/pull/1769) ([dbluhm](https://github.com/dbluhm)) + - fix: Safely shutdown when root\_profile uninitialized [\#1960](https://github.com/hyperledger/aries-cloudagent-python/pull/1960) ([frostyfrog](https://github.com/frostyfrog)) + - feat: include connection ids in keylist update webhook [\#1914](https://github.com/hyperledger/aries-cloudagent-python/pull/1914) ([dbluhm](https://github.com/dbluhm)) + - fix: incorrect response schema for discover features [\#1912](https://github.com/hyperledger/aries-cloudagent-python/pull/1912) ([dbluhm](https://github.com/dbluhm)) + - Fix: SchemasInputDescriptorFilter: broken deserialization renders generated clients unusable [\#1894](https://github.com/hyperledger/aries-cloudagent-python/pull/1894) ([rmnre](https://github.com/rmnre)) + - fix: schema class can set Meta.unknown [\#1885](https://github.com/hyperledger/aries-cloudagent-python/pull/1885) ([dbluhm](https://github.com/dbluhm)) + +- Unit, Integration, and Aries Agent Test Harness Test updates + - Additional integration tests for revocation scenarios [\#2055](https://github.com/hyperledger/aries-cloudagent-python/pull/2055) ([ianco](https://github.com/ianco)) + - Previously flagged in release 1.0.0-rc1 + - Fixes a few AATH failures [\#1897](https://github.com/hyperledger/aries-cloudagent-python/pull/1897) ([ianco](https://github.com/ianco)) + - fix: warnings in tests from IndySdkProfile [\#1865](https://github.com/hyperledger/aries-cloudagent-python/pull/1865) ([dbluhm](https://github.com/dbluhm)) + - Unit test fixes for python 3.9 [\#1858](https://github.com/hyperledger/aries-cloudagent-python/pull/1858) ([andrewwhitehead](https://github.com/andrewwhitehead)) + - Update pip-audit.yml [\#1945](https://github.com/hyperledger/aries-cloudagent-python/pull/1945) ([ryjones](https://github.com/ryjones)) + - Update pip-audit.yml [\#1944](https://github.com/hyperledger/aries-cloudagent-python/pull/1944) ([ryjones](https://github.com/ryjones)) + +- Dependency, Python version, GitHub Actions and Container Image Changes + - fix: indy dependency version format [\#2054](https://github.com/hyperledger/aries-cloudagent-python/pull/2054) ([chumbert](https://github.com/chumbert)) + - ci: add gha for pr-tests [\#2058](https://github.com/hyperledger/aries-cloudagent-python/pull/2058) ([dbluhm](https://github.com/dbluhm)) + - ci: test additional versions of python nightly [\#2059](https://github.com/hyperledger/aries-cloudagent-python/pull/2059) ([dbluhm](https://github.com/dbluhm)) + - Update github actions dependencies \(for node16 support\) [\#2066](https://github.com/hyperledger/aries-cloudagent-python/pull/2066) ([andrewwhitehead](https://github.com/andrewwhitehead)) + - Docker images and GHA for publishing images [\#2076](https://github.com/hyperledger/aries-cloudagent-python/pull/2076) ([dbluhm](https://github.com/dbluhm)) + - Update dockerfiles to use python 3.9 [\#2109](https://github.com/hyperledger/aries-cloudagent-python/pull/2109) ([ianco](https://github.com/ianco)) + - Updating base images from slim-buster to slim-bullseye [\#2105](https://github.com/hyperledger/aries-cloudagent-python/pull/2105) ([pradeepp88](https://github.com/pradeepp88)) + - Previously flagged in release 1.0.0-rc1 + - feat: update pynacl version from 1.4.0 to 1.50 [\#1981](https://github.com/hyperledger/aries-cloudagent-python/pull/1981) ([morrieinmaas](https://github.com/morrieinmaas)) + - Fix: web.py dependency - integration tests & demos [\#1973](https://github.com/hyperledger/aries-cloudagent-python/pull/1973) ([shaangill025](https://github.com/shaangill025)) + - chore: update pydid [\#1915](https://github.com/hyperledger/aries-cloudagent-python/pull/1915) ([dbluhm](https://github.com/dbluhm)) - Demo and Documentation Updates - - Fixes to acme exercise code [\#1990](https://github.com/hyperledger/aries-cloudagent-python/pull/1990) ([ianco](https://github.com/ianco)) - - Fixed bug in run\_demo script [\#1982](https://github.com/hyperledger/aries-cloudagent-python/pull/1982) ([pasquale95](https://github.com/pasquale95)) - - Transaction Author with Endorser demo [\#1975](https://github.com/hyperledger/aries-cloudagent-python/pull/1975) ([ianco](https://github.com/ianco)) - - Redis Plugins \[redis\_cache & redis\_queue\] related updates [\#1937](https://github.com/hyperledger/aries-cloudagent-python/pull/1937) ([shaangill025](https://github.com/shaangill025)) + - Fix typos in alice-local.sh & faber-local.sh [\#2010](https://github.com/hyperledger/aries-cloudagent-python/pull/2010) ([naonishijima](https://github.com/naonishijima)) + - Added a bit about manually creating a revoc reg tails file [\#2012](https://github.com/hyperledger/aries-cloudagent-python/pull/2012) ([ianco](https://github.com/ianco)) + - Add ability to set docker container name [\#2024](https://github.com/hyperledger/aries-cloudagent-python/pull/2024) ([matrixik](https://github.com/matrixik)) + - Doc updates for json demo [\#2026](https://github.com/hyperledger/aries-cloudagent-python/pull/2026) ([ianco](https://github.com/ianco)) + - Multitenancy demo \(docker-compose with postgres and ngrok\) [\#2089](https://github.com/hyperledger/aries-cloudagent-python/pull/2089) ([ianco](https://github.com/ianco)) + - Allow using YAML configuration file with run\_docker [\#2091](https://github.com/hyperledger/aries-cloudagent-python/pull/2091) ([matrixik](https://github.com/matrixik)) + - Previously flagged in release 1.0.0-rc1 + - Fixes to acme exercise code [\#1990](https://github.com/hyperledger/aries-cloudagent-python/pull/1990) ([ianco](https://github.com/ianco)) + - Fixed bug in run\_demo script [\#1982](https://github.com/hyperledger/aries-cloudagent-python/pull/1982) ([pasquale95](https://github.com/pasquale95)) + - Transaction Author with Endorser demo [\#1975](https://github.com/hyperledger/aries-cloudagent-python/pull/1975) ([ianco](https://github.com/ianco)) + - Redis Plugins \[redis\_cache & redis\_queue\] related updates [\#1937](https://github.com/hyperledger/aries-cloudagent-python/pull/1937) ([shaangill025](https://github.com/shaangill025)) - Release management pull requests - - Release 1.0.0-rc0 [\#1904](https://github.com/hyperledger/aries-cloudagent-python/pull/1904) ([swcurran](https://github.com/swcurran)) - - Add 0.7.5 patch Changelog entry to main branch Changelog [\#1996](https://github.com/hyperledger/aries-cloudagent-python/pull/1996) ([swcurran](https://github.com/swcurran)) - - Release 1.0.0-rc1 [\#2005](https://github.com/hyperledger/aries-cloudagent-python/pull/2005) ([swcurran](https://github.com/swcurran)) - + - 0.8.0-rc0 release updates [\#2115](https://github.com/hyperledger/aries-cloudagent-python/pull/2115) ([swcurran](https://github.com/swcurran)) + - Previously flagged in release 1.0.0-rc1 + - Release 1.0.0-rc0 [\#1904](https://github.com/hyperledger/aries-cloudagent-python/pull/1904) ([swcurran](https://github.com/swcurran)) + - Add 0.7.5 patch Changelog entry to main branch Changelog [\#1996](https://github.com/hyperledger/aries-cloudagent-python/pull/1996) ([swcurran](https://github.com/swcurran)) + - Release 1.0.0-rc1 [\#2005](https://github.com/hyperledger/aries-cloudagent-python/pull/2005) ([swcurran](https://github.com/swcurran)) # 0.7.5 diff --git a/PUBLISHING.md b/PUBLISHING.md index d8bd13a15d..4e561692dc 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -12,23 +12,116 @@ Once ready to do a release, create a local branch that includes the following up 3. Include details of the merged PRs included in this release. General process to follow: -- Gather the set of PRs since the last release and put them into a list. A good tool to use for this is the [github-changelog-generator](https://github.com/github-changelog-generator/github-changelog-generator). Steps: - - Create a read only GitHub token for your account on this page: [https://github.com/settings/tokens](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token) with a scope of `repo` / `public_repo`. - - Use a command like the following, adjusting the tag parameters as appropriate. `docker run -it --rm -v "$(pwd)":/usr/local/src/your-app githubchangeloggenerator/github-changelog-generator --user hyperledger --project aries-cloudagent-python --output 0.7.4-rc0.md --since-tag 0.7.3 --future-release 0.7.4-rc0 --release-branch main --token ` - - In the generated file, use only the PR list -- we don't include the list of closed issues in the Change Log. +- Gather the set of PRs since the last release and put them into a list. A good + tool to use for this is the + [github-changelog-generator](https://github.com/github-changelog-generator/github-changelog-generator). + Steps: + - Create a read only GitHub token for your account on this page: + [https://github.com/settings/tokens](https://github.com/settings/tokens/new?description=GitHub%20Changelog%20Generator%20token) + with a scope of `repo` / `public_repo`. + - Use a command like the following, adjusting the tag parameters as + appropriate. `docker run -it --rm -v "$(pwd)":/usr/local/src/your-app + githubchangeloggenerator/github-changelog-generator --user hyperledger + --project aries-cloudagent-python --output 0.7.4-rc0.md --since-tag 0.7.3 + --future-release 0.7.4-rc0 --release-branch main --token ` + - In the generated file, use only the PR list -- we don't include the list of + closed issues in the Change Log. + +In some cases, the approach above fails because of too many API calls. An +alternate approach to getting the list of PRs in the right format is to use this +scary `sed` pipeline process to get the same output.¥ + +- Put the following commands into a file called `changelog.sed` + +``` bash +/Approved/d +/updated /d +/^$/d +/^ [0-9]/d +s/was merged.*// +/^@/d +s# by \(.*\) # [\1](https://github.com/\1)# +s/^ // +s# \#\([0-9]*\)# [\#\1](https://github.com/hyperledger/aries-cloudagent-python/pull/\1) # +s/ / /g +/^Version/d +/tasks done/d +s/^/- / +``` + +- Navigate in your browser to the paged list of PRs merged since the last + release (using in the GitHub UI a filter such as `is:pr is:merged sort:updated + merged:>2022-04-07`) and for each page, highlight, and copy the text + of only the list of PRs on the page to use in the following step. +- For each page, run the command `sed -e :a -e '$!N;s/\n#/ #/;ta' -e 'P;D' < dict: settings["debug.auto_accept_requests"] = True if args.auto_respond_messages: settings["debug.auto_respond_messages"] = True + if args.disable_multiple_credential_flow: + settings["debug.disable_multiple_credential_flow"] = True return settings diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py index ee7850376a..ea18c2a590 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/handler.py @@ -60,7 +60,9 @@ def get_format_identifier(self, message_type: str) -> str: """ @abstractmethod - def get_format_data(self, message_type: str, data: dict) -> CredFormatAttachment: + def get_format_data( + self, message_type: str, data: dict, attach_id: str = None + ) -> CredFormatAttachment: """Get credential format and attachment objects for use in cred ex messages.""" @abstractclassmethod @@ -81,7 +83,7 @@ async def receive_proposal( @abstractmethod async def create_offer( - self, cred_proposal_message: V20CredProposal + self, cred_proposal_message: V20CredProposal, attach_id: str = None ) -> CredFormatAttachment: """Create format specific credential offer attachment data.""" @@ -93,7 +95,11 @@ async def receive_offer( @abstractmethod async def create_request( - self, cred_ex_record: V20CredExRecord, request_data: Mapping = None + self, + cred_ex_record: V20CredExRecord, + request_data: Mapping = None, + attach_id: str = None, + init_cred_req_flow: bool = False, ) -> CredFormatAttachment: """Create format specific credential request attachment data.""" @@ -105,18 +111,24 @@ async def receive_request( @abstractmethod async def issue_credential( - self, cred_ex_record: V20CredExRecord, retries: int = 5 + self, cred_ex_record: V20CredExRecord, retries: int = 5, attach_id: str = None ) -> CredFormatAttachment: """Create format specific issue credential attachment data.""" @abstractmethod async def receive_credential( - self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue + self, + cred_ex_record: V20CredExRecord, + cred_issue_message: V20CredIssue, + attach_id: str = None, ) -> None: """Create format specific issue credential message.""" @abstractmethod async def store_credential( - self, cred_ex_record: V20CredExRecord, cred_id: str = None + self, + cred_ex_record: V20CredExRecord, + cred_id: str = None, + attach_id: str = None, ) -> None: """Store format specific credential from issue credential message.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py index 7ef901260f..541dcafed7 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/handler.py @@ -4,7 +4,7 @@ from marshmallow import RAISE import json -from typing import Mapping, Tuple +from typing import Mapping, Tuple, Sequence, Optional import asyncio from ......cache.base import BaseCache @@ -87,22 +87,24 @@ def validate_fields(cls, message_type: str, attachment_data: Mapping): # Validate, throw if not valid Schema(unknown=RAISE).load(attachment_data) - async def get_detail_record(self, cred_ex_id: str) -> V20CredExRecordIndy: + async def get_detail_record( + self, cred_ex_id: str + ) -> Optional[Sequence[V20CredExRecordIndy]]: """Retrieve credential exchange detail record by cred_ex_id.""" async with self.profile.session() as session: records = await IndyCredFormatHandler.format.detail.query_by_cred_ex_id( session, cred_ex_id ) - - if len(records) > 1: - LOGGER.warning( - "Cred ex id %s has %d %s detail records: should be 1", - cred_ex_id, - len(records), - IndyCredFormatHandler.format.api, - ) - return records[0] if records else None + # Not valid with multi-cred feature + # if len(records) > 1: + # LOGGER.warning( + # "Cred ex id %s has %d %s detail records: should be 1", + # cred_ex_id, + # len(records), + # IndyCredFormatHandler.format.api, + # ) + return records if records else None async def _check_uniqueness(self, cred_ex_id: str): """Raise exception on evidence that cred ex already has cred issued to it.""" @@ -110,7 +112,8 @@ async def _check_uniqueness(self, cred_ex_id: str): exist = await IndyCredFormatHandler.format.detail.query_by_cred_ex_id( session, cred_ex_id ) - if exist: + cred_ex_record = await V20CredExRecord.retrieve_by_id(session, cred_ex_id) + if exist and not cred_ex_record.multiple_credentials: raise V20CredFormatError( f"{IndyCredFormatHandler.format.api} detail record already " f"exists for cred ex id {cred_ex_id}" @@ -128,7 +131,9 @@ def get_format_identifier(self, message_type: str) -> str: """ return ATTACHMENT_FORMAT[message_type][IndyCredFormatHandler.format.api] - def get_format_data(self, message_type: str, data: dict) -> CredFormatAttachment: + def get_format_data( + self, message_type: str, data: dict, attach_id: str = None + ) -> CredFormatAttachment: """Get credential format and attachment objects for use in cred ex messages. Returns a tuple of both credential format and attachment decorator for use @@ -146,10 +151,12 @@ def get_format_data(self, message_type: str, data: dict) -> CredFormatAttachment """ return ( V20CredFormat( - attach_id=IndyCredFormatHandler.format.api, + attach_id=attach_id or IndyCredFormatHandler.format.api, format_=self.get_format_identifier(message_type), ), - AttachDecorator.data_base64(data, ident=IndyCredFormatHandler.format.api), + AttachDecorator.data_base64( + data, ident=attach_id or IndyCredFormatHandler.format.api + ), ) async def _match_sent_cred_def_id(self, tag_query: Mapping[str, str]) -> str: @@ -184,17 +191,21 @@ async def receive_proposal( """ async def create_offer( - self, cred_proposal_message: V20CredProposal + self, cred_proposal_message: V20CredProposal, attach_id: str = None ) -> CredFormatAttachment: """Create indy credential offer.""" issuer = self.profile.inject(IndyIssuer) ledger = self.profile.inject(BaseLedger) cache = self.profile.inject_or(BaseCache) - - cred_def_id = await self._match_sent_cred_def_id( - cred_proposal_message.attachment(IndyCredFormatHandler.format) - ) + if attach_id: + cred_def_id = await self._match_sent_cred_def_id( + cred_proposal_message.attachment_by_id(attach_id) + ) + else: + cred_def_id = await self._match_sent_cred_def_id( + cred_proposal_message.attachment(IndyCredFormatHandler.format) + ) async def _create(): offer_json = await issuer.create_credential_offer(cred_def_id) @@ -215,9 +226,17 @@ async def _create(): schema_id = await ledger.credential_definition_id2schema_id(cred_def_id) schema = await ledger.get_schema(schema_id) schema_attrs = {attr for attr in schema["attrNames"]} - preview_attrs = { - attr for attr in cred_proposal_message.credential_preview.attr_dict() - } + if attach_id: + preview_attrs = { + attr + for attr in cred_proposal_message.credential_preview.attr_dict( + attach_id=attach_id + ) + } + else: + preview_attrs = { + attr for attr in cred_proposal_message.credential_preview.attr_dict() + } if preview_attrs != schema_attrs: raise V20CredFormatError( f"Preview attributes {preview_attrs} " @@ -237,15 +256,21 @@ async def _create(): if not cred_offer: cred_offer = await _create() - return self.get_format_data(CRED_20_OFFER, cred_offer) + return self.get_format_data(CRED_20_OFFER, cred_offer, attach_id) async def receive_offer( - self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer + self, + cred_ex_record: V20CredExRecord, + cred_offer_message: V20CredOffer, ) -> None: """Receive indy credential offer.""" async def create_request( - self, cred_ex_record: V20CredExRecord, request_data: Mapping = None + self, + cred_ex_record: V20CredExRecord, + request_data: Mapping = None, + attach_id: str = None, + init_cred_req_flow: bool = False, ) -> CredFormatAttachment: """Create indy credential request.""" if cred_ex_record.state != V20CredExRecord.STATE_OFFER_RECEIVED: @@ -256,7 +281,12 @@ async def create_request( await self._check_uniqueness(cred_ex_record.cred_ex_id) holder_did = request_data.get("holder_did") if request_data else None - cred_offer = cred_ex_record.cred_offer.attachment(IndyCredFormatHandler.format) + if attach_id: + cred_offer = cred_ex_record.cred_offer.attachment_by_id(attach_id) + else: + cred_offer = cred_ex_record.cred_offer.attachment( + IndyCredFormatHandler.format + ) if "nonce" not in cred_offer: raise V20CredFormatError("Missing nonce in credential offer") @@ -305,15 +335,20 @@ async def _create(): detail_record = V20CredExRecordIndy( cred_ex_id=cred_ex_record.cred_ex_id, cred_request_metadata=cred_req_result["metadata"], + attach_id=attach_id, ) async with self.profile.session() as session: await detail_record.save(session, reason="create v2.0 credential request") - return self.get_format_data(CRED_20_REQUEST, cred_req_result["request"]) + return self.get_format_data( + CRED_20_REQUEST, cred_req_result["request"], attach_id + ) async def receive_request( - self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest + self, + cred_ex_record: V20CredExRecord, + cred_request_message: V20CredRequest, ) -> None: """Receive indy credential request.""" if not cred_ex_record.cred_offer: @@ -322,18 +357,32 @@ async def receive_request( ) async def issue_credential( - self, cred_ex_record: V20CredExRecord, retries: int = 5 + self, + cred_ex_record: V20CredExRecord, + retries: int = 5, + attach_id: str = None, ) -> CredFormatAttachment: """Issue indy credential.""" await self._check_uniqueness(cred_ex_record.cred_ex_id) - cred_offer = cred_ex_record.cred_offer.attachment(IndyCredFormatHandler.format) - cred_request = cred_ex_record.cred_request.attachment( - IndyCredFormatHandler.format - ) - cred_values = cred_ex_record.cred_offer.credential_preview.attr_dict( - decode=False - ) + if attach_id: + cred_offer = cred_ex_record.cred_offer.attachment_by_id(attach_id) + cred_request = cred_ex_record.cred_request.attachment_by_id(attach_id) + else: + cred_offer = cred_ex_record.cred_offer.attachment( + IndyCredFormatHandler.format + ) + cred_request = cred_ex_record.cred_request.attachment( + IndyCredFormatHandler.format + ) + if attach_id: + cred_values = cred_ex_record.cred_offer.credential_preview.attr_dict( + decode=False, attach_id=attach_id + ) + else: + cred_values = cred_ex_record.cred_offer.credential_preview.attr_dict( + decode=False + ) schema_id = cred_offer["schema_id"] cred_def_id = cred_offer["cred_def_id"] @@ -394,7 +443,9 @@ async def issue_credential( await revoc.handle_full_registry(rev_reg_id) del revoc - result = self.get_format_data(CRED_20_ISSUE, json.loads(cred_json)) + result = self.get_format_data( + CRED_20_ISSUE, json.loads(cred_json), attach_id + ) break if not result: @@ -407,6 +458,7 @@ async def issue_credential( cred_ex_id=cred_ex_record.cred_ex_id, rev_reg_id=rev_reg_id, cred_rev_id=cred_rev_id, + attach_id=attach_id, ) await detail_record.save(txn, reason="v2.0 issue credential") @@ -430,7 +482,10 @@ async def issue_credential( return result async def receive_credential( - self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue + self, + cred_ex_record: V20CredExRecord, + cred_issue_message: V20CredIssue, + attach_id: str = None, ) -> None: """Receive indy credential. @@ -438,10 +493,16 @@ async def receive_credential( """ async def store_credential( - self, cred_ex_record: V20CredExRecord, cred_id: str = None + self, + cred_ex_record: V20CredExRecord, + cred_id: str = None, + attach_id: str = None, ) -> None: """Store indy credential.""" - cred = cred_ex_record.cred_issue.attachment(IndyCredFormatHandler.format) + if attach_id: + cred = cred_ex_record.cred_issue.attachment_by_id(attach_id) + else: + cred = cred_ex_record.cred_issue.attachment(IndyCredFormatHandler.format) rev_reg_def = None multitenant_mgr = self.profile.inject_or(BaseMultitenantManager) @@ -463,14 +524,31 @@ async def store_credential( holder = self.profile.inject(IndyHolder) cred_offer_message = cred_ex_record.cred_offer mime_types = None - if cred_offer_message and cred_offer_message.credential_preview: - mime_types = cred_offer_message.credential_preview.mime_types() or None + if cred_offer_message and cred_offer_message.credential_preview is not None: + if attach_id: + mime_types = ( + cred_offer_message.credential_preview.mime_types(attach_id) or None + ) + else: + mime_types = cred_offer_message.credential_preview.mime_types() or None if rev_reg_def: rev_reg = RevocationRegistry.from_definition(rev_reg_def, True) await rev_reg.get_or_fetch_local_tails_path() try: - detail_record = await self.get_detail_record(cred_ex_record.cred_ex_id) + detail_records = await self.get_detail_record(cred_ex_record.cred_ex_id) + detail_record = None + if detail_records and attach_id: + detail_record = next( + ( + record + for record in detail_records + if record.attach_id == attach_id + ), + None, + ) + elif detail_records: + detail_record = detail_records[0] if detail_record is None: raise V20CredFormatError( f"No credential exchange {IndyCredFormatHandler.format.aries} " diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py index 06028fa00e..4991b4f153 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/indy/tests/test_handler.py @@ -294,24 +294,45 @@ async def test_get_indy_detail_record(self): with async_mock.patch.object( INDY_LOGGER, "warning", async_mock.MagicMock() ) as mock_warning: - assert await self.handler.get_detail_record(cred_ex_id) in details_indy - mock_warning.assert_called_once() + assert await self.handler.get_detail_record(cred_ex_id) == details_indy async def test_check_uniqueness(self): with async_mock.patch.object( self.handler.format.detail, "query_by_cred_ex_id", async_mock.CoroutineMock(), - ) as mock_indy_query: + ) as mock_indy_query, async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: mock_indy_query.return_value = [] + mock_retrieve_by_id.return_value = V20CredExRecord( + cred_ex_id="dummy-cxid", + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + multiple_credentials=True, + ) await self.handler._check_uniqueness("dummy-cx-id") with async_mock.patch.object( self.handler.format.detail, "query_by_cred_ex_id", async_mock.CoroutineMock(), - ) as mock_indy_query: + ) as mock_indy_query, async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: mock_indy_query.return_value = [async_mock.MagicMock()] + mock_retrieve_by_id.return_value = V20CredExRecord( + cred_ex_id="dummy-cxid", + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + multiple_credentials=False, + ) with self.assertRaises(V20CredFormatError) as context: await self.handler._check_uniqueness("dummy-cx-id") assert "detail record already exists" in str(context.exception) @@ -400,18 +421,81 @@ async def test_create_offer(self): self.issuer.create_credential_offer.assert_called_once_with(CRED_DEF_ID) - # assert identifier match - assert cred_format.attach_id == self.handler.format.api == attachment.ident + async def test_create_offer_multiple_cred_flow(self): + schema_id_parts = SCHEMA_ID.split(":") - # assert content of attachment is proposal data - assert attachment.content == INDY_OFFER + cred_preview = V20CredPreview( + attributes_dict={ + "indy-0": [ + V20CredAttrSpec(name="legalName", value="value1"), + V20CredAttrSpec(name="jurisdictionId", value="value1"), + V20CredAttrSpec(name="incorporationDate", value="value1"), + ], + "indy-1": [ + V20CredAttrSpec(name="legalName", value="value2"), + V20CredAttrSpec(name="jurisdictionId", value="value2"), + V20CredAttrSpec(name="incorporationDate", value="value2"), + ], + } + ) - # assert data is encoded as base64 - assert attachment.data.base64 + cred_proposal = V20CredProposal( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + ], + filters_attach=[ + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID}, ident="indy-0" + ), + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID}, ident="indy-1" + ), + ], + ) - self.issuer.create_credential_offer.reset_mock() - (cred_format, attachment) = await self.handler.create_offer(cred_proposal) - self.issuer.create_credential_offer.assert_not_called() + cred_def_record = StorageRecord( + CRED_DEF_SENT_RECORD_TYPE, + CRED_DEF_ID, + { + "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": TEST_DID, + "cred_def_id": CRED_DEF_ID, + "epoch": str(int(time())), + }, + ) + await self.session.storage.add_record(cred_def_record) + + self.issuer.create_credential_offer = async_mock.CoroutineMock( + return_value=json.dumps(INDY_OFFER) + ) + + (cred_format, attachment) = await self.handler.create_offer( + cred_proposal, "indy-0" + ) + assert cred_format.attach_id == "indy-0" + assert attachment.content == INDY_OFFER + assert attachment.data.base64 + (cred_format, attachment) = await self.handler.create_offer( + cred_proposal, "indy-1" + ) + assert cred_format.attach_id == "indy-1" + assert attachment.content == INDY_OFFER + assert attachment.data.base64 async def test_create_offer_no_cache(self): schema_id_parts = SCHEMA_ID.split(":") @@ -559,6 +643,68 @@ async def test_receive_offer(self): # Not much to assert. Receive offer doesn't do anything await self.handler.receive_offer(cred_ex_record, cred_offer_message) + async def test_create_request_multiple_cred_flow(self): + holder_did = "did" + + cred_offer = V20CredOffer( + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + ], + offers_attach=[ + AttachDecorator.data_base64(INDY_OFFER, ident="indy-0"), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-1"), + ], + ) + cred_ex_record = V20CredExRecord( + cred_ex_id="dummy-id", + state=V20CredExRecord.STATE_OFFER_RECEIVED, + cred_offer=cred_offer.serialize(), + multiple_credentials=True, + ) + + cred_def = {"cred": "def"} + self.ledger.get_credential_definition = async_mock.CoroutineMock( + return_value=cred_def + ) + + cred_req_meta = {} + self.holder.create_credential_request = async_mock.CoroutineMock( + return_value=(json.dumps(INDY_CRED_REQ), json.dumps(cred_req_meta)) + ) + with async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: + mock_retrieve_by_id.return_value = cred_ex_record + + (cred_format, attachment) = await self.handler.create_request( + cred_ex_record, {"holder_did": holder_did}, "indy-0" + ) + + assert cred_format.attach_id == "indy-0" + assert attachment.content == INDY_CRED_REQ + assert attachment.data.base64 + + (cred_format, attachment) = await self.handler.create_request( + cred_ex_record, {"holder_did": holder_did}, "indy-1" + ) + + assert cred_format.attach_id == "indy-1" + assert attachment.content == INDY_CRED_REQ + assert attachment.data.base64 + async def test_create_request(self): holder_did = "did" @@ -588,44 +734,52 @@ async def test_create_request(self): self.holder.create_credential_request = async_mock.CoroutineMock( return_value=(json.dumps(INDY_CRED_REQ), json.dumps(cred_req_meta)) ) + with async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: + mock_retrieve_by_id.return_value = cred_ex_record - (cred_format, attachment) = await self.handler.create_request( - cred_ex_record, {"holder_did": holder_did} - ) - - self.holder.create_credential_request.assert_called_once_with( - INDY_OFFER, cred_def, holder_did - ) + (cred_format, attachment) = await self.handler.create_request( + cred_ex_record, {"holder_did": holder_did} + ) - # assert identifier match - assert cred_format.attach_id == self.handler.format.api == attachment.ident + self.holder.create_credential_request.assert_called_once_with( + INDY_OFFER, cred_def, holder_did + ) - # assert content of attachment is proposal data - assert attachment.content == INDY_CRED_REQ + # assert identifier match + assert cred_format.attach_id == self.handler.format.api == attachment.ident - # assert data is encoded as base64 - assert attachment.data.base64 + # assert content of attachment is proposal data + assert attachment.content == INDY_CRED_REQ - # cover case with cache (change ID to prevent already exists error) - cred_ex_record._id = "dummy-id2" - await self.handler.create_request(cred_ex_record, {"holder_did": holder_did}) + # assert data is encoded as base64 + assert attachment.data.base64 - # cover case with no cache in injection context - self.context.injector.clear_binding(BaseCache) - cred_ex_record._id = "dummy-id3" - self.context.injector.bind_instance( - BaseMultitenantManager, - async_mock.MagicMock(MultitenantManager, autospec=True), - ) - with async_mock.patch.object( - IndyLedgerRequestsExecutor, - "get_ledger_for_identifier", - async_mock.CoroutineMock(return_value=(None, self.ledger)), - ): + # cover case with cache (change ID to prevent already exists error) + cred_ex_record._id = "dummy-id2" await self.handler.create_request( cred_ex_record, {"holder_did": holder_did} ) + # cover case with no cache in injection context + self.context.injector.clear_binding(BaseCache) + cred_ex_record._id = "dummy-id3" + self.context.injector.bind_instance( + BaseMultitenantManager, + async_mock.MagicMock(MultitenantManager, autospec=True), + ) + with async_mock.patch.object( + IndyLedgerRequestsExecutor, + "get_ledger_for_identifier", + async_mock.CoroutineMock(return_value=(None, self.ledger)), + ): + await self.handler.create_request( + cred_ex_record, {"holder_did": holder_did} + ) + async def test_create_request_bad_state(self): cred_ex_record = V20CredExRecord(state=V20CredExRecord.STATE_OFFER_SENT) @@ -730,7 +884,12 @@ async def test_issue_credential_revocable(self): with async_mock.patch.object( test_module, "IndyRevocation", autospec=True - ) as revoc: + ) as revoc, async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: + mock_retrieve_by_id.return_value = cred_ex_record revoc.return_value.get_or_create_active_registry = async_mock.CoroutineMock( return_value=( async_mock.MagicMock( # active_rev_reg_rec @@ -826,7 +985,12 @@ async def test_issue_credential_non_revocable(self): IndyLedgerRequestsExecutor, "get_ledger_for_identifier", async_mock.CoroutineMock(return_value=("test_ledger_id", self.ledger)), - ): + ), async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: + mock_retrieve_by_id.return_value = cred_ex_record (cred_format, attachment) = await self.handler.issue_credential( cred_ex_record, retries=0 ) @@ -916,7 +1080,12 @@ async def test_issue_credential_no_active_rr_no_retries(self): with async_mock.patch.object( test_module, "IndyRevocation", autospec=True - ) as revoc: + ) as revoc, async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: + mock_retrieve_by_id.return_value = cred_ex_record revoc.return_value.get_or_create_active_registry = async_mock.CoroutineMock( return_value=() ) @@ -976,7 +1145,12 @@ async def test_issue_credential_no_active_rr_retry(self): with async_mock.patch.object( test_module, "IndyRevocation", autospec=True - ) as revoc: + ) as revoc, async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: + mock_retrieve_by_id.return_value = cred_ex_record revoc.return_value.get_or_create_active_registry = async_mock.CoroutineMock( side_effect=[ None, @@ -997,6 +1171,112 @@ async def test_issue_credential_no_active_rr_retry(self): await self.handler.issue_credential(cred_ex_record, retries=1) assert "has no active revocation registry" in str(context.exception) + async def test_issue_credential_multiple_cred_flow(self): + CRED_DEF_NR = deepcopy(CRED_DEF) + CRED_DEF_NR["value"]["revocation"] = None + + cred_preview = V20CredPreview( + attributes_dict={ + "indy-0": [ + V20CredAttrSpec(name="legalName", value="value1"), + V20CredAttrSpec(name="jurisdictionId", value="value1"), + V20CredAttrSpec(name="incorporationDate", value="value1"), + ], + "indy-1": [ + V20CredAttrSpec(name="legalName", value="value2"), + V20CredAttrSpec(name="jurisdictionId", value="value2"), + V20CredAttrSpec(name="incorporationDate", value="value2"), + ], + } + ) + cred_offer = V20CredOffer( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + ], + offers_attach=[ + AttachDecorator.data_base64(INDY_OFFER, ident="indy-0"), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-1"), + ], + ) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-0"), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-1"), + ], + ) + + cred_ex_record = V20CredExRecord( + cred_ex_id="dummy-cxid", + cred_offer=cred_offer.serialize(), + cred_request=cred_request.serialize(), + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + multiple_credentials=True, + ) + + self.issuer.create_credential = async_mock.CoroutineMock( + return_value=(json.dumps(INDY_CRED), None) + ) + self.ledger.get_credential_definition = async_mock.CoroutineMock( + return_value=CRED_DEF_NR + ) + self.context.injector.bind_instance( + BaseMultitenantManager, + async_mock.MagicMock(MultitenantManager, autospec=True), + ) + with async_mock.patch.object( + IndyLedgerRequestsExecutor, + "get_ledger_for_identifier", + async_mock.CoroutineMock(return_value=("test_ledger_id", self.ledger)), + ), async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: + mock_retrieve_by_id.return_value = cred_ex_record + (cred_format, attachment) = await self.handler.issue_credential( + cred_ex_record, retries=0, attach_id="indy-0" + ) + + assert cred_format.attach_id == "indy-0" + assert attachment.content == INDY_CRED + assert attachment.data.base64 + + (cred_format, attachment) = await self.handler.issue_credential( + cred_ex_record, retries=0, attach_id="indy-1" + ) + + assert cred_format.attach_id == "indy-1" + assert attachment.content == INDY_CRED + assert attachment.data.base64 + async def test_issue_credential_rr_full(self): attr_values = { "legalName": "value", @@ -1048,7 +1328,12 @@ async def test_issue_credential_rr_full(self): ) with async_mock.patch.object( test_module, "IndyRevocation", autospec=True - ) as revoc: + ) as revoc, async_mock.patch.object( + test_module.V20CredExRecord, + "retrieve_by_id", + async_mock.CoroutineMock(), + ) as mock_retrieve_by_id: + mock_retrieve_by_id.return_value = cred_ex_record revoc.return_value.get_or_create_active_registry = async_mock.CoroutineMock( return_value=( async_mock.MagicMock( # active_rev_reg_rec @@ -1073,6 +1358,170 @@ async def test_receive_credential(self): # Not much to assert. Receive credential doesn't do anything await self.handler.receive_credential(cred_ex_record, cred_issue_message) + async def test_store_credential_multiple_cred_flow(self): + connection_id = "test_conn_id" + cred_req_meta = {"req": "meta"} + thread_id = "thread-id" + + cred_preview = V20CredPreview( + attributes_dict={ + "indy-0": [ + V20CredAttrSpec(name="legalName", value="value1", mime_type=None), + V20CredAttrSpec( + name="jurisdictionId", value="value1", mime_type=None + ), + V20CredAttrSpec( + name="incorporationDate", value="value1", mime_type=None + ), + V20CredAttrSpec( + name="pic", value="cG90YXRv", mime_type="image/jpeg" + ), + ], + "indy-1": [ + V20CredAttrSpec(name="legalName", value="value2", mime_type=None), + V20CredAttrSpec( + name="jurisdictionId", value="value2", mime_type=None + ), + V20CredAttrSpec( + name="incorporationDate", value="value2", mime_type=None + ), + V20CredAttrSpec( + name="pic", value="cG90YXRv", mime_type="image/jpeg" + ), + ], + } + ) + cred_offer = V20CredOffer( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + ], + offers_attach=[ + AttachDecorator.data_base64(INDY_OFFER, ident="indy-0"), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-1"), + ], + ) + cred_offer.assign_thread_id(thread_id) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-0"), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-1"), + ], + ) + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + ], + credentials_attach=[ + AttachDecorator.data_base64(INDY_CRED, ident="indy-0"), + AttachDecorator.data_base64(INDY_CRED, ident="indy-1"), + ], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_offer=cred_offer.serialize(), + cred_request=cred_request.serialize(), + cred_issue=cred_issue.serialize(), + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_CREDENTIAL_RECEIVED, + thread_id=thread_id, + auto_remove=True, + multiple_credentials=True, + ) + + cred_id = "cred-id" + + self.holder.store_credential = async_mock.CoroutineMock(return_value=cred_id) + stored_cred = {"stored": "cred"} + self.holder.get_credential = async_mock.CoroutineMock( + return_value=json.dumps(stored_cred) + ) + + self.context.injector.bind_instance( + BaseMultitenantManager, + async_mock.MagicMock(MultitenantManager, autospec=True), + ) + with async_mock.patch.object( + IndyLedgerRequestsExecutor, + "get_ledger_for_identifier", + async_mock.CoroutineMock(return_value=("test_ledger_id", self.ledger)), + ), async_mock.patch.object( + test_module, "RevocationRegistry", autospec=True + ) as mock_rev_reg, async_mock.patch.object( + test_module.IndyCredFormatHandler, "get_detail_record", autospec=True + ) as mock_get_detail_record: + mock_rev_reg.from_definition = async_mock.MagicMock( + return_value=async_mock.MagicMock( + get_or_fetch_local_tails_path=async_mock.CoroutineMock() + ) + ) + mock_get_detail_record.return_value = [ + async_mock.MagicMock( + cred_request_metadata=cred_req_meta, + attach_id="indy-0", + save=async_mock.CoroutineMock(), + ) + ] + + self.ledger.get_credential_definition.reset_mock() + await self.handler.store_credential( + stored_cx_rec, cred_id=cred_id, attach_id="indy-0" + ) + mock_get_detail_record.return_value = [ + async_mock.MagicMock( + cred_request_metadata=cred_req_meta, + attach_id="indy-0", + save=async_mock.CoroutineMock(), + ), + async_mock.MagicMock( + cred_request_metadata=cred_req_meta, + attach_id="indy-1", + save=async_mock.CoroutineMock(), + ), + ] + await self.handler.store_credential( + stored_cx_rec, cred_id=cred_id, attach_id="indy-1" + ) + async def test_store_credential(self): connection_id = "test_conn_id" attr_values = { @@ -1176,10 +1625,12 @@ async def test_store_credential(self): get_or_fetch_local_tails_path=async_mock.CoroutineMock() ) ) - mock_get_detail_record.return_value = async_mock.MagicMock( - cred_request_metadata=cred_req_meta, - save=async_mock.CoroutineMock(), - ) + mock_get_detail_record.return_value = [ + async_mock.MagicMock( + cred_request_metadata=cred_req_meta, + save=async_mock.CoroutineMock(), + ) + ] self.ledger.get_credential_definition.reset_mock() await self.handler.store_credential(stored_cx_rec, cred_id=cred_id) @@ -1271,10 +1722,13 @@ async def test_store_credential_holder_store_indy_error(self): ) as mock_get_detail_record, async_mock.patch.object( test_module.RevocationRegistry, "from_definition", async_mock.MagicMock() ) as mock_rev_reg: - mock_get_detail_record.return_value = async_mock.MagicMock( - cred_request_metadata=cred_req_meta, - save=async_mock.CoroutineMock(), - ) + mock_get_detail_record.return_value = [ + async_mock.MagicMock( + cred_request_metadata=cred_req_meta, + attach_id="0", + save=async_mock.CoroutineMock(), + ) + ] mock_rev_reg.return_value = async_mock.MagicMock( get_or_fetch_local_tails_path=async_mock.CoroutineMock() ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py index fc850070b4..f08cfbaffa 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/handler.py @@ -5,7 +5,7 @@ from ......vc.ld_proofs.check import get_properties_without_context import logging -from typing import Mapping +from typing import Mapping, Sequence, Optional from marshmallow import EXCLUDE, INCLUDE @@ -121,22 +121,24 @@ def validate_fields(cls, message_type: str, attachment_data: Mapping) -> None: # Validate, throw if not valid Schema(unknown=EXCLUDE).load(attachment_data) - async def get_detail_record(self, cred_ex_id: str) -> V20CredExRecordLDProof: + async def get_detail_record( + self, cred_ex_id: str + ) -> Optional[Sequence[V20CredExRecordLDProof]]: """Retrieve credential exchange detail record by cred_ex_id.""" async with self.profile.session() as session: records = await LDProofCredFormatHandler.format.detail.query_by_cred_ex_id( session, cred_ex_id ) - - if len(records) > 1: - LOGGER.warning( - "Cred ex id %s has %d %s detail records: should be 1", - cred_ex_id, - len(records), - LDProofCredFormatHandler.format.api, - ) - return records[0] if records else None + # Not valid with multi-cred feature + # if len(records) > 1: + # LOGGER.warning( + # "Cred ex id %s has %d %s detail records: should be 1", + # cred_ex_id, + # len(records), + # LDProofCredFormatHandler.format.api, + # ) + return records if records else None def get_format_identifier(self, message_type: str) -> str: """Get attachment format identifier for format and message combination. @@ -150,7 +152,9 @@ def get_format_identifier(self, message_type: str) -> str: """ return ATTACHMENT_FORMAT[message_type][LDProofCredFormatHandler.format.api] - def get_format_data(self, message_type: str, data: dict) -> CredFormatAttachment: + def get_format_data( + self, message_type: str, data: dict, attach_id: str = None + ) -> CredFormatAttachment: """Get credential format and attachment objects for use in cred ex messages. Returns a tuple of both credential format and attachment decorator for use @@ -168,11 +172,11 @@ def get_format_data(self, message_type: str, data: dict) -> CredFormatAttachment """ return ( V20CredFormat( - attach_id=LDProofCredFormatHandler.format.api, + attach_id=attach_id or LDProofCredFormatHandler.format.api, format_=self.get_format_identifier(message_type), ), AttachDecorator.data_base64( - data, ident=LDProofCredFormatHandler.format.api + data, ident=attach_id or LDProofCredFormatHandler.format.api ), ) @@ -387,7 +391,7 @@ async def receive_proposal( """Receive linked data proof credential proposal.""" async def create_offer( - self, cred_proposal_message: V20CredProposal + self, cred_proposal_message: V20CredProposal, attach_id: str = None ) -> CredFormatAttachment: """Create linked data proof credential offer.""" if not cred_proposal_message: @@ -398,7 +402,12 @@ async def create_offer( # Parse offer data which is either a proposal or an offer. # Data is stored in proposal if we received a proposal # but also when we create an offer (manager does some weird stuff) - offer_data = cred_proposal_message.attachment(LDProofCredFormatHandler.format) + if attach_id: + offer_data = cred_proposal_message.attachment_by_id(attach_id) + else: + offer_data = cred_proposal_message.attachment( + LDProofCredFormatHandler.format + ) detail = LDProofVCDetail.deserialize(offer_data) detail = await self._prepare_detail(detail) @@ -418,7 +427,7 @@ async def create_offer( detail.credential.issuer_id, detail.options.proof_type ) - return self.get_format_data(CRED_20_OFFER, detail.serialize()) + return self.get_format_data(CRED_20_OFFER, detail.serialize(), attach_id) async def receive_offer( self, cred_ex_record: V20CredExRecord, cred_offer_message: V20CredOffer @@ -426,30 +435,45 @@ async def receive_offer( """Receive linked data proof credential offer.""" async def create_request( - self, cred_ex_record: V20CredExRecord, request_data: Mapping = None + self, + cred_ex_record: V20CredExRecord, + request_data: Mapping = None, + attach_id: str = None, + init_cred_req_flow: bool = False, ) -> CredFormatAttachment: """Create linked data proof credential request.""" holder_did = request_data.get("holder_did") if request_data else None - if cred_ex_record.cred_offer: - request_data = cred_ex_record.cred_offer.attachment( - LDProofCredFormatHandler.format - ) - # API data is stored in proposal (when starting from request) - # It is a bit of a strage flow IMO. - elif cred_ex_record.cred_proposal: - request_data = cred_ex_record.cred_proposal.attachment( - LDProofCredFormatHandler.format - ) + if attach_id and request_data and attach_id in request_data: + request_data = request_data.get(attach_id) else: - raise V20CredFormatError( - "Cannot create linked data proof request without offer or input data" - ) + if cred_ex_record.cred_offer: + if attach_id: + request_data = cred_ex_record.cred_offer.attachment_by_id(attach_id) + else: + request_data = cred_ex_record.cred_offer.attachment( + LDProofCredFormatHandler.format + ) + # API data is stored in proposal (when starting from request) + # It is a bit of a strage flow IMO. + elif cred_ex_record.cred_proposal: + if attach_id and not init_cred_req_flow: + request_data = cred_ex_record.cred_proposal.attachment_by_id( + attach_id + ) + else: + request_data = cred_ex_record.cred_proposal.attachment( + LDProofCredFormatHandler.format + ) + else: + raise V20CredFormatError( + "Cannot create linked data proof request without offer or input data" + ) detail = LDProofVCDetail.deserialize(request_data) detail = await self._prepare_detail(detail, holder_did=holder_did) - return self.get_format_data(CRED_20_REQUEST, detail.serialize()) + return self.get_format_data(CRED_20_REQUEST, detail.serialize(), attach_id) async def receive_request( self, cred_ex_record: V20CredExRecord, cred_request_message: V20CredRequest @@ -457,7 +481,10 @@ async def receive_request( """Receive linked data proof request.""" async def issue_credential( - self, cred_ex_record: V20CredExRecord, retries: int = 5 + self, + cred_ex_record: V20CredExRecord, + retries: int = 5, + attach_id: str = None, ) -> CredFormatAttachment: """Issue linked data proof credential.""" if not cred_ex_record.cred_request: @@ -465,9 +492,12 @@ async def issue_credential( "Cannot issue credential without credential request" ) - detail_dict = cred_ex_record.cred_request.attachment( - LDProofCredFormatHandler.format - ) + if attach_id: + detail_dict = cred_ex_record.cred_request.attachment_by_id(attach_id) + else: + detail_dict = cred_ex_record.cred_request.attachment( + LDProofCredFormatHandler.format + ) detail = LDProofVCDetail.deserialize(detail_dict) detail = await self._prepare_detail(detail) @@ -488,17 +518,32 @@ async def issue_credential( purpose=proof_purpose, ) - return self.get_format_data(CRED_20_ISSUE, vc) + detail_record = V20CredExRecordLDProof( + cred_ex_id=cred_ex_record.cred_ex_id, + attach_id=attach_id, + ) + async with self.profile.session() as session: + await detail_record.save( + session, reason="create V20CredExRecordLDProof detail record" + ) + + return self.get_format_data(CRED_20_ISSUE, vc, attach_id) async def receive_credential( - self, cred_ex_record: V20CredExRecord, cred_issue_message: V20CredIssue + self, + cred_ex_record: V20CredExRecord, + cred_issue_message: V20CredIssue, + attach_id: str = None, ) -> None: """Receive linked data proof credential.""" - cred_dict = cred_issue_message.attachment(LDProofCredFormatHandler.format) - detail_dict = cred_ex_record.cred_request.attachment( - LDProofCredFormatHandler.format - ) - + if attach_id: + cred_dict = cred_issue_message.attachment_by_id(attach_id) + detail_dict = cred_ex_record.cred_request.attachment_by_id(attach_id) + else: + cred_dict = cred_issue_message.attachment(LDProofCredFormatHandler.format) + detail_dict = cred_ex_record.cred_request.attachment( + LDProofCredFormatHandler.format + ) vc = VerifiableCredential.deserialize(cred_dict, unknown=INCLUDE) detail = LDProofVCDetail.deserialize(detail_dict) @@ -559,13 +604,19 @@ async def receive_credential( ) async def store_credential( - self, cred_ex_record: V20CredExRecord, cred_id: str = None + self, + cred_ex_record: V20CredExRecord, + cred_id: str = None, + attach_id: str = None, ) -> None: """Store linked data proof credential.""" # Get attachment data - cred_dict: dict = cred_ex_record.cred_issue.attachment( - LDProofCredFormatHandler.format - ) + if attach_id: + cred_dict: dict = cred_ex_record.cred_issue.attachment_by_id(attach_id) + else: + cred_dict: dict = cred_ex_record.cred_issue.attachment( + LDProofCredFormatHandler.format + ) # Deserialize objects credential = VerifiableCredential.deserialize(cred_dict, unknown=INCLUDE) @@ -614,7 +665,9 @@ async def store_credential( # Create detail record with cred_id_stored detail_record = V20CredExRecordLDProof( - cred_ex_id=cred_ex_record.cred_ex_id, cred_id_stored=vc_record.record_id + cred_ex_id=cred_ex_record.cred_ex_id, + cred_id_stored=vc_record.record_id, + attach_id=attach_id, ) # save credential and detail record diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/tests/test_handler.py b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/tests/test_handler.py index f0e11070e6..e731d32878 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/tests/test_handler.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/formats/ld_proof/tests/test_handler.py @@ -187,8 +187,7 @@ async def test_get_ld_proof_detail_record(self): with async_mock.patch.object( LD_PROOF_LOGGER, "warning", async_mock.MagicMock() ) as mock_warning: - assert await self.handler.get_detail_record(cred_ex_id) in details_ld_proof - mock_warning.assert_called_once() + assert await self.handler.get_detail_record(cred_ex_id) == details_ld_proof async def test_assert_can_issue_with_id_and_proof_type(self): with self.assertRaises(V20CredFormatError) as context: @@ -428,6 +427,48 @@ async def test_create_offer(self): # assert data is encoded as base64 assert attachment.data.base64 + async def test_create_offer_multiple_cred_offer(self): + cred_proposal = V20CredProposal( + formats=[ + V20CredFormat( + attach_id="ld_proof-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + V20CredFormat( + attach_id="ld_proof-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + ], + filters_attach=[ + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-0"), + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-1"), + ], + ) + with async_mock.patch.object( + LDProofCredFormatHandler, + "_assert_can_issue_with_id_and_proof_type", + async_mock.CoroutineMock(), + ) as mock_can_issue, patch.object( + test_module, "get_properties_without_context", return_value=[] + ): + (cred_format, attachment) = await self.handler.create_offer( + cred_proposal, "ld_proof-0" + ) + assert cred_format.attach_id == "ld_proof-0" + assert attachment.content == LD_PROOF_VC_DETAIL + assert attachment.data.base64 + + (cred_format, attachment) = await self.handler.create_offer( + cred_proposal, "ld_proof-1" + ) + assert cred_format.attach_id == "ld_proof-1" + assert attachment.content == LD_PROOF_VC_DETAIL + assert attachment.data.base64 + async def test_create_offer_adds_bbs_context(self): cred_proposal = V20CredProposal( formats=[ @@ -517,6 +558,131 @@ async def test_create_bound_request(self): # assert data is encoded as base64 assert attachment.data.base64 + async def test_create_request_from_cred_spec(self): + cred_proposal = V20CredProposal( + formats=[ + V20CredFormat( + attach_id="ld_proof-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + V20CredFormat( + attach_id="ld_proof-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + ], + filters_attach=[ + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-0"), + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-1"), + ], + ) + cred_ex_record = V20CredExRecord( + cred_ex_id="dummy-id", + state=V20CredExRecord.STATE_OFFER_RECEIVED, + ) + + (cred_format, attachment) = await self.handler.create_request( + cred_ex_record=cred_ex_record, + request_data={"ld_proof-0": cred_proposal.attachment_by_id("ld_proof-0")}, + attach_id="ld_proof-0", + ) + + # assert identifier match + assert cred_format.attach_id == attachment.ident + + # assert content of attachment is proposal data + assert attachment.content == LD_PROOF_VC_DETAIL + + # assert data is encoded as base64 + assert attachment.data.base64 + + async def test_create_bound_request_multiple_cred_flow_from_offer(self): + cred_offer = V20CredOffer( + formats=[ + V20CredFormat( + attach_id="ld_proof-0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + V20CredFormat( + attach_id="ld_proof-1", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + ], + offers_attach=[ + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-0"), + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-1"), + ], + ) + cred_ex_record = V20CredExRecord( + cred_ex_id="dummy-id", + state=V20CredExRecord.STATE_OFFER_RECEIVED, + cred_offer=cred_offer, + multiple_credentials=True, + ) + + (cred_format, attachment) = await self.handler.create_request( + cred_ex_record=cred_ex_record, attach_id="ld_proof-0" + ) + assert cred_format.attach_id == "ld_proof-0" + assert attachment.content == LD_PROOF_VC_DETAIL + assert attachment.data.base64 + + (cred_format, attachment) = await self.handler.create_request( + cred_ex_record=cred_ex_record, attach_id="ld_proof-1" + ) + assert cred_format.attach_id == "ld_proof-1" + assert attachment.content == LD_PROOF_VC_DETAIL + assert attachment.data.base64 + + async def test_create_bound_request_multiple_cred_flow_from_proposal(self): + cred_proposal = V20CredProposal( + formats=[ + V20CredFormat( + attach_id="ld_proof-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + V20CredFormat( + attach_id="ld_proof-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + ], + filters_attach=[ + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-0"), + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-1"), + ], + ) + cred_ex_record = V20CredExRecord( + cred_ex_id="dummy-id", + state=V20CredExRecord.STATE_OFFER_RECEIVED, + cred_proposal=cred_proposal, + multiple_credentials=True, + ) + + (cred_format, attachment) = await self.handler.create_request( + cred_ex_record=cred_ex_record, attach_id="ld_proof-0" + ) + assert cred_format.attach_id == "ld_proof-0" + assert attachment.content == LD_PROOF_VC_DETAIL + assert attachment.data.base64 + + (cred_format, attachment) = await self.handler.create_request( + cred_ex_record=cred_ex_record, attach_id="ld_proof-1" + ) + assert cred_format.attach_id == "ld_proof-1" + assert attachment.content == LD_PROOF_VC_DETAIL + assert attachment.data.base64 + async def test_create_free_request(self): cred_ex_record = V20CredExRecord( cred_ex_id="dummy-id", @@ -552,6 +718,61 @@ async def test_receive_request(self): # Not much to assert. Receive request doesn't do anything await self.handler.receive_request(cred_ex_record, cred_request_message) + async def test_issue_credential_multiple_cred_flow(self): + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="ld_proof-0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + V20CredFormat( + attach_id="ld_proof-1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-0"), + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-1"), + ], + ) + + cred_ex_record = V20CredExRecord( + cred_ex_id="dummy-cxid", + cred_request=cred_request, + ) + + with async_mock.patch.object( + LDProofCredFormatHandler, + "_get_suite_for_detail", + async_mock.CoroutineMock(), + ) as mock_get_suite, async_mock.patch.object( + test_module, "issue", async_mock.CoroutineMock(return_value=LD_PROOF_VC) + ) as mock_issue, async_mock.patch.object( + LDProofCredFormatHandler, + "_get_proof_purpose", + ) as mock_get_proof_purpose: + (cred_format, attachment) = await self.handler.issue_credential( + cred_ex_record=cred_ex_record, attach_id="ld_proof-0" + ) + detail = LDProofVCDetail.deserialize(LD_PROOF_VC_DETAIL) + mock_get_suite.assert_called_with(detail) + assert cred_format.attach_id == "ld_proof-0" + assert attachment.content == LD_PROOF_VC + assert attachment.data.base64 + + (cred_format, attachment) = await self.handler.issue_credential( + cred_ex_record=cred_ex_record, attach_id="ld_proof-1" + ) + detail = LDProofVCDetail.deserialize(LD_PROOF_VC_DETAIL) + mock_get_suite.assert_called_with(detail) + assert cred_format.attach_id == "ld_proof-1" + assert attachment.content == LD_PROOF_VC + assert attachment.data.base64 + async def test_issue_credential(self): cred_request = V20CredRequest( formats=[ @@ -658,6 +879,55 @@ async def test_issue_credential_x_no_data(self): context.exception ) + async def test_receive_credential_multiple_cred_flow(self): + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="ld_proof-0", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + V20CredFormat( + attach_id="ld_proof-1", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + ], + credentials_attach=[ + AttachDecorator.data_base64(LD_PROOF_VC, ident="ld_proof-0"), + AttachDecorator.data_base64(LD_PROOF_VC, ident="ld_proof-1"), + ], + ) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="ld_proof-0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + V20CredFormat( + attach_id="ld_proof-1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-0"), + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="ld_proof-1"), + ], + ) + cred_ex_record = V20CredExRecord( + cred_ex_id="cred-ex-id", + cred_request=cred_request, + ) + + await self.handler.receive_credential(cred_ex_record, cred_issue, "ld_proof-0") + await self.handler.receive_credential(cred_ex_record, cred_issue, "ld_proof-1") + async def test_receive_credential(self): cred_issue = V20CredIssue( formats=[ @@ -859,6 +1129,53 @@ async def test_receive_credential_x_proof_options_ne(self): context.exception ) + async def test_store_credential_multiple_cred_flow(self): + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="ld_proof-0", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + V20CredFormat( + attach_id="ld_proof-1", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + ], + credentials_attach=[ + AttachDecorator.data_base64(LD_PROOF_VC, ident="ld_proof-0"), + AttachDecorator.data_base64(LD_PROOF_VC, ident="ld_proof-1"), + ], + ) + + cred_ex_record = V20CredExRecord( + cred_ex_id="dummy-cxid", + cred_issue=cred_issue, + ) + + cred_id = "cred_id" + self.holder.store_credential = async_mock.CoroutineMock() + + with async_mock.patch.object( + LDProofCredFormatHandler, + "_get_suite", + async_mock.CoroutineMock(), + ) as mock_get_suite, async_mock.patch.object( + test_module, + "verify_credential", + async_mock.CoroutineMock( + return_value=DocumentVerificationResult(verified=True) + ), + ) as mock_verify_credential, async_mock.patch.object( + LDProofCredFormatHandler, + "_get_proof_purpose", + ) as mock_get_proof_purpose: + await self.handler.store_credential(cred_ex_record, cred_id, "ld_proof-0") + await self.handler.store_credential(cred_ex_record, cred_id, "ld_proof-1") + async def test_store_credential(self): cred_issue = V20CredIssue( formats=[ diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py index ad6f3f9313..7d8447ab5b 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/manager.py @@ -2,7 +2,8 @@ import logging -from typing import Mapping, Optional, Tuple +from typing import Mapping, Optional, Tuple, Sequence +from uuid import uuid4 from ....connections.models.conn_record import ConnRecord from ....core.oob_processor import OobRecord @@ -200,6 +201,7 @@ async def create_offer( counter_proposal: V20CredProposal = None, replacement_id: str = None, comment: str = None, + multiple_available: int = 1, ) -> Tuple[V20CredExRecord, V20CredOffer]: """ Create credential offer, update credential exchange record. @@ -208,7 +210,8 @@ async def create_offer( cred_ex_record: credential exchange record for which to create offer replacement_id: identifier to help coordinate credential replacement comment: optional human-readable comment to set in offer message - + multiple_available: Count of verifiable credentials of the indicated + type available for issuance. Returns: A tuple (credential exchange record, credential offer message) @@ -225,11 +228,13 @@ async def create_offer( # Format specific create_offer handler for format in cred_proposal_message.formats: cred_format = V20CredFormat.Format.get(format.format) - if cred_format: + attach_id = ( + format.attach_id if format.attach_id != cred_format.api else None + ) formats.append( await cred_format.handler(self.profile).create_offer( - cred_proposal_message + cred_proposal_message=cred_proposal_message, attach_id=attach_id ) ) @@ -237,6 +242,15 @@ async def create_offer( raise V20CredManagerError( "Unable to create credential offer. No supported formats" ) + elif len(formats) >= 2: + if not multiple_available or multiple_available <= 1: + raise V20CredManagerError( + "Multiple formats included but multiple_available" + f" is set as {str(multiple_available)}" + ) + cred_ex_record.multiple_credentials = True + if multiple_available > 1: + cred_ex_record.multiple_credentials = True cred_offer_message = V20CredOffer( replacement_id=replacement_id, @@ -244,6 +258,7 @@ async def create_offer( credential_preview=cred_proposal_message.credential_preview, formats=[format for (format, _) in formats], offers_attach=[attach for (_, attach) in formats], + multiple_available=multiple_available, ) cred_offer_message._thread = {"thid": cred_ex_record.thread_id} @@ -299,7 +314,7 @@ async def receive_offer( auto_remove=not self._profile.settings.get("preserve_exchange_records"), trace=(cred_offer_message._trace is not None), ) - + handled_formats = [] # Format specific receive_offer handler for format in cred_offer_message.formats: cred_format = V20CredFormat.Format.get(format.format) @@ -308,6 +323,12 @@ async def receive_offer( await cred_format.handler(self.profile).receive_offer( cred_ex_record, cred_offer_message ) + handled_formats.append(cred_format) + + if len(handled_formats) == 0: + raise V20CredManagerError("No supported credential formats received.") + elif len(handled_formats) >= 2: + cred_ex_record.multiple_credentials = True cred_ex_record.cred_offer = cred_offer_message cred_ex_record.state = V20CredExRecord.STATE_OFFER_RECEIVED @@ -318,7 +339,12 @@ async def receive_offer( return cred_ex_record async def create_request( - self, cred_ex_record: V20CredExRecord, holder_did: str, comment: str = None + self, + cred_ex_record: V20CredExRecord, + holder_did: str, + exclude_attach_ids: Sequence[str] = [], + comment: str = None, + multiple_credential_flow: bool = False, ) -> Tuple[V20CredExRecord, V20CredRequest]: """ Create a credential request. @@ -327,18 +353,24 @@ async def create_request( cred_ex_record: credential exchange record for which to create request holder_did: holder DID comment: optional human-readable comment to set in request message - + multiple_credential_flow: Flag to indicate if this is part of + multiple credential issuance. Returns: A tuple (credential exchange record, credential request message) """ + cred_request_exists = False if cred_ex_record.cred_request: - raise V20CredManagerError( - "create_request() called multiple times for " - f"v2.0 credential exchange {cred_ex_record.cred_ex_id}" - ) + if multiple_credential_flow: + cred_request_exists = True + else: + raise V20CredManagerError( + "create_request() called multiple times for " + f"v2.0 credential exchange {cred_ex_record.cred_ex_id}" + ) # react to credential offer, use offer formats + init_cred_req_flow = False if cred_ex_record.state: if cred_ex_record.state != V20CredExRecord.STATE_OFFER_RECEIVED: raise V20CredManagerError( @@ -348,23 +380,50 @@ async def create_request( ) cred_offer = cred_ex_record.cred_offer - - input_formats = cred_offer.formats + if not cred_offer and cred_request_exists: + cred_proposal = cred_ex_record.cred_proposal + input_formats = cred_proposal.formats + init_cred_req_flow = True + else: + input_formats = cred_offer.formats # start with request (not allowed for indy -> checked in indy format handler) # use proposal formats else: cred_proposal = cred_ex_record.cred_proposal input_formats = cred_proposal.formats + if cred_ex_record.multiple_issuance_state: + if cred_ex_record.multiple_issuance_state in [ + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE, + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_ABANDONED, + ]: + raise V20CredManagerError( + f"Credential exchange {cred_ex_record.cred_ex_id} " + f"in {cred_ex_record.multiple_issuance_state} " + "multiple_issuance_state" + ) + request_formats = [] # Format specific create_request handler for format in input_formats: cred_format = V20CredFormat.Format.get(format.format) if cred_format: + attach_id = ( + format.attach_id if format.attach_id != cred_format.api else None + ) + if cred_request_exists and ( + not attach_id or attach_id == cred_format.api + ): + attach_id = f"{attach_id or cred_format.api}-{str(uuid4())}" + if attach_id and attach_id in exclude_attach_ids: + continue request_formats.append( await cred_format.handler(self.profile).create_request( - cred_ex_record, {"holder_did": holder_did} + cred_ex_record=cred_ex_record, + request_data={"holder_did": holder_did}, + attach_id=attach_id, + init_cred_req_flow=init_cred_req_flow, ) ) @@ -372,6 +431,11 @@ async def create_request( raise V20CredManagerError( "Unable to create credential request. No supported formats" ) + elif len(request_formats) >= 2: + cred_ex_record.multiple_credentials = True + cred_ex_record.multiple_issuance_state = ( + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_PENDING + ) cred_request_message = V20CredRequest( comment=comment, @@ -380,14 +444,24 @@ async def create_request( ) # Assign thid (and optionally pthid) to message - cred_request_message.assign_thread_from(cred_ex_record.cred_offer) + if not cred_ex_record.cred_offer: + cred_request_message._thread = {"thid": cred_ex_record.thread_id} + else: + cred_request_message.assign_thread_from(cred_ex_record.cred_offer) cred_request_message.assign_trace_decorator( self._profile.settings, cred_ex_record.trace ) cred_ex_record.thread_id = cred_request_message._thread_id cred_ex_record.state = V20CredExRecord.STATE_REQUEST_SENT - cred_ex_record.cred_request = cred_request_message + if cred_request_exists: + existing_cred_request = cred_ex_record.cred_request + for fmt, atch in request_formats: + existing_cred_request.add_attachments(fmt, atch) + cred_ex_record.cred_request = existing_cred_request + cred_ex_record.multiple_credentials = True + else: + cred_ex_record.cred_request = cred_request_message async with self._profile.session() as session: await cred_ex_record.save(session, reason="create v2.0 credential request") @@ -443,14 +517,45 @@ async def receive_request( if connection_record: cred_ex_record.connection_id = connection_record.connection_id + if cred_ex_record.multiple_issuance_state: + if cred_ex_record.multiple_issuance_state in [ + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE, + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_ABANDONED, + ]: + raise V20CredManagerError( + f"Credential exchange {cred_ex_record.cred_ex_id} " + f"in {cred_ex_record.multiple_issuance_state} " + "multiple_issuance_state" + ) + + handled_formats = [] for format in cred_request_message.formats: cred_format = V20CredFormat.Format.get(format.format) if cred_format: await cred_format.handler(self.profile).receive_request( cred_ex_record, cred_request_message ) + handled_formats.append(cred_format) + + if len(handled_formats) == 0: + raise V20CredManagerError("No supported credential formats received.") + elif len(handled_formats) >= 2: + cred_ex_record.multiple_credentials = True + cred_ex_record.multiple_issuance_state = ( + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_PENDING + ) - cred_ex_record.cred_request = cred_request_message + if cred_ex_record.cred_request: + existing_cred_request = cred_ex_record.cred_request + for iter in range(len(cred_request_message.requests_attach)): + existing_cred_request.add_attachments( + cred_request_message.formats[iter], + cred_request_message.requests_attach[iter], + ) + cred_ex_record.cred_request = existing_cred_request + cred_ex_record.multiple_credentials = True + else: + cred_ex_record.cred_request = cred_request_message cred_ex_record.state = V20CredExRecord.STATE_REQUEST_RECEIVED async with self._profile.session() as session: @@ -463,6 +568,8 @@ async def issue_credential( cred_ex_record: V20CredExRecord, *, comment: str = None, + more_available: int = 0, + credential_spec: V20CredProposal = None, ) -> Tuple[V20CredExRecord, V20CredIssue]: """ Issue a credential. @@ -470,12 +577,79 @@ async def issue_credential( Args: cred_ex_record: credential exchange record for which to issue credential comment: optional human-readable comment pertaining to credential issue - + more_available: Count of the verifiable credential type for the Holder + that the Issuer is willing to issue Returns: Tuple: (Updated credential exchange record, credential issue message) """ - + if credential_spec: + if not cred_ex_record.multiple_credentials: + raise V20CredManagerError( + "Credential spec included but cred_ex_record " + "multiple_credentials set as " + f"{str(cred_ex_record.multiple_credentials)}" + ) + input_formats = credential_spec.formats + # Creating requests for the credential spec + request_formats = [] + cred_issue_attach_id_exists = False + for format in input_formats: + cred_format = V20CredFormat.Format.get(format.format) + + if cred_format: + attach_id = ( + format.attach_id + if format.attach_id != cred_format.api + else None + ) + if not attach_id: + if ( + cred_ex_record.cred_issue + and cred_ex_record.cred_issue.attachment_by_id( + cred_format.api + ) + is not None + ): + cred_issue_attach_id_exists = True + cred_spec_attch = credential_spec.attachment() + else: + if ( + cred_ex_record.cred_issue + and cred_ex_record.cred_issue.attachment_by_id(attach_id) + is not None + ): + cred_issue_attach_id_exists = True + cred_spec_attch = credential_spec.attachment_by_id(attach_id) + if cred_issue_attach_id_exists: + raise V20CredManagerError( + f"{attach_id or cred_format.api} identifier in " + "credential_spec already exists with cred_issue " + f"in cred_ex_record {cred_ex_record.cred_ex_id}" + ) + request_formats.append( + await cred_format.handler(self.profile).create_request( + cred_ex_record=cred_ex_record, + attach_id=attach_id, + request_data={ + attach_id or cred_format.api: cred_spec_attch + }, + ) + ) + if len(request_formats) >= 1: + cred_request_message = V20CredRequest( + formats=[format for (format, _) in request_formats], + requests_attach=[attach for (_, attach) in request_formats], + ) + cred_request_message.assign_thread_from(cred_ex_record.cred_offer) + cred_request_message.assign_trace_decorator( + self._profile.settings, cred_ex_record.trace + ) + if cred_ex_record.cred_request and cred_ex_record.multiple_credentials: + existing_cred_request = cred_ex_record.cred_request + for fmt, atch in request_formats: + existing_cred_request.add_attachments(fmt, atch) + cred_ex_record.cred_request = existing_cred_request if cred_ex_record.state != V20CredExRecord.STATE_REQUEST_RECEIVED: raise V20CredManagerError( f"Credential exchange {cred_ex_record.cred_ex_id} " @@ -483,11 +657,26 @@ async def issue_credential( f"(must be {V20CredExRecord.STATE_REQUEST_RECEIVED})" ) + if cred_ex_record.multiple_issuance_state: + if cred_ex_record.multiple_issuance_state in [ + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE, + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_ABANDONED, + ]: + raise V20CredManagerError( + f"Credential exchange {cred_ex_record.cred_ex_id} " + f"in {cred_ex_record.multiple_issuance_state} " + "multiple_issuance_state " + ) + + cred_issue_exists = False if cred_ex_record.cred_issue: - raise V20CredManagerError( - "issue_credential() called multiple times for " - f"cred ex record {cred_ex_record.cred_ex_id}" - ) + if cred_ex_record.multiple_credentials: + cred_issue_exists = True + else: + raise V20CredManagerError( + "issue_credential() called multiple times for " + f"cred ex record {cred_ex_record.cred_ex_id}" + ) replacement_id = None input_formats = cred_ex_record.cred_request.formats @@ -498,15 +687,21 @@ async def issue_credential( # Format specific issue_credential handler issue_formats = [] + to_exclude = cred_ex_record.processed_attach_ids for format in input_formats: cred_format = V20CredFormat.Format.get(format.format) - if cred_format: + attach_id = ( + format.attach_id if format.attach_id != cred_format.api else None + ) + if attach_id and attach_id in to_exclude: + continue issue_formats.append( await cred_format.handler(self.profile).issue_credential( - cred_ex_record + cred_ex_record=cred_ex_record, attach_id=attach_id ) ) + cred_ex_record.process_attach_id(attach_id or cred_format.api) if len(issue_formats) == 0: raise V20CredManagerError( @@ -518,10 +713,31 @@ async def issue_credential( comment=comment, formats=[format for (format, _) in issue_formats], credentials_attach=[attach for (_, attach) in issue_formats], + more_available=more_available, ) - - cred_ex_record.state = V20CredExRecord.STATE_ISSUED - cred_ex_record.cred_issue = cred_issue_message + if not more_available or more_available == 0: + cred_ex_record.state = V20CredExRecord.STATE_ISSUED + if cred_ex_record.multiple_credentials: + cred_ex_record.multiple_issuance_state = ( + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE + ) + else: + cred_ex_record.state = V20CredExRecord.STATE_OFFER_SENT + cred_ex_record.multiple_issuance_state = ( + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_PENDING + ) + cred_ex_record.multiple_credentials = True + if cred_issue_exists: + existing_cred_issue = cred_ex_record.cred_issue + for iter in range(len(cred_issue_message.credentials_attach)): + existing_cred_issue.add_attachments( + cred_issue_message.formats[iter], + cred_issue_message.credentials_attach[iter], + ) + cred_ex_record.cred_issue = existing_cred_issue + cred_ex_record.multiple_credentials = True + else: + cred_ex_record.cred_issue = cred_issue_message async with self._profile.session() as session: # FIXME - re-fetch record to check state, apply transactional update await cred_ex_record.save(session, reason="v2.0 issue credential") @@ -534,7 +750,9 @@ async def issue_credential( return (cred_ex_record, cred_issue_message) async def receive_credential( - self, cred_issue_message: V20CredIssue, connection_id: Optional[str] + self, + cred_issue_message: V20CredIssue, + connection_id: Optional[str], ) -> V20CredExRecord: """ Receive a credential issue message from an issuer. @@ -556,37 +774,108 @@ async def receive_credential( role=V20CredExRecord.ROLE_HOLDER, ) + if cred_ex_record.multiple_issuance_state: + if cred_ex_record.multiple_issuance_state in [ + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE, + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_ABANDONED, + ]: + raise V20CredManagerError( + f"Credential exchange {cred_ex_record.cred_ex_id} " + f"in {cred_ex_record.multiple_issuance_state} " + "multiple_issuance_state" + ) + cred_request_message = cred_ex_record.cred_request - req_formats = [ - V20CredFormat.Format.get(fmt.format) + req_format_ids = [ + fmt.attach_id for fmt in cred_request_message.formats if V20CredFormat.Format.get(fmt.format) ] - issue_formats = [ - V20CredFormat.Format.get(fmt.format) + issue_format_ids = [ + fmt.attach_id for fmt in cred_issue_message.formats if V20CredFormat.Format.get(fmt.format) ] + alrady_processed_attach = cred_ex_record.processed_attach_ids + issue_formats = cred_issue_message.formats handled_formats = [] - + more_available = cred_issue_message.more_available # check that we didn't receive any formats not present in the request - if set(issue_formats) - set(req_formats): + if set(issue_format_ids + alrady_processed_attach) - set(req_format_ids): raise V20CredManagerError( - "Received issue credential format(s) not present in credential " - f"request: {set(issue_formats) - set(req_formats)}" + "Received issue credential format(s) not " + "present in credential request: " + f"{set(issue_format_ids + alrady_processed_attach) - set(req_format_ids)}" ) - for issue_format in issue_formats: - await issue_format.handler(self.profile).receive_credential( - cred_ex_record, cred_issue_message - ) - handled_formats.append(issue_format) + cred_format = V20CredFormat.Format.get(issue_format.format) + if cred_format: + attach_id = ( + issue_format.attach_id + if issue_format.attach_id != cred_format.api + else None + ) + if attach_id and attach_id in alrady_processed_attach: + continue + await cred_format.handler(self.profile).receive_credential( + cred_ex_record, cred_issue_message, attach_id + ) + cred_ex_record.process_attach_id(attach_id or cred_format.api) + handled_formats.append(cred_format) if len(handled_formats) == 0: raise V20CredManagerError("No supported credential formats received.") - cred_ex_record.cred_issue = cred_issue_message - cred_ex_record.state = V20CredExRecord.STATE_CREDENTIAL_RECEIVED + if cred_ex_record.cred_issue: + existing_cred_issue = cred_ex_record.cred_issue + + for iter in range(len(cred_issue_message.credentials_attach)): + existing_cred_issue.add_attachments( + cred_issue_message.formats[iter], + cred_issue_message.credentials_attach[iter], + ) + cred_ex_record.cred_issue = existing_cred_issue + cred_ex_record.multiple_credentials = True + else: + cred_ex_record.cred_issue = cred_issue_message + disable_multiple_cred_flow = self._profile.settings.get( + "debug.disable_multiple_credential_flow" + ) + if more_available and more_available > 0: + if disable_multiple_cred_flow: + cred_ex_record.state = V20CredExRecord.STATE_CREDENTIAL_RECEIVED + responder = self._profile.inject_or(BaseResponder) + if responder: + report = V20CredProblemReport( + description={ + "en": ( + "Holder requests no more credentials " + "of this type to be issued." + ), + "code": ( + ProblemReportReason.STOP_MORE_CREDENTIAL_ISSUANCE.value + ), + } + ) + if cred_ex_record.thread_id: + report.assign_thread_id(cred_ex_record.thread_id) + await responder.send_reply( + report, connection_id=cred_ex_record.connection_id + ) + cred_ex_record.multiple_issuance_state = ( + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_ABANDONED + ) + else: + cred_ex_record.state = V20CredExRecord.STATE_OFFER_RECEIVED + cred_ex_record.multiple_issuance_state = ( + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_PENDING + ) + else: + cred_ex_record.state = V20CredExRecord.STATE_CREDENTIAL_RECEIVED + if cred_ex_record.multiple_credentials: + cred_ex_record.multiple_issuance_state = ( + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE + ) async with self._profile.session() as session: await cred_ex_record.save(session, reason="receive v2.0 credential issue") @@ -612,17 +901,23 @@ async def store_credential( f"in {cred_ex_record.state} state " f"(must be {V20CredExRecord.STATE_CREDENTIAL_RECEIVED})" ) - + to_exclude = cred_ex_record.stored_attach_ids # Format specific store_credential handler for format in cred_ex_record.cred_issue.formats: cred_format = V20CredFormat.Format.get(format.format) if cred_format: + attach_id = ( + format.attach_id if format.attach_id != cred_format.api else None + ) + if attach_id and attach_id in to_exclude: + continue await cred_format.handler(self.profile).store_credential( - cred_ex_record, cred_id + cred_ex_record=cred_ex_record, cred_id=cred_id, attach_id=attach_id ) # TODO: if storing multiple credentials we can't reuse the same id cred_id = None + cred_ex_record.store_attach_id(attach_id or cred_format.api) return cred_ex_record @@ -738,11 +1033,16 @@ async def receive_problem_report( message._thread_id, ) - cred_ex_record.state = V20CredExRecord.STATE_ABANDONED code = message.description.get( "code", ProblemReportReason.ISSUANCE_ABANDONED.value, ) + if code == ProblemReportReason.ISSUANCE_ABANDONED.value: + cred_ex_record.state = V20CredExRecord.STATE_ABANDONED + elif code == ProblemReportReason.STOP_MORE_CREDENTIAL_ISSUANCE.value: + cred_ex_record.multiple_issuance_state = ( + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_ABANDONED + ) cred_ex_record.error_msg = f"{code}: {message.description.get('en', code)}" await cred_ex_record.save(session, reason="received problem report") diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py index 4a0c6fd4d2..1e50e3c8d9 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_format.py @@ -104,6 +104,18 @@ def get_attachment_data( return None + def get_attachment_data_by_id( + self, + attach_id: str, + attachments: Sequence[AttachDecorator], + ): + """Find attachment by attachment identifier.""" + for atch in attachments: + if atch.ident == attach_id: + return atch.content + + return None + def __init__( self, *, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_issue.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_issue.py index 00008dd023..b4f3cfd091 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_issue.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_issue.py @@ -32,6 +32,7 @@ def __init__( self, _id: str = None, *, + more_available: int = None, replacement_id: str = None, comment: str = None, formats: Sequence[V20CredFormat] = None, @@ -46,6 +47,7 @@ def __init__( credentials_attach: credentials attachments formats: acceptable attachment formats filter_attach: list of credential attachments + more_available: count of verifiable credentials willing to issue """ super().__init__(_id=_id, **kwargs) @@ -53,6 +55,7 @@ def __init__( self.comment = comment self.formats = list(formats) if formats else [] self.credentials_attach = list(credentials_attach) if credentials_attach else [] + self.more_available = more_available def attachment(self, fmt: V20CredFormat.Format = None) -> dict: """ @@ -79,6 +82,39 @@ def attachment(self, fmt: V20CredFormat.Format = None) -> dict: else None ) + def attachment_by_id(self, attach_id: str) -> dict: + """ + Return attached credential by attach identifier. + + Args: + attach_id: string identifier + + """ + _format_list = [ + V20CredFormat.Format.get(f.format) + for f in self.formats + if f.attach_id == attach_id + ] + if len(_format_list) == 0: + return None + target_format = _format_list[0] + return ( + target_format.get_attachment_data_by_id(attach_id, self.credentials_attach) + if target_format + else None + ) + + def add_attachments(self, fmt: V20CredFormat, atch: AttachDecorator) -> None: + """ + Update attachment format and cred issue attachment. + + Args: + fmt: format of attachment + atch: attachment + """ + self.formats.append(fmt) + self.credentials_attach.append(atch) + class V20CredIssueSchema(AgentMessageSchema): """Credential issue schema.""" @@ -98,6 +134,14 @@ class Meta: comment = fields.Str( description="Human-readable comment", required=False, allow_none=True ) + more_available = fields.Int( + description=( + "Count of the verifiable credential type for the Holder " + "that the Issuer is willing to issue" + ), + required=False, + strict=True, + ) formats = fields.Nested( V20CredFormatSchema, many=True, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_offer.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_offer.py index 447f3c454f..fa3a1e3f98 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_offer.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_offer.py @@ -33,6 +33,7 @@ def __init__( self, _id: str = None, *, + multiple_available: int = None, replacement_id: str = None, comment: str = None, credential_preview: V20CredPreview = None, @@ -49,6 +50,7 @@ def __init__( credential_preview: credential preview formats: acceptable attachment formats offers_attach: list of offer attachments + multiple_available: count of verifiable credentials available for issuance """ super().__init__(_id=_id, **kwargs) @@ -57,6 +59,7 @@ def __init__( self.credential_preview = credential_preview self.formats = list(formats) if formats else [] self.offers_attach = list(offers_attach) if offers_attach else [] + self.multiple_available = multiple_available def attachment(self, fmt: V20CredFormat.Format = None) -> dict: """ @@ -83,6 +86,28 @@ def attachment(self, fmt: V20CredFormat.Format = None) -> dict: else None ) + def attachment_by_id(self, attach_id: str) -> dict: + """ + Return attached offer. + + Args: + attach_id: string identifier + + """ + _format_list = [ + V20CredFormat.Format.get(f.format) + for f in self.formats + if f.attach_id == attach_id + ] + if len(_format_list) == 0: + return None + target_format = _format_list[0] + return ( + target_format.get_attachment_data_by_id(attach_id, self.offers_attach) + if target_format + else None + ) + class V20CredOfferSchema(AgentMessageSchema): """Credential offer schema.""" @@ -104,6 +129,14 @@ class Meta: required=False, allow_none=True, ) + multiple_available = fields.Int( + description=( + "Count of verifiable credentials of the indicated " + "type available for issuance" + ), + required=False, + strict=True, + ) credential_preview = fields.Nested(V20CredPreviewSchema, required=False) formats = fields.Nested( V20CredFormatSchema, diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_problem_report.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_problem_report.py index 64af518625..49a68e0ef1 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_problem_report.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_problem_report.py @@ -21,6 +21,7 @@ class ProblemReportReason(Enum): """Supported reason codes.""" ISSUANCE_ABANDONED = "issuance-abandoned" + STOP_MORE_CREDENTIAL_ISSUANCE = "stop-more-credential-issuance" class V20CredProblemReport(ProblemReport): diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_proposal.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_proposal.py index 265ec1e868..530f4dc562 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_proposal.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_proposal.py @@ -81,6 +81,28 @@ def attachment(self, fmt: V20CredFormat.Format = None) -> dict: else None ) + def attachment_by_id(self, attach_id: str) -> dict: + """ + Return attached filter by attach identifier. + + Args: + attach_id: string identifier + + """ + _format_list = [ + V20CredFormat.Format.get(f.format) + for f in self.formats + if f.attach_id == attach_id + ] + if len(_format_list) == 0: + return None + target_format = _format_list[0] + return ( + target_format.get_attachment_data_by_id(attach_id, self.filters_attach) + if target_format + else None + ) + class V20CredProposalSchema(AgentMessageSchema): """Credential proposal schema.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_request.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_request.py index c610433756..338ceac284 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_request.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/cred_request.py @@ -78,6 +78,39 @@ def attachment(self, fmt: V20CredFormat.Format = None) -> dict: else None ) + def attachment_by_id(self, attach_id: str) -> dict: + """ + Return attached credential request. + + Args: + attach_id: string identifier + + """ + _format_list = [ + V20CredFormat.Format.get(f.format) + for f in self.formats + if f.attach_id == attach_id + ] + if len(_format_list) == 0: + return None + target_format = _format_list[0] + return ( + target_format.get_attachment_data_by_id(attach_id, self.requests_attach) + if target_format + else None + ) + + def add_attachments(self, fmt: V20CredFormat, atch: AttachDecorator) -> None: + """ + Update attachment format and requests attachment. + + Args: + fmt: format of attachment + atch: attachment + """ + self.formats.append(fmt) + self.requests_attach.append(atch) + class V20CredRequestSchema(AgentMessageSchema): """Credential request schema.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/inner/cred_preview.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/inner/cred_preview.py index 36c903c899..248767b490 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/inner/cred_preview.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/inner/cred_preview.py @@ -1,14 +1,15 @@ """Credential preview inner object.""" -from typing import Sequence +from typing import Sequence, Mapping -from marshmallow import EXCLUDE, fields +from marshmallow import EXCLUDE, fields, ValidationError, pre_load, post_dump from ......messaging.models.base import BaseModel, BaseModelSchema from ......wallet.util import b64_to_str from .....didcomm_prefix import DIDCommPrefix +from ...messages.cred_format import V20CredFormat from ...message_types import CRED_20_PREVIEW @@ -108,6 +109,7 @@ def __init__( *, _type: str = None, attributes: Sequence[V20CredAttrSpec] = None, + attributes_dict: Mapping = None, **kwargs, ): """ @@ -130,13 +132,14 @@ def __init__( """ super().__init__(**kwargs) self.attributes = list(attributes) if attributes else [] + self.attributes_dict = attributes_dict @property def _type(self): """Accessor for message type.""" return DIDCommPrefix.qualify_current(V20CredPreview.Meta.message_type) - def attr_dict(self, decode: bool = False): + def attr_dict(self, decode: bool = False, attach_id: str = None): """ Return name:value pair per attribute. @@ -144,22 +147,38 @@ def attr_dict(self, decode: bool = False): decode: whether first to decode attributes with MIME type """ - - return { - attr.name: b64_to_str(attr.value) - if attr.mime_type and decode - else attr.value - for attr in self.attributes - } - - def mime_types(self): + if attach_id: + return { + attr.name: b64_to_str(attr.value) + if attr.mime_type and decode + else attr.value + for attr in self.attributes_dict.get(attach_id) + } + else: + return { + attr.name: b64_to_str(attr.value) + if attr.mime_type and decode + else attr.value + for attr in self.attributes + } + + def mime_types(self, attach_id: str = None): """ Return per-attribute mapping from name to MIME type. Return empty dict if no attribute has MIME type. """ - return {attr.name: attr.mime_type for attr in self.attributes if attr.mime_type} + if attach_id: + return { + attr.name: attr.mime_type + for attr in self.attributes_dict.get(attach_id) + if attr.mime_type + } + else: + return { + attr.name: attr.mime_type for attr in self.attributes if attr.mime_type + } class V20CredPreviewSchema(BaseModelSchema): @@ -178,5 +197,39 @@ class Meta: data_key="@type", ) attributes = fields.Nested( - V20CredAttrSpecSchema, many=True, required=True, data_key="attributes" + V20CredAttrSpecSchema, many=True, required=False, data_key="attributes" ) + attributes_dict = fields.Dict( + keys=fields.Str(description="identifier"), + values=fields.Nested(V20CredAttrSpecSchema, many=True, required=True), + required=False, + ) + + def check_cred_ident_in_keys(self, attr_dict): + """Check for indy or ld_proof in attachment identifier.""" + for key, value in attr_dict.items(): + if V20CredFormat.Format.INDY.api in key: + return True + return False + + @pre_load + def extract_and_process_attributes(self, data, **kwargs): + """Process attributes and populate attributes_dict accordingly.""" + if not data.get("attributes") and not data.get("attributes_dict"): + raise ValidationError("Missing attributes dict") + elif data.get("attributes") and data.get("attributes_dict"): + raise ValidationError("Only specify either attributes or attributes_dict") + elif data.get("attributes") and not data.get("attributes_dict"): + attr_data = data.get("attributes") + if isinstance(attr_data, dict) and self.check_cred_ident_in_keys(attr_data): + data["attributes_dict"] = attr_data + del data["attributes"] + return data + + @post_dump + def cleanup_attributes(self, data, **kwargs): + """Cleanup attributes_dict and return as attributes.""" + if data.get("attributes_dict"): + data["attributes"] = data.get("attributes_dict") + del data["attributes_dict"] + return data diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/inner/tests/test_cred_preview.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/inner/tests/test_cred_preview.py index 0f704f0c8f..1af23baa55 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/inner/tests/test_cred_preview.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/inner/tests/test_cred_preview.py @@ -1,5 +1,7 @@ from unittest import TestCase +from .......messaging.models.base import BaseModelError + from ......didcomm_prefix import DIDCommPrefix from ....message_types import CRED_20_PREVIEW @@ -99,6 +101,57 @@ def test_serialize(self): ], } + def test_check_cred_ident_in_keys(self): + """Test serialization.""" + + attr_dict = { + "@type": DIDCommPrefix.qualify_current(CRED_20_PREVIEW), + "attributes": { + "indy-0": [ + {"name": "test", "value": "123"}, + {"name": "hello", "value": "world"}, + {"name": "icon", "mime-type": "image/png", "value": "cG90YXRv"}, + ] + }, + } + cred20_preview = V20CredPreview.deserialize(attr_dict) + assert "indy-0" in cred20_preview.attributes_dict + assert cred20_preview.attributes == [] + attr_dict = { + "@type": DIDCommPrefix.qualify_current(CRED_20_PREVIEW), + "attributes": { + "test-0": [ + {"name": "test", "value": "123"}, + {"name": "hello", "value": "world"}, + {"name": "icon", "mime-type": "image/png", "value": "cG90YXRv"}, + ] + }, + } + with self.assertRaises(BaseModelError): + cred20_preview = V20CredPreview.deserialize(attr_dict) + attr_dict = { + "@type": DIDCommPrefix.qualify_current(CRED_20_PREVIEW), + } + with self.assertRaises(BaseModelError): + cred20_preview = V20CredPreview.deserialize(attr_dict) + attr_dict = { + "@type": DIDCommPrefix.qualify_current(CRED_20_PREVIEW), + "attributes": [ + {"name": "test", "value": "123"}, + {"name": "hello", "value": "world"}, + {"name": "icon", "mime-type": "image/png", "value": "cG90YXRv"}, + ], + "attributes_dict": { + "indy-0": [ + {"name": "test", "value": "123"}, + {"name": "hello", "value": "world"}, + {"name": "icon", "mime-type": "image/png", "value": "cG90YXRv"}, + ] + }, + } + with self.assertRaises(BaseModelError): + cred20_preview = V20CredPreview.deserialize(attr_dict) + class TestV20CredPreviewSchema(TestCase): """Test schema.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_format.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_format.py index 4aade3ba15..805934434e 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_format.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_format.py @@ -74,3 +74,22 @@ def test_get_attachment_data(self): ) is None ) + + def test_get_attachment_data_by_id(self): + assert ( + V20CredFormat.Format.INDY.get_attachment_data_by_id( + attach_id="indy-1", + attachments=[ + AttachDecorator.data_base64(TEST_INDY_FILTER, ident="indy-1") + ], + ) + == TEST_INDY_FILTER + ) + assert not ( + V20CredFormat.Format.INDY.get_attachment_data_by_id( + attach_id="indy", + attachments=[ + AttachDecorator.data_base64(TEST_INDY_FILTER, ident="indy-1") + ], + ) + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_issue.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_issue.py index a0f2d70639..44177b3395 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_issue.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_issue.py @@ -1,4 +1,5 @@ from asynctest import mock as async_mock, TestCase as AsyncTestCase +from copy import deepcopy from ......messaging.decorators.attach_decorator import AttachDecorator from ......messaging.models.base import BaseModelError @@ -87,6 +88,31 @@ class TestV20CredIssue(AsyncTestCase): ], ) + CRED_ISSUE_MULTIPLE = V20CredIssue( + replacement_id="0", + comment="Test", + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][V20CredFormat.Format.INDY.api], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][V20CredFormat.Format.INDY.api], + ), + ], + credentials_attach=[ + AttachDecorator.data_base64( + mapping=INDY_CRED, + ident="indy-0", + ), + AttachDecorator.data_base64( + mapping=INDY_CRED, + ident="indy-1", + ), + ], + ) + async def test_init_type(self): """Test initializer and type.""" assert ( @@ -100,6 +126,25 @@ async def test_init_type(self): assert TestV20CredIssue.CRED_ISSUE._type == DIDCommPrefix.qualify_current( CRED_20_ISSUE ) + assert ( + TestV20CredIssue.CRED_ISSUE_MULTIPLE.attachment_by_id("indy-1") + == TestV20CredIssue.INDY_CRED + ) + assert not TestV20CredIssue.CRED_ISSUE_MULTIPLE.attachment_by_id("indy") + cred_issue_single = deepcopy(TestV20CredIssue.CRED_ISSUE) + cred_issue_single.add_attachments( + V20CredFormat( + attach_id="indy-abc", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][V20CredFormat.Format.INDY.api], + ), + AttachDecorator.data_base64( + mapping=TestV20CredIssue.INDY_CRED, + ident="indy-abc", + ), + ) + assert ( + cred_issue_single.attachment_by_id("indy-abc") == TestV20CredIssue.INDY_CRED + ) async def test_attachment_no_target_format(self): """Test attachment behaviour for only unknown formats.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_offer.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_offer.py index 1394281b45..c6ad097279 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_offer.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_offer.py @@ -63,6 +63,31 @@ class TestV20CredOffer(AsyncTestCase): ], ) + CRED_OFFER_MULTIPLE = V20CredOffer( + comment="shaken, not stirred", + credential_preview=preview, + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][V20CredFormat.Format.INDY.api], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][V20CredFormat.Format.INDY.api], + ), + ], + offers_attach=[ + AttachDecorator.data_base64( + mapping=indy_offer, + ident="indy-0", + ), + AttachDecorator.data_base64( + mapping=indy_offer, + ident="indy-1", + ), + ], + ) + async def test_init_type(self): """Test initializer and type.""" assert ( @@ -76,6 +101,11 @@ async def test_init_type(self): assert TestV20CredOffer.CRED_OFFER._type == DIDCommPrefix.qualify_current( CRED_20_OFFER ) + assert ( + TestV20CredOffer.CRED_OFFER_MULTIPLE.attachment_by_id("indy-1") + == TestV20CredOffer.indy_offer + ) + assert not TestV20CredOffer.CRED_OFFER_MULTIPLE.attachment_by_id("indy") async def test_attachment_no_target_format(self): """Test attachment behaviour for only unknown formats.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_proposal.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_proposal.py index f9edf2e2b0..caa6a117cf 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_proposal.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_proposal.py @@ -47,6 +47,8 @@ async def test_init(self): ) assert cred_proposal.credential_preview == TEST_PREVIEW assert cred_proposal.attachment() == TEST_INDY_FILTER + assert cred_proposal.attachment_by_id("indy") == TEST_INDY_FILTER + assert not cred_proposal.attachment_by_id("random") assert cred_proposal._type == DIDCommPrefix.qualify_current(CRED_20_PROPOSAL) async def test_attachment_no_target_format(self): diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_request.py b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_request.py index 607a3ff445..524902a026 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_request.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/messages/tests/test_cred_request.py @@ -1,4 +1,5 @@ from asynctest import TestCase as AsyncTestCase +from copy import deepcopy from ......messaging.decorators.attach_decorator import AttachDecorator from ......messaging.models.base import BaseModelError @@ -66,6 +67,34 @@ class TestV20CredRequest(AsyncTestCase): ], ) + CRED_REQUEST_MULTIPLE = V20CredRequest( + comment="Test", + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64( + ident="indy-0", + mapping=indy_cred_req, + ), + AttachDecorator.data_base64( + ident="indy-1", + mapping=indy_cred_req, + ), + ], + ) + async def test_init_type(self): """Test initializer and type.""" assert ( @@ -79,6 +108,28 @@ async def test_init_type(self): assert TestV20CredRequest.CRED_REQUEST._type == DIDCommPrefix.qualify_current( CRED_20_REQUEST ) + assert ( + TestV20CredRequest.CRED_REQUEST_MULTIPLE.attachment_by_id("indy-1") + == TestV20CredRequest.indy_cred_req + ) + assert not TestV20CredRequest.CRED_REQUEST_MULTIPLE.attachment_by_id("indy") + cred_req_single = deepcopy(TestV20CredRequest.CRED_REQUEST) + cred_req_single.add_attachments( + V20CredFormat( + attach_id="indy-abc", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64( + ident="indy-abc", + mapping=TestV20CredRequest.indy_cred_req, + ), + ) + assert ( + cred_req_single.attachment_by_id("indy-abc") + == TestV20CredRequest.indy_cred_req + ) async def test_attachment_no_target_format(self): """Test attachment behaviour for only unknown formats.""" diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py index 900310f524..4d5072a991 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/cred_ex_record.py @@ -2,7 +2,7 @@ import logging -from typing import Any, Mapping, Optional, Union +from typing import Any, Mapping, Optional, Union, Sequence from marshmallow import fields, Schema, validate @@ -53,6 +53,10 @@ class Meta: STATE_CREDENTIAL_REVOKED = "credential-revoked" STATE_ABANDONED = "abandoned" + STATE_MULTIPLE_ISSUANCE_PENDING = "pending" + STATE_MULTIPLE_ISSUANCE_COMPLETE = "complete" + STATE_MULTIPLE_ISSUANCE_ABANDONED = "abandoned" + def __init__( self, *, @@ -75,6 +79,10 @@ def __init__( cred_id_stored: str = None, # backward compat: BaseRecord.from_storage() conn_id: str = None, # backward compat: BaseRecord.from_storage() by_format: Mapping = None, # backward compat: BaseRecord.from_storage() + multiple_credentials: bool = None, + processed_attach_ids: Sequence[str] = [], + stored_attach_ids: Sequence[str] = [], # Will be empty for issuers + multiple_issuance_state: str = None, **kwargs, ): """Initialize a new V20CredExRecord.""" @@ -94,6 +102,10 @@ def __init__( self.auto_issue = auto_issue self.auto_remove = auto_remove self.error_msg = error_msg + self.multiple_credentials = multiple_credentials + self.processed_attach_ids = list(processed_attach_ids) + self.stored_attach_ids = list(stored_attach_ids) + self.multiple_issuance_state = multiple_issuance_state @property def cred_ex_id(self) -> str: @@ -145,6 +157,24 @@ def cred_issue(self, value): """Setter; store de/serialized views.""" self._cred_issue = V20CredIssue.serde(value) + def process_attach_id(self, attach_id: str): + """ + Add attach_id to processed_attach_ids list. + + Args: + attach_id: Attachment identifier + """ + self.processed_attach_ids.append(attach_id) + + def store_attach_id(self, attach_id: str): + """ + Add attach_id to stored_attach_ids list. + + Args: + attach id: Attachment identifier + """ + self.stored_attach_ids.append(attach_id) + async def save_error_state( self, session: ProfileSession, @@ -198,6 +228,9 @@ def record_value(self) -> Mapping: "auto_remove", "error_msg", "trace", + "multiple_credentials", + "multiple_issuance_state", + "processed_attach_ids", ) }, **{ @@ -251,13 +284,17 @@ def by_format(self) -> Mapping: }.items(): msg = getattr(self, item) if msg: + attach_ids_list = [ + V20CredFormat.Format.get(f.format).api + if f.attach_id == V20CredFormat.Format.get(f.format).api + else f.attach_id + for f in msg.formats + ] result.update( { item: { - V20CredFormat.Format.get(f.format).api: msg.attachment( - V20CredFormat.Format.get(f.format) - ) - for f in msg.formats + attach_id: msg.attachment_by_id(attach_id) + for attach_id in attach_ids_list } } ) @@ -380,3 +417,22 @@ class Meta: description="Error message", example="The front fell off", ) + multiple_credentials = fields.Boolean( + description="Multiple credentials issuance", + required=False, + ) + processed_attach_ids = fields.List( + fields.Str(description="Attachment ID", required=True), + required=False, + description="List of processed attachment IDs", + ) + stored_attach_ids = fields.List( + fields.Str(description="Attachment ID", required=True), + required=False, + description="List of stored attachment IDs", + ) + multiple_issuance_state = fields.Str( + required=False, + description="Multiple credential issuance flow state", + example=V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE, + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/indy.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/indy.py index 31de284076..4544724bc5 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/indy.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/indy.py @@ -10,6 +10,8 @@ from .. import UNENCRYPTED_TAGS +INDY_KEY = "indy" + class V20CredExRecordIndy(BaseRecord): """Credential exchange indy detail record.""" @@ -33,6 +35,7 @@ def __init__( cred_request_metadata: Mapping = None, rev_reg_id: str = None, cred_rev_id: str = None, + attach_id: str = None, **kwargs, ): """Initialize indy credential exchange record details.""" @@ -43,6 +46,7 @@ def __init__( self.cred_request_metadata = cred_request_metadata self.rev_reg_id = rev_reg_id self.cred_rev_id = cred_rev_id + self.attach_id = attach_id or INDY_KEY @property def cred_ex_indy_id(self) -> str: @@ -59,6 +63,7 @@ def record_value(self) -> dict: "cred_request_metadata", "rev_reg_id", "cred_rev_id", + "attach_id", ) } @@ -116,3 +121,7 @@ class Meta: description="Credential revocation identifier within revocation registry", **INDY_CRED_REV_ID, ) + attach_id = fields.Str( + required=False, + description="Format attachment identifier", + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py index 4bb9ca5591..c68a80264d 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/detail/ld_proof.py @@ -10,6 +10,8 @@ from .. import UNENCRYPTED_TAGS +LD_PROOF_KEY = "ld_proof" + class V20CredExRecordLDProof(BaseRecord): """Credential exchange linked data proof detail record.""" @@ -30,6 +32,7 @@ def __init__( *, cred_ex_id: str = None, cred_id_stored: str = None, + attach_id: str = None, **kwargs, ): """Initialize LD Proof credential exchange record details.""" @@ -37,6 +40,7 @@ def __init__( self.cred_ex_id = cred_ex_id self.cred_id_stored = cred_id_stored + self.attach_id = attach_id or LD_PROOF_KEY @property def cred_ex_ld_proof_id(self) -> str: @@ -46,7 +50,13 @@ def cred_ex_ld_proof_id(self) -> str: @property def record_value(self) -> dict: """Accessor for the JSON record value generated for this credential exchange.""" - return {prop: getattr(self, prop) for prop in ("cred_id_stored",)} + return { + prop: getattr(self, prop) + for prop in ( + "cred_id_stored", + "attach_id", + ) + } @classmethod async def query_by_cred_ex_id( @@ -89,3 +99,7 @@ class Meta: description="Credential identifier stored in wallet", example=UUIDFour.EXAMPLE, ) + attach_id = fields.Str( + required=False, + description="Format attachment identifier", + ) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/models/tests/test_cred_ex_record.py b/aries_cloudagent/protocols/issue_credential/v2_0/models/tests/test_cred_ex_record.py index d46fd498d7..481e914257 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/models/tests/test_cred_ex_record.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/models/tests/test_cred_ex_record.py @@ -7,10 +7,19 @@ from ...messages.cred_format import V20CredFormat from ...messages.inner.cred_preview import V20CredAttrSpec, V20CredPreview from ...messages.cred_proposal import V20CredProposal +from ...messages.cred_offer import V20CredOffer +from ...messages.cred_request import V20CredRequest from .. import cred_ex_record as test_module from ..cred_ex_record import V20CredExRecord +from ...formats.indy.tests.test_handler import ( + INDY_OFFER, + CRED_20_OFFER, + CRED_20_REQUEST, + INDY_CRED_REQ, +) + TEST_DID = "LjgpST2rjsoxYegQDRm7EL" SCHEMA_NAME = "bc-reg" SCHEMA_TXN = 12 @@ -133,3 +142,54 @@ async def test_save_error_state(self): mock_save.side_effect = test_module.StorageError() await record.save_error_state(session, reason="test") mock_log_exc.assert_called_once() + + def test_by_format(self): + attr_values = { + "legalName": "value", + "jurisdictionId": "value", + "incorporationDate": "value", + } + + cred_preview = V20CredPreview( + attributes=[ + V20CredAttrSpec(name=k, value=v) for (k, v) in attr_values.items() + ] + ) + cred_offer = V20CredOffer( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ) + ], + offers_attach=[AttachDecorator.data_base64(INDY_OFFER, ident="0")], + ) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[ + AttachDecorator.data_base64( + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ) + ], + ) + + cred_ex_record = V20CredExRecord( + cred_ex_id="dummy-cxid", + cred_offer=cred_offer.serialize(), + cred_request=cred_request.serialize(), + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + ) + + assert cred_ex_record.by_format diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py index e3a066d339..2cb6cd6fad 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/routes.py @@ -52,6 +52,61 @@ from .formats.ld_proof.models.cred_detail import LDProofVCDetailSchema LOGGER = logging.getLogger(__name__) +_FILTER_EXAMPLE = { + "indy": { + "cred_def_id": "WgWxqztrNooG92RXvxSTWv:3:CL:20:tag", + "issuer_did": "WgWxqztrNooG92RXvxSTWv", + "schema_id": "WgWxqztrNooG92RXvxSTWv:2:schema_name:1.0", + "schema_issuer_did": "WgWxqztrNooG92RXvxSTWv", + "schema_name": "preferences", + "schema_version": "1.0", + }, + "ld_proof": { + "credential": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/citizenship/v1", + ], + "credentialSubject": { + "familyName": "SMITH", + "gender": "Male", + "givenName": "JOHN", + "type": ["PermanentResident", "Person"], + }, + "description": "Government of Example Permanent Resident Card.", + "identifier": "83627465", + "issuanceDate": "2019-12-03T12:19:52Z", + "issuer": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "name": "Permanent Resident Card", + "type": ["VerifiableCredential", "PermanentResidentCard"], + }, + "options": {"proofType": "Ed25519Signature2018"}, + }, +} + +_FILTER_LD_PROOF_EXAMPLE = { + "ld_proof": { + "credential": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/citizenship/v1", + ], + "credentialSubject": { + "familyName": "SMITH", + "gender": "Male", + "givenName": "JOHN", + "type": ["PermanentResident", "Person"], + }, + "description": "Government of Example Permanent Resident Card.", + "identifier": "83627465", + "issuanceDate": "2019-12-03T12:19:52Z", + "issuer": "did:key:z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th", + "name": "Permanent Resident Card", + "type": ["VerifiableCredential", "PermanentResidentCard"], + }, + "options": {"proofType": "Ed25519Signature2018"}, + } +} class V20IssueCredentialModuleResponseSchema(OpenAPISchema): @@ -154,48 +209,14 @@ class V20CredFilterIndySchema(OpenAPISchema): ) -class V20CredFilterSchema(OpenAPISchema): - """Credential filtration criteria.""" - - indy = fields.Nested( - V20CredFilterIndySchema, - required=False, - description="Credential filter for indy", - ) - ld_proof = fields.Nested( - LDProofVCDetailSchema, - required=False, - description="Credential filter for linked data proof", - ) - - @validates_schema - def validate_fields(self, data, **kwargs): - """ - Validate schema fields. - - Data must have indy, ld_proof, or both. - - Args: - data: The data to validate - - Raises: - ValidationError: if data has neither indy nor ld_proof - - """ - if not any(f.api in data for f in V20CredFormat.Format): - raise ValidationError( - "V20CredFilterSchema requires indy, ld_proof, or both" - ) - - class V20IssueCredSchemaCore(AdminAPIMessageTracingSchema): """Filter, auto-remove, comment, trace.""" - filter_ = fields.Nested( - V20CredFilterSchema, + filter_ = fields.Dict( required=True, data_key="filter", description="Credential specification criteria by format", + example=_FILTER_EXAMPLE, ) auto_remove = fields.Bool( description=( @@ -207,27 +228,54 @@ class V20IssueCredSchemaCore(AdminAPIMessageTracingSchema): comment = fields.Str( description="Human-readable comment", required=False, allow_none=True ) - - credential_preview = fields.Nested(V20CredPreviewSchema, required=False) + credential_preview = fields.Nested( + V20CredPreviewSchema, + required=False, + example={ + "@type": "issue-credential/2.0/credential-preview", + "attributes": [ + { + "mime-type": "image/jpeg", + "name": "favourite_drink", + "value": "martini", + } + ], + }, + ) @validates_schema def validate(self, data, **kwargs): - """Make sure preview is present when indy format is present.""" - - if data.get("filter", {}).get("indy") and not data.get("credential_preview"): - raise ValidationError( - "Credential preview is required if indy filter is present" - ) - - -class V20CredFilterLDProofSchema(OpenAPISchema): - """Credential filtration criteria.""" - - ld_proof = fields.Nested( - LDProofVCDetailSchema, - required=True, - description="Credential filter for linked data proof", - ) + """Validate filter and checks for preview when indy format is present.""" + filter_dict = data.get("filter_") + filter_attach_ids_handled = [] + if filter_dict: + for attach_id in filter_dict.keys(): + if "indy" in attach_id: + try: + V20CredFilterIndySchema().load(filter_dict.get(attach_id)) + except ValidationError: + raise ValidationError( + "V20CredFilterIndySchema schema validation " + f"failed for {attach_id}" + ) + if not data.get("credential_preview"): + raise ValidationError( + "Credential preview is required if indy filter is present" + ) + filter_attach_ids_handled.append(attach_id) + if "ld_proof" in attach_id: + try: + LDProofVCDetailSchema().load(filter_dict.get(attach_id)) + except ValidationError: + raise ValidationError( + "LDProofVCDetailSchema schema validation " + f"failed for {attach_id}" + ) + filter_attach_ids_handled.append(attach_id) + if len(filter_attach_ids_handled) == 0: + raise ValidationError( + "V20IssueCredSchemaCore requires indy, ld_proof, or both" + ) class V20CredRequestFreeSchema(AdminAPIMessageTracingSchema): @@ -238,12 +286,11 @@ class V20CredRequestFreeSchema(AdminAPIMessageTracingSchema): required=True, example=UUIDFour.EXAMPLE, # typically but not necessarily a UUID4 ) - # Request can only start with LD Proof - filter_ = fields.Nested( - V20CredFilterLDProofSchema, + filter_ = fields.Dict( required=True, data_key="filter", description="Credential specification criteria by format", + example=_FILTER_LD_PROOF_EXAMPLE, ) auto_remove = fields.Bool( description=( @@ -266,6 +313,34 @@ class V20CredRequestFreeSchema(AdminAPIMessageTracingSchema): allow_none=True, example="did:key:ahsdkjahsdkjhaskjdhakjshdkajhsdkjahs", ) + multiple_credential_flow = fields.Bool( + description=( + "Flag to indicate if this request is being created as part of " + "multiple credential issuance flow" + ), + required=False, + ) + + @validates_schema + def validate(self, data, **kwargs): + """Validate filter and checks for preview when indy format is present.""" + filter_dict = data.get("filter_") + filter_attach_ids_handled = [] + if filter_dict: + for attach_id in filter_dict.keys(): + if "ld_proof" in attach_id: + try: + LDProofVCDetailSchema().load(filter_dict.get(attach_id)) + except ValidationError: + raise ValidationError( + "LDProofVCDetailSchema schema validation " + f"failed for {attach_id}" + ) + filter_attach_ids_handled.append(attach_id) + if len(filter_attach_ids_handled) == 0: + raise ValidationError( + "Credential filter for linked data proof is required" + ) class V20CredExFreeSchema(V20IssueCredSchemaCore): @@ -281,25 +356,72 @@ class V20CredExFreeSchema(V20IssueCredSchemaCore): class V20CredBoundOfferRequestSchema(OpenAPISchema): """Request schema for sending bound credential offer admin message.""" - filter_ = fields.Nested( - V20CredFilterSchema, + filter_ = fields.Dict( required=False, data_key="filter", description="Credential specification criteria by format", + example=_FILTER_EXAMPLE, ) counter_preview = fields.Nested( V20CredPreviewSchema, required=False, description="Optional content for counter-proposal", + example={ + "@type": "issue-credential/2.0/credential-preview", + "attributes": { + "indy-0": [ + { + "mime-type": "image/jpeg", + "name": "favourite_drink", + "value": "martini", + }, + { + "mime-type": "image/jpeg", + "name": "favourite_drink", + "value": "martini", + }, + ] + }, + }, + ) + multiple_available = fields.Int( + strict=True, + description=( + "Count of verifiable credentials of the indicated type available for issuance" + ), + required=False, ) @validates_schema def validate_fields(self, data, **kwargs): """Validate schema fields: need both filter and counter_preview or neither.""" - if ( - "filter_" in data - and ("indy" in data["filter_"] or "ld_proof" in data["filter_"]) - ) ^ ("counter_preview" in data): + filter_dict = data.get("filter_") + filter_attach_ids_handled = [] + if filter_dict: + for attach_id in filter_dict.keys(): + if "indy" in attach_id: + try: + V20CredFilterIndySchema().load(filter_dict.get(attach_id)) + except ValidationError: + raise ValidationError( + "V20CredFilterIndySchema schema validation " + f"failed for {attach_id}" + ) + filter_attach_ids_handled.append(attach_id) + if "ld_proof" in attach_id: + try: + LDProofVCDetailSchema().load(filter_dict.get(attach_id)) + except ValidationError: + raise ValidationError( + "LDProofVCDetailSchema schema validation " + f"failed for {attach_id}" + ) + filter_attach_ids_handled.append(attach_id) + if len(filter_attach_ids_handled) == 0: + raise ValidationError( + "V20CredBoundOfferRequestSchema requires indy, ld_proof, or both" + ) + if (len(filter_attach_ids_handled) > 0) ^ ("counter_preview" in data): raise ValidationError( f"V20CredBoundOfferRequestSchema\n{data}\nrequires " "both indy/ld_proof filter and counter_preview or neither" @@ -321,6 +443,13 @@ class V20CredOfferRequestSchema(V20IssueCredSchemaCore): ), required=False, ) + multiple_available = fields.Int( + strict=True, + description=( + "Count of verifiable credentials of the indicated type available for issuance" + ), + required=False, + ) class V20CredOfferConnFreeRequestSchema(V20IssueCredSchemaCore): @@ -333,6 +462,13 @@ class V20CredOfferConnFreeRequestSchema(V20IssueCredSchemaCore): ), required=False, ) + multiple_available = fields.Int( + strict=True, + description=( + "Count of verifiable credentials of the indicated type available for issuance" + ), + required=False, + ) class V20CredRequestRequestSchema(OpenAPISchema): @@ -344,6 +480,19 @@ class V20CredRequestRequestSchema(OpenAPISchema): allow_none=True, example="did:key:ahsdkjahsdkjhaskjdhakjshdkajhsdkjahs", ) + excluded_attach_ids = fields.List( + fields.Str(description="Attachment identifier"), + description="Attachment identifiers, all of which to exclude", + required=False, + data_key="excludeAttachmentIDs", + ) + multiple_credential_flow = fields.Bool( + description=( + "Flag to indicate if this request is being created as part of " + "multiple credential issuance flow" + ), + required=False, + ) class V20CredIssueRequestSchema(OpenAPISchema): @@ -352,6 +501,34 @@ class V20CredIssueRequestSchema(OpenAPISchema): comment = fields.Str( description="Human-readable comment", required=False, allow_none=True ) + more_available = fields.Int( + strict=True, + description=( + "Count of verifiable credentials of the indicated type willing to issue" + ), + required=False, + ) + filter_ = fields.Dict( + required=False, + data_key="filter", + description="LD_PROOF credential specification criteria", + example=_FILTER_LD_PROOF_EXAMPLE, + ) + + @validates_schema + def validate(self, data, **kwargs): + """Validate filter and checks for preview when indy format is present.""" + filter_dict = data.get("filter_") + if filter_dict: + for attach_id in filter_dict.keys(): + if "ld_proof" in attach_id: + try: + LDProofVCDetailSchema().load(filter_dict.get(attach_id)) + except ValidationError: + raise ValidationError( + "LDProofVCDetailSchema schema validation " + f"failed for {attach_id}" + ) class V20CredIssueProblemReportRequestSchema(OpenAPISchema): @@ -384,7 +561,7 @@ def _formats_filters(filt_spec: Mapping) -> Mapping: "formats": [ V20CredFormat( attach_id=fmt_api, - format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][fmt_api], + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][fmt_api.split("-")[0]], ) for fmt_api in filt_spec ], @@ -405,11 +582,15 @@ async def _get_attached_credentials( result = {} for fmt in V20CredFormat.Format: - detail_record = await fmt.handler(profile).get_detail_record( + detail_records = await fmt.handler(profile).get_detail_record( cred_ex_record.cred_ex_id ) - if detail_record: - result[fmt.api] = detail_record + if detail_records: + for detail_record in detail_records: + if not detail_record.attach_id or detail_record.attach_id == fmt.api: + result[fmt.api] = detail_record + elif detail_record.attach_id: + result[detail_record.attach_id] = detail_record return result @@ -419,10 +600,19 @@ def _format_result_with_details( ) -> Mapping: """Get credential exchange result with detail records.""" result = {"cred_ex_record": cred_ex_record.serialize()} - for fmt in V20CredFormat.Format: - ident = fmt.api - detail_record = details.get(ident) - result[ident] = detail_record.serialize() if detail_record else None + detail_keys = list(details.keys()) + formats_added = set() + for key in detail_keys: + if V20CredFormat.Format.INDY.api in key: + formats_added.add(V20CredFormat.Format.INDY.api) + if V20CredFormat.Format.LD_PROOF.api in key: + formats_added.add(V20CredFormat.Format.LD_PROOF.api) + detail_record = details.get(key) + result[key] = detail_record.serialize() + if V20CredFormat.Format.INDY.api not in formats_added: + result[V20CredFormat.Format.INDY.api] = None + if V20CredFormat.Format.LD_PROOF.api not in formats_added: + result[V20CredFormat.Format.LD_PROOF.api] = None return result @@ -802,6 +992,7 @@ async def _create_free_offer( preview_spec: dict = None, comment: str = None, trace_msg: bool = None, + multiple_available: int = 1, ): """Create a credential offer and related exchange record.""" @@ -830,6 +1021,7 @@ async def _create_free_offer( (cred_ex_record, cred_offer_message) = await cred_manager.create_offer( cred_ex_record, comment=comment, + multiple_available=multiple_available, ) return (cred_ex_record, cred_offer_message) @@ -869,6 +1061,7 @@ async def credential_exchange_create_free_offer(request: web.BaseRequest): comment = body.get("comment") preview_spec = body.get("credential_preview") filt_spec = body.get("filter") + multiple_available = body.get("multiple_available") if not filt_spec: raise web.HTTPBadRequest(reason="Missing filter") trace_msg = body.get("trace") @@ -882,6 +1075,9 @@ async def credential_exchange_create_free_offer(request: web.BaseRequest): preview_spec=preview_spec, comment=comment, trace_msg=trace_msg, + multiple_available=multiple_available + if multiple_available and isinstance(multiple_available, int) + else 1, ) result = cred_ex_record.serialize() except ( @@ -943,6 +1139,7 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): comment = body.get("comment") preview_spec = body.get("credential_preview") trace_msg = body.get("trace") + multiple_available = body.get("multiple_available") cred_ex_record = None conn_record = None @@ -961,6 +1158,9 @@ async def credential_exchange_send_free_offer(request: web.BaseRequest): preview_spec=preview_spec, comment=comment, trace_msg=trace_msg, + multiple_available=multiple_available + if multiple_available and isinstance(multiple_available, int) + else 1, ) result = cred_ex_record.serialize() @@ -1026,6 +1226,7 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): body = await request.json() if request.body_exists else {} filt_spec = body.get("filter") preview_spec = body.get("counter_preview") + multiple_available = body.get("multiple_available") cred_ex_id = request.match_info["cred_ex_id"] cred_ex_record = None @@ -1065,6 +1266,9 @@ async def credential_exchange_send_bound_offer(request: web.BaseRequest): if preview_spec else None, comment=None, + multiple_available=multiple_available + if multiple_available and isinstance(multiple_available, int) + else 1, ) result = cred_ex_record.serialize() @@ -1139,6 +1343,7 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): auto_remove = body.get("auto_remove") trace_msg = body.get("trace") holder_did = body.get("holder_did") + multiple_credential_flow = body.get("multiple_credential_flow") or False conn_record = None cred_ex_record = None @@ -1171,6 +1376,7 @@ async def credential_exchange_send_free_request(request: web.BaseRequest): cred_ex_record=cred_ex_record, holder_did=holder_did, comment=comment, + multiple_credential_flow=multiple_credential_flow, ) result = cred_ex_record.serialize() @@ -1229,12 +1435,16 @@ async def credential_exchange_send_bound_request(request: web.BaseRequest): context: AdminRequestContext = request["context"] profile = context.profile outbound_handler = request["outbound_message_router"] + excluded_attach_ids = [] try: body = await request.json() or {} holder_did = body.get("holder_did") + excluded_attach_ids = body.get("excludeAttachmentIDs", []) + multiple_credential_flow = body.get("multiple_credential_flow") or False except JSONDecodeError: holder_did = None + multiple_credential_flow = False cred_ex_id = request.match_info["cred_ex_id"] @@ -1278,8 +1488,10 @@ async def credential_exchange_send_bound_request(request: web.BaseRequest): cred_manager = V20CredManager(profile) cred_ex_record, cred_request_message = await cred_manager.create_request( - cred_ex_record, - holder_did, + cred_ex_record=cred_ex_record, + holder_did=holder_did, + exclude_attach_ids=excluded_attach_ids, + multiple_credential_flow=multiple_credential_flow, ) result = cred_ex_record.serialize() @@ -1344,9 +1556,12 @@ async def credential_exchange_issue(request: web.BaseRequest): body = await request.json() comment = body.get("comment") + more_available = body.get("more_available") + filt_spec = body.get("filter") cred_ex_id = request.match_info["cred_ex_id"] + cred_proposal = None cred_ex_record = None conn_record = None try: @@ -1370,9 +1585,17 @@ async def credential_exchange_issue(request: web.BaseRequest): ) cred_manager = V20CredManager(profile) + if filt_spec: + cred_proposal = V20CredProposal( + **_formats_filters(filt_spec), + ) (cred_ex_record, cred_issue_message) = await cred_manager.issue_credential( cred_ex_record, comment=comment, + more_available=more_available + if more_available and isinstance(more_available, int) + else 0, + credential_spec=cred_proposal, ) details = await _get_attached_credentials(profile, cred_ex_record) diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py index 55d654c5f1..b3eaaa674e 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_manager.py @@ -356,7 +356,7 @@ async def test_create_free_offer(self): mock_save.assert_called_once() mock_handler.return_value.create_offer.assert_called_once_with( - cx_rec.cred_proposal + cred_proposal_message=cx_rec.cred_proposal, attach_id="0" ) assert cx_rec.cred_ex_id == ret_cx_rec._id # cover property @@ -372,6 +372,199 @@ async def test_create_free_offer(self): comment=comment, ) # once more to cover case where offer is available in cache + async def test_create_free_offer_multiple_cred_flow(self): + comment = "comment" + + cred_preview = V20CredPreview( + attributes_dict={ + "indy-0": ( + V20CredAttrSpec(name="legalName", value="value"), + V20CredAttrSpec(name="jurisdictionId", value="value"), + V20CredAttrSpec(name="incorporationDate", value="value"), + ), + "indy-1": ( + V20CredAttrSpec(name="legalName", value="value"), + V20CredAttrSpec(name="jurisdictionId", value="value"), + V20CredAttrSpec(name="incorporationDate", value="value"), + ), + "indy-2": ( + V20CredAttrSpec(name="legalName", value="value"), + V20CredAttrSpec(name="jurisdictionId", value="value"), + V20CredAttrSpec(name="incorporationDate", value="value"), + ), + } + ) + cred_proposal = V20CredProposal( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-2", + format_="random", + ), + ], + filters_attach=[ + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID}, ident="indy-0" + ), + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID}, ident="indy-1" + ), + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID}, ident="indy-2" + ), + ], + ) + + cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + role=V20CredExRecord.ROLE_ISSUER, + cred_proposal=cred_proposal, + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.create_offer = async_mock.CoroutineMock( + side_effect=[ + ( + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-0"), + ), + ( + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-1"), + ), + ] + ) + + (ret_cx_rec, ret_offer) = await self.manager.create_offer( + cred_ex_record=cx_rec, + counter_proposal=None, + replacement_id="0", + comment=comment, + multiple_available=2, + ) + assert ret_cx_rec == cx_rec + + assert cx_rec.cred_ex_id == ret_cx_rec._id # cover property + assert cx_rec.thread_id == ret_offer._thread_id + assert cx_rec.role == V20CredExRecord.ROLE_ISSUER + assert cx_rec.state == V20CredExRecord.STATE_OFFER_SENT + assert cx_rec.cred_offer.attachment_by_id("indy-0") == INDY_OFFER + assert cx_rec.cred_offer.attachment_by_id("indy-1") == INDY_OFFER + + async def test_create_free_offer_multiple_cred_flow_x(self): + comment = "comment" + + cred_preview = V20CredPreview( + attributes_dict={ + "indy-0": ( + V20CredAttrSpec(name="legalName", value="value"), + V20CredAttrSpec(name="jurisdictionId", value="value"), + V20CredAttrSpec(name="incorporationDate", value="value"), + ), + "indy-1": ( + V20CredAttrSpec(name="legalName", value="value"), + V20CredAttrSpec(name="jurisdictionId", value="value"), + V20CredAttrSpec(name="incorporationDate", value="value"), + ), + } + ) + cred_proposal = V20CredProposal( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + ], + filters_attach=[ + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID}, ident="indy-0" + ), + AttachDecorator.data_base64( + {"cred_def_id": CRED_DEF_ID}, ident="indy-1" + ), + ], + ) + + cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + role=V20CredExRecord.ROLE_ISSUER, + cred_proposal=cred_proposal, + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.create_offer = async_mock.CoroutineMock( + side_effect=[ + ( + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-0"), + ), + ( + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-1"), + ), + ] + ) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.create_offer( + cred_ex_record=cx_rec, + counter_proposal=None, + replacement_id="0", + comment=comment, + ) + assert "Multiple formats included but multiple_available" in str( + context.exception + ) + async def test_create_bound_offer(self): comment = "comment" @@ -430,7 +623,7 @@ async def test_create_bound_offer(self): mock_save.assert_called_once() mock_handler.return_value.create_offer.assert_called_once_with( - cred_proposal + cred_proposal_message=cred_proposal, attach_id="0" ) assert cx_rec.thread_id == ret_offer._thread_id @@ -463,6 +656,139 @@ async def test_create_offer_x_no_formats(self): ) assert "No supported formats" in str(context.exception) + async def test_receive_offer_x(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + + cred_preview = V20CredPreview( + attributes=( + V20CredAttrSpec(name="legalName", value="value"), + V20CredAttrSpec(name="jurisdictionId", value="value"), + V20CredAttrSpec(name="incorporationDate", value="value"), + ) + ) + cred_offer = V20CredOffer( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="0", + format_="random", + ) + ], + offers_attach=[AttachDecorator.data_base64(INDY_OFFER, ident="0")], + ) + cred_offer.assign_thread_id(thread_id) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + role=V20CredExRecord.ROLE_HOLDER, + thread_id=thread_id, + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredExRecord, + "retrieve_by_conn_and_thread", + async_mock.CoroutineMock(return_value=stored_cx_rec), + ) as mock_retrieve, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + with self.assertRaises(V20CredManagerError) as context: + await self.manager.receive_offer(cred_offer, connection_id) + assert "No supported credential formats received" in str(context.exception) + + async def test_receive_offer_multiple_cred_flow(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + attr_dict = { + "indy-0": ( + V20CredAttrSpec(name="legalName", value="value"), + V20CredAttrSpec(name="jurisdictionId", value="value"), + V20CredAttrSpec(name="incorporationDate", value="value"), + ), + "indy-1": ( + V20CredAttrSpec(name="legalName", value="value"), + V20CredAttrSpec(name="jurisdictionId", value="value"), + V20CredAttrSpec(name="incorporationDate", value="value"), + ), + } + cred_preview = V20CredPreview(attributes_dict=attr_dict) + cred_proposal = V20CredProposal( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + ], + filters_attach=[ + AttachDecorator.data_base64({}, ident="indy-0"), + AttachDecorator.data_base64({}, ident="indy-1"), + ], + ) + cred_offer = V20CredOffer( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ), + ], + offers_attach=[ + AttachDecorator.data_base64(INDY_OFFER, ident="indy-0"), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-1"), + ], + ) + cred_offer.assign_thread_id(thread_id) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_proposal=cred_proposal, + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + role=V20CredExRecord.ROLE_HOLDER, + thread_id=thread_id, + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredExRecord, + "retrieve_by_conn_and_thread", + async_mock.CoroutineMock(return_value=stored_cx_rec), + ) as mock_retrieve, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.receive_offer = async_mock.CoroutineMock() + + cx_rec = await self.manager.receive_offer(cred_offer, connection_id) + assert cx_rec.connection_id == connection_id + assert cx_rec.thread_id == cred_offer._thread_id + assert cx_rec.role == V20CredExRecord.ROLE_HOLDER + assert cx_rec.state == V20CredExRecord.STATE_OFFER_RECEIVED + assert cx_rec.cred_offer.attachment_by_id("indy-0") == INDY_OFFER + assert cx_rec.cred_offer.attachment_by_id("indy-1") == INDY_OFFER + assert cx_rec.cred_proposal.credential_preview.attributes_dict == attr_dict + async def test_receive_offer_proposed(self): connection_id = "test_conn_id" thread_id = "thread-id" @@ -642,7 +968,10 @@ async def test_create_bound_request(self): ) mock_handler.return_value.create_request.assert_called_once_with( - stored_cx_rec, {"holder_did": holder_did} + cred_ex_record=stored_cx_rec, + request_data={"holder_did": holder_did}, + attach_id="0", + init_cred_req_flow=False, ) assert ret_cred_req.attachment() == INDY_CRED_REQ @@ -650,44 +979,265 @@ async def test_create_bound_request(self): assert ret_cx_rec.state == V20CredExRecord.STATE_REQUEST_SENT - async def test_create_request_x_no_formats(self): - comment = "comment" - - cred_proposal = V20CredProposal( - formats=[], - filters_attach=[], - ) - - cx_rec = V20CredExRecord( - cred_ex_id="dummy-cxid", - role=V20CredExRecord.ROLE_ISSUER, - cred_proposal=cred_proposal, - ) - - with self.assertRaises(V20CredManagerError) as context: - await self.manager.create_request( - cred_ex_record=cx_rec, - holder_did="holder_did", - comment=comment, - ) - assert "No supported formats" in str(context.exception) - - async def test_create_free_request(self): + async def test_create_request_multiple_cred_flow(self): connection_id = "test_conn_id" thread_id = "thread-id" holder_did = "did" - cred_proposal = V20CredProposal( + cred_offer = V20CredOffer( formats=[ V20CredFormat( - attach_id="0", - format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ - V20CredFormat.Format.LD_PROOF.api + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api ], - ) - ], - filters_attach=[AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="0")], - ) + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-2", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + ], + offers_attach=[ + AttachDecorator.data_base64(INDY_OFFER, ident="indy-0"), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-1"), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-2"), + ], + ) + cred_offer.assign_thread_id(thread_id) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_offer=cred_offer, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_HOLDER, + state=V20CredExRecord.STATE_OFFER_RECEIVED, + thread_id=thread_id, + ) + + self.cache = InMemoryCache() + self.context.injector.bind_instance(BaseCache, self.cache) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.create_request = async_mock.CoroutineMock( + side_effect=[ + ( + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-0"), + ), + ( + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-1"), + ), + ] + ) + + ret_cx_rec, ret_cred_req = await self.manager.create_request( + stored_cx_rec, + holder_did, + multiple_credential_flow=True, + exclude_attach_ids=["indy-2"], + ) + + assert ret_cred_req.attachment_by_id("indy-0") == INDY_CRED_REQ + assert ret_cred_req.attachment_by_id("indy-1") == INDY_CRED_REQ + assert ret_cred_req._thread_id == thread_id + assert ret_cx_rec.state == V20CredExRecord.STATE_REQUEST_SENT + + async def test_create_request_multiple_cred_flow_existing(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + holder_did = "did" + + cred_offer = V20CredOffer( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ) + ], + offers_attach=[ + AttachDecorator.data_base64( + INDY_OFFER, ident=V20CredFormat.Format.INDY.api + ), + ], + ) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[ + AttachDecorator.data_base64( + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ) + ], + ) + cred_offer.assign_thread_id(thread_id) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_offer=cred_offer, + cred_request=cred_request, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_HOLDER, + state=V20CredExRecord.STATE_OFFER_RECEIVED, + thread_id=thread_id, + multiple_credentials=True, + ) + + self.cache = InMemoryCache() + self.context.injector.bind_instance(BaseCache, self.cache) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler, async_mock.patch.object( + test_module, "uuid4", async_mock.MagicMock() + ) as mock_uuid: + mock_uuid.return_value = "abc123" + mock_handler.return_value.create_request = async_mock.CoroutineMock( + return_value=( + V20CredFormat( + attach_id="indy-abc123", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc123"), + ) + ) + + ret_cx_rec, ret_cred_req = await self.manager.create_request( + stored_cx_rec, + holder_did, + multiple_credential_flow=True, + ) + + assert ( + ret_cx_rec.cred_request.attachment_by_id("indy-abc123") == INDY_CRED_REQ + ) + assert ret_cx_rec.cred_request.attachment_by_id("indy") == INDY_CRED_REQ + assert ret_cx_rec.multiple_credentials + assert ret_cred_req.attachment_by_id("indy-abc123") == INDY_CRED_REQ + assert ret_cred_req._thread_id == thread_id + assert ret_cx_rec.state == V20CredExRecord.STATE_REQUEST_SENT + + async def test_create_request_multiple_cred_flow_x(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + holder_did = "did" + + cred_offer = V20CredOffer( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ) + ], + offers_attach=[ + AttachDecorator.data_base64( + INDY_OFFER, ident=V20CredFormat.Format.INDY.api + ), + ], + ) + cred_offer.assign_thread_id(thread_id) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_offer=cred_offer, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_HOLDER, + state=V20CredExRecord.STATE_OFFER_RECEIVED, + thread_id=thread_id, + multiple_issuance_state=V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE, + ) + + self.cache = InMemoryCache() + self.context.injector.bind_instance(BaseCache, self.cache) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.create_request( + stored_cx_rec, + holder_did, + multiple_credential_flow=True, + ) + assert ( + f"in {V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE} multiple_issuance_state" + in str(context.exception) + ) + + async def test_create_request_x_no_formats(self): + comment = "comment" + + cred_proposal = V20CredProposal( + formats=[], + filters_attach=[], + ) + + cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + role=V20CredExRecord.ROLE_ISSUER, + cred_proposal=cred_proposal, + ) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.create_request( + cred_ex_record=cx_rec, + holder_did="holder_did", + comment=comment, + ) + assert "No supported formats" in str(context.exception) + + async def test_create_free_request(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + holder_did = "did" + + cred_proposal = V20CredProposal( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.LD_PROOF.api + ], + ) + ], + filters_attach=[AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="0")], + ) cred_offer = V20CredOffer(thread_id) cred_offer._thread = ThreadDecorator(pthid="some-pthid") @@ -729,7 +1279,10 @@ async def test_create_free_request(self): ) mock_handler.return_value.create_request.assert_called_once_with( - stored_cx_rec, {"holder_did": holder_did} + cred_ex_record=stored_cx_rec, + request_data={"holder_did": holder_did}, + attach_id="0", + init_cred_req_flow=False, ) assert ret_cred_req.attachment() == LD_PROOF_VC_DETAIL @@ -804,29 +1357,36 @@ async def test_receive_request(self): assert cx_rec.state == V20CredExRecord.STATE_REQUEST_RECEIVED assert cx_rec.cred_request.attachment() == INDY_CRED_REQ - async def test_receive_request_no_connection_cred_request(self): + async def test_receive_request_multiple_cred_flow(self): + mock_conn = async_mock.MagicMock(connection_id="test_conn_id") stored_cx_rec = V20CredExRecord( cred_ex_id="dummy-cxid", + connection_id=mock_conn.connection_id, initiator=V20CredExRecord.INITIATOR_EXTERNAL, role=V20CredExRecord.ROLE_ISSUER, state=V20CredExRecord.STATE_OFFER_SENT, - thread_id="test_id", ) cred_request = V20CredRequest( formats=[ V20CredFormat( - attach_id="0", + attach_id="indy-0", format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ V20CredFormat.Format.INDY.api ], - ) + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-0"), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-1"), ], - requests_attach=[AttachDecorator.data_base64(INDY_CRED_REQ, ident="0")], ) - mock_conn = async_mock.MagicMock(connection_id="test_conn_id") - mock_oob = async_mock.MagicMock() - with async_mock.patch.object( V20CredExRecord, "save", autospec=True ) as mock_save, async_mock.patch.object( @@ -837,43 +1397,48 @@ async def test_receive_request_no_connection_cred_request(self): mock_retrieve.return_value = stored_cx_rec mock_handler.return_value.receive_request = async_mock.CoroutineMock() - cx_rec = await self.manager.receive_request( - cred_request, mock_conn, mock_oob - ) + cx_rec = await self.manager.receive_request(cred_request, mock_conn, None) - mock_retrieve.assert_called_once_with( - self.session, - None, - cred_request._thread_id, - role=V20CredExRecord.ROLE_ISSUER, - ) - mock_handler.return_value.receive_request.assert_called_once_with( - cx_rec, cred_request - ) - mock_save.assert_called_once() assert cx_rec.state == V20CredExRecord.STATE_REQUEST_RECEIVED - assert cx_rec.cred_request.attachment() == INDY_CRED_REQ - assert cx_rec.connection_id == "test_conn_id" + assert cx_rec.cred_request.attachment_by_id("indy-0") == INDY_CRED_REQ + assert cx_rec.cred_request.attachment_by_id("indy-1") == INDY_CRED_REQ - async def test_receive_request_no_cred_ex_with_offer_found(self): + async def test_receive_request_multiple_cred_flow_existing(self): mock_conn = async_mock.MagicMock(connection_id="test_conn_id") stored_cx_rec = V20CredExRecord( cred_ex_id="dummy-cxid", + connection_id=mock_conn.connection_id, initiator=V20CredExRecord.INITIATOR_EXTERNAL, role=V20CredExRecord.ROLE_ISSUER, state=V20CredExRecord.STATE_OFFER_SENT, - thread_id="test_id", + cred_request=V20CredRequest( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[ + AttachDecorator.data_base64( + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ), + ], + ), ) cred_request = V20CredRequest( formats=[ V20CredFormat( - attach_id="0", + attach_id="indy-abc123", format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ V20CredFormat.Format.INDY.api ], ) ], - requests_attach=[AttachDecorator.data_base64(INDY_CRED_REQ, ident="0")], + requests_attach=[ + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc123"), + ], ) with async_mock.patch.object( @@ -882,193 +1447,1485 @@ async def test_receive_request_no_cred_ex_with_offer_found(self): V20CredExRecord, "retrieve_by_conn_and_thread", async_mock.CoroutineMock() ) as mock_retrieve, async_mock.patch.object( V20CredFormat.Format, "handler" - ) as mock_handler: - mock_retrieve.side_effect = (StorageNotFoundError(),) - mock_handler.return_value.receive_request = async_mock.CoroutineMock() + ) as mock_handler, async_mock.patch.object( + test_module, "uuid4", async_mock.MagicMock() + ) as mock_uuid: + mock_uuid.return_value = "abc123" + mock_handler.return_value.receive_request = async_mock.CoroutineMock( + return_value=None + ) + mock_retrieve.return_value = stored_cx_rec cx_rec = await self.manager.receive_request(cred_request, mock_conn, None) - mock_retrieve.assert_called_once_with( - self.session, - "test_conn_id", - cred_request._thread_id, - role=V20CredExRecord.ROLE_ISSUER, + assert cx_rec.state == V20CredExRecord.STATE_REQUEST_RECEIVED + assert cx_rec.cred_request.attachment_by_id("indy") == INDY_CRED_REQ + assert cx_rec.cred_request.attachment_by_id("indy-abc123") == INDY_CRED_REQ + assert cx_rec.multiple_credentials + + async def test_receive_request_multiple_cred_flow_x(self): + mock_conn = async_mock.MagicMock(connection_id="test_conn_id") + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[ + AttachDecorator.data_base64( + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ), + ], + ) + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=mock_conn.connection_id, + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_OFFER_SENT, + multiple_issuance_state=V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE, + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredExRecord, "retrieve_by_conn_and_thread", async_mock.CoroutineMock() + ) as mock_retrieve: + mock_retrieve.return_value = stored_cx_rec + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.receive_request(cred_request, mock_conn, None) + assert ( + f"in {V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE} multiple_issuance_state" + in str(context.exception) + ) + + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="test", + format_="random", + ) + ], + requests_attach=[ + AttachDecorator.data_base64(INDY_CRED_REQ, ident="test"), + ], + ) + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=mock_conn.connection_id, + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_OFFER_SENT, + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredExRecord, "retrieve_by_conn_and_thread", async_mock.CoroutineMock() + ) as mock_retrieve: + mock_retrieve.return_value = stored_cx_rec + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.receive_request(cred_request, mock_conn, None) + assert "No supported credential formats received" in str(context.exception) + + async def test_receive_request_no_connection_cred_request(self): + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_OFFER_SENT, + thread_id="test_id", + ) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[AttachDecorator.data_base64(INDY_CRED_REQ, ident="0")], + ) + + mock_conn = async_mock.MagicMock(connection_id="test_conn_id") + mock_oob = async_mock.MagicMock() + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredExRecord, "retrieve_by_conn_and_thread", async_mock.CoroutineMock() + ) as mock_retrieve, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_retrieve.return_value = stored_cx_rec + mock_handler.return_value.receive_request = async_mock.CoroutineMock() + + cx_rec = await self.manager.receive_request( + cred_request, mock_conn, mock_oob + ) + + mock_retrieve.assert_called_once_with( + self.session, + None, + cred_request._thread_id, + role=V20CredExRecord.ROLE_ISSUER, + ) + mock_handler.return_value.receive_request.assert_called_once_with( + cx_rec, cred_request + ) + mock_save.assert_called_once() + assert cx_rec.state == V20CredExRecord.STATE_REQUEST_RECEIVED + assert cx_rec.cred_request.attachment() == INDY_CRED_REQ + assert cx_rec.connection_id == "test_conn_id" + + async def test_receive_request_no_cred_ex_with_offer_found(self): + mock_conn = async_mock.MagicMock(connection_id="test_conn_id") + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_OFFER_SENT, + thread_id="test_id", + ) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[AttachDecorator.data_base64(INDY_CRED_REQ, ident="0")], + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredExRecord, "retrieve_by_conn_and_thread", async_mock.CoroutineMock() + ) as mock_retrieve, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_retrieve.side_effect = (StorageNotFoundError(),) + mock_handler.return_value.receive_request = async_mock.CoroutineMock() + + cx_rec = await self.manager.receive_request(cred_request, mock_conn, None) + + mock_retrieve.assert_called_once_with( + self.session, + "test_conn_id", + cred_request._thread_id, + role=V20CredExRecord.ROLE_ISSUER, + ) + mock_handler.return_value.receive_request.assert_called_once_with( + cx_rec, cred_request + ) + + async def test_create_request_sequential_flow(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + holder_did = "did" + + cred_proposal = V20CredProposal( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.LD_PROOF.api + ], + ) + ], + filters_attach=[AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="0")], + ) + + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.LD_PROOF.api + ], + ) + ], + requests_attach=[ + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="0") + ], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_proposal=cred_proposal, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_HOLDER, + thread_id=thread_id, + cred_request=cred_request, + multiple_credentials=True, + state=V20CredExRecord.STATE_OFFER_RECEIVED, + ) + + self.cache = InMemoryCache() + self.context.injector.bind_instance(BaseCache, self.cache) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.create_request = async_mock.CoroutineMock( + return_value=( + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.LD_PROOF.api + ], + ), + AttachDecorator.data_base64(LD_PROOF_VC_DETAIL, ident="0"), + ) + ) + + ret_cx_rec, ret_cred_req = await self.manager.create_request( + stored_cx_rec, + holder_did, + multiple_credential_flow=True, + ) + + mock_handler.return_value.create_request.assert_called_once_with( + cred_ex_record=stored_cx_rec, + request_data={"holder_did": holder_did}, + attach_id="0", + init_cred_req_flow=True, + ) + + assert ret_cred_req.attachment() == LD_PROOF_VC_DETAIL + assert ret_cred_req._thread_id == thread_id + + assert ret_cx_rec.state == V20CredExRecord.STATE_REQUEST_SENT + + async def test_issue_credential(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + comment = "comment" + attr_values = { + "legalName": "value", + "jurisdictionId": "value", + "incorporationDate": "value", + } + cred_preview = V20CredPreview( + attributes=[ + V20CredAttrSpec(name=k, value=v) for (k, v) in attr_values.items() + ] + ) + cred_proposal = V20CredProposal( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ) + ], + filters_attach=[ + AttachDecorator.data_base64( + { + "schema_id": SCHEMA_ID, + "cred_def_id": CRED_DEF_ID, + }, + ident="0", + ) + ], + ) + cred_offer = V20CredOffer( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ) + ], + offers_attach=[AttachDecorator.data_base64(INDY_OFFER, ident="0")], + ) + cred_offer.assign_thread_id(thread_id) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[AttachDecorator.data_base64(INDY_CRED_REQ, ident="0")], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_proposal=cred_proposal, + cred_offer=cred_offer, + cred_request=cred_request, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + thread_id=thread_id, + ) + + issuer = async_mock.MagicMock() + cred_rev_id = "1000" + issuer.create_credential = async_mock.CoroutineMock( + return_value=(json.dumps(INDY_CRED), cred_rev_id) + ) + self.context.injector.bind_instance(IndyIssuer, issuer) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.issue_credential = async_mock.CoroutineMock( + return_value=( + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64( + INDY_CRED, ident=V20CredFormat.Format.INDY.api + ), + ) + ) + (ret_cx_rec, ret_cred_issue) = await self.manager.issue_credential( + stored_cx_rec, comment=comment + ) + + mock_save.assert_called_once() + mock_handler.return_value.issue_credential.assert_called_once_with( + cred_ex_record=ret_cx_rec, attach_id="0" + ) + + assert ret_cx_rec.cred_issue.attachment() == INDY_CRED + assert ret_cred_issue.attachment() == INDY_CRED + assert ret_cx_rec.state == V20CredExRecord.STATE_ISSUED + assert ret_cred_issue._thread_id == thread_id + + async def test_issue_credential_credential_spec_x(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + comment = "comment" + attr_values = { + "legalName": "value", + "jurisdictionId": "value", + "incorporationDate": "value", + } + cred_preview = V20CredPreview( + attributes=[ + V20CredAttrSpec(name=k, value=v) for (k, v) in attr_values.items() + ] + ) + cred_proposal = V20CredProposal( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ) + ], + filters_attach=[ + AttachDecorator.data_base64( + { + "schema_id": SCHEMA_ID, + "cred_def_id": CRED_DEF_ID, + }, + ident="0", + ) + ], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_proposal=cred_proposal, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + thread_id=thread_id, + ) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.issue_credential( + stored_cx_rec, + comment=comment, + credential_spec=cred_proposal, + ) + assert ( + "Credential spec included but cred_ex_record " + "multiple_credentials" in str(context.exception) + ) + + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ) + ], + credentials_attach=[AttachDecorator.data_base64(INDY_CRED, ident="0")], + ) + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_proposal=cred_proposal, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_OFFER_RECEIVED, + thread_id=thread_id, + cred_issue=cred_issue, + multiple_credentials=True, + ) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.issue_credential( + stored_cx_rec, + comment=comment, + credential_spec=cred_proposal, + ) + assert ( + "identifier in credential_spec already exists " + "with cred_issue " in str(context.exception) + ) + + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ) + ], + credentials_attach=[ + AttachDecorator.data_base64( + INDY_CRED, ident=V20CredFormat.Format.INDY.api + ) + ], + ) + cred_proposal.formats[0].attach_id = "indy" + cred_proposal.filters_attach[0] = AttachDecorator.data_base64( + { + "schema_id": SCHEMA_ID, + "cred_def_id": CRED_DEF_ID, + }, + ident="indy", + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_proposal=cred_proposal, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_OFFER_RECEIVED, + thread_id=thread_id, + cred_issue=cred_issue, + multiple_credentials=True, + ) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.issue_credential( + stored_cx_rec, + comment=comment, + credential_spec=cred_proposal, + ) + assert ( + "identifier in credential_spec already exists " + "with cred_issue " in str(context.exception) + ) + + async def test_issue_credential_credential_spec(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + comment = "comment" + attr_values = { + "legalName": "value", + "jurisdictionId": "value", + "incorporationDate": "value", + } + cred_preview = V20CredPreview( + attributes=[ + V20CredAttrSpec(name=k, value=v) for (k, v) in attr_values.items() + ] + ) + cred_proposal = V20CredProposal( + credential_preview=cred_preview, + formats=[ + V20CredFormat( + attach_id="1", + format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + V20CredFormat.Format.INDY.api + ], + ) + ], + filters_attach=[ + AttachDecorator.data_base64( + { + "schema_id": SCHEMA_ID, + "cred_def_id": CRED_DEF_ID, + }, + ident="1", + ) + ], + ) + cred_offer = V20CredOffer( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ) + ], + offers_attach=[AttachDecorator.data_base64(INDY_OFFER, ident="0")], + ) + cred_offer.assign_thread_id(thread_id) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[AttachDecorator.data_base64(INDY_CRED_REQ, ident="0")], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_proposal=cred_proposal, + cred_request=cred_request, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + thread_id=thread_id, + multiple_credentials=True, + processed_attach_ids=["0"], + ) + + issuer = async_mock.MagicMock() + cred_rev_id = "1000" + issuer.create_credential = async_mock.CoroutineMock( + return_value=(json.dumps(INDY_CRED), cred_rev_id) + ) + self.context.injector.bind_instance(IndyIssuer, issuer) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.issue_credential = async_mock.CoroutineMock( + return_value=( + V20CredFormat( + attach_id="1", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_CRED, ident="1"), + ) + ) + mock_handler.return_value.create_request = async_mock.CoroutineMock( + return_value=( + V20CredFormat( + attach_id="1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="1"), + ) + ) + (ret_cx_rec, ret_cred_issue) = await self.manager.issue_credential( + stored_cx_rec, + comment=comment, + credential_spec=cred_proposal, + ) + + mock_save.assert_called_once() + mock_handler.return_value.issue_credential.assert_called_once_with( + cred_ex_record=ret_cx_rec, attach_id="1" + ) + + assert ret_cx_rec.cred_issue.attachment() == INDY_CRED + assert ret_cred_issue.attachment() == INDY_CRED + assert ret_cx_rec.state == V20CredExRecord.STATE_ISSUED + assert ret_cred_issue._thread_id == thread_id + + async def test_issue_credential_multiple_cred_flow(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + comment = "comment" + cred_offer = V20CredOffer( + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ), + ], + offers_attach=[ + AttachDecorator.data_base64(INDY_OFFER, ident="indy-0"), + AttachDecorator.data_base64(INDY_OFFER, ident="indy-1"), + ], + ) + cred_offer.assign_thread_id(thread_id) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-0"), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-1"), + ], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_offer=cred_offer, + cred_request=cred_request, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + thread_id=thread_id, + ) + + issuer = async_mock.MagicMock() + cred_rev_id = "1000" + issuer.create_credential = async_mock.CoroutineMock( + return_value=(json.dumps(INDY_CRED), cred_rev_id) + ) + self.context.injector.bind_instance(IndyIssuer, issuer) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.issue_credential = async_mock.CoroutineMock( + side_effect=[ + ( + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_CRED, ident="indy-0"), + ), + ( + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_CRED, ident="indy-1"), + ), + ] + ) + (ret_cx_rec, ret_cred_issue) = await self.manager.issue_credential( + stored_cx_rec, comment=comment + ) + + assert ret_cx_rec.cred_issue.attachment_by_id("indy-0") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-1") == INDY_CRED + assert ret_cred_issue.attachment_by_id("indy-0") == INDY_CRED + assert ret_cred_issue.attachment_by_id("indy-1") == INDY_CRED + assert ret_cx_rec.state == V20CredExRecord.STATE_ISSUED + assert ret_cred_issue._thread_id == thread_id + + async def test_issue_credential_multiple_cred_flow_existing_a(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + comment = "comment" + cred_offer = V20CredOffer( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + V20CredFormat.Format.INDY.api + ], + ) + ], + offers_attach=[ + AttachDecorator.data_base64( + INDY_OFFER, ident=V20CredFormat.Format.INDY.api + ), + ], + ) + cred_offer.assign_thread_id(thread_id) + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[ + AttachDecorator.data_base64( + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ), + ], + ) + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ) + ], + credentials_attach=[ + AttachDecorator.data_base64( + INDY_CRED, ident=V20CredFormat.Format.INDY.api + ) + ], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_offer=cred_offer, + cred_request=cred_request, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + thread_id=thread_id, + cred_issue=cred_issue, + multiple_credentials=True, + ) + + issuer = async_mock.MagicMock() + cred_rev_id = "1000" + issuer.create_credential = async_mock.CoroutineMock( + return_value=(json.dumps(INDY_CRED), cred_rev_id) + ) + self.context.injector.bind_instance(IndyIssuer, issuer) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.issue_credential = async_mock.CoroutineMock( + return_value=( + V20CredFormat( + attach_id="indy-abc123", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_CRED, ident="indy-abc123"), + ) + ) + (ret_cx_rec, ret_cred_issue) = await self.manager.issue_credential( + stored_cx_rec, comment=comment + ) + + assert ret_cx_rec.cred_issue.attachment_by_id("indy") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-abc123") == INDY_CRED + assert ret_cred_issue.attachment_by_id("indy-abc123") == INDY_CRED + assert not ret_cred_issue.attachment_by_id("indy") + assert ret_cx_rec.state == V20CredExRecord.STATE_ISSUED + assert ret_cred_issue._thread_id == thread_id + + async def test_issue_credential_multiple_cred_flow_existing_b(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + comment = "comment" + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc123", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc321", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64( + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc123"), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc321"), + ], + ) + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc321", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + ], + credentials_attach=[ + AttachDecorator.data_base64( + INDY_CRED, ident=V20CredFormat.Format.INDY.api + ), + AttachDecorator.data_base64(INDY_CRED, ident="indy-abc321"), + ], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + cred_request=cred_request, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + thread_id=thread_id, + cred_issue=cred_issue, + multiple_credentials=True, + processed_attach_ids=[V20CredFormat.Format.INDY.api, "indy-abc321"], + ) + + issuer = async_mock.MagicMock() + cred_rev_id = "1000" + issuer.create_credential = async_mock.CoroutineMock( + return_value=(json.dumps(INDY_CRED), cred_rev_id) + ) + self.context.injector.bind_instance(IndyIssuer, issuer) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.issue_credential = async_mock.CoroutineMock( + return_value=( + V20CredFormat( + attach_id="indy-abc123", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + AttachDecorator.data_base64(INDY_CRED, ident="indy-abc123"), + ) + ) + (ret_cx_rec, ret_cred_issue) = await self.manager.issue_credential( + stored_cx_rec, comment=comment, more_available=1 + ) + + assert ret_cx_rec.cred_issue.attachment_by_id("indy") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-abc123") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-abc321") == INDY_CRED + assert ret_cred_issue.attachment_by_id("indy-abc123") == INDY_CRED + assert not ret_cred_issue.attachment_by_id("indy") + assert not ret_cred_issue.attachment_by_id("indy-abc321") + assert ret_cx_rec.state == V20CredExRecord.STATE_OFFER_SENT + assert ret_cred_issue._thread_id == thread_id + + async def test_issue_credential_multiple_cred_flow_x(self): + connection_id = "test_conn_id" + thread_id = "thread-id" + comment = "comment" + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + thread_id=thread_id, + multiple_issuance_state=V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE, + ) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.issue_credential(stored_cx_rec, comment=comment) + assert ( + f"in {V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE} multiple_issuance_state" + in str(context.exception) + ) + + async def test_issue_credential_x_no_formats(self): + comment = "comment" + + cred_request = V20CredRequest( + formats=[], + requests_attach=[], + ) + + cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + cred_request=cred_request, + ) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.issue_credential( + cred_ex_record=cx_rec, + comment=comment, + ) + assert "No supported formats" in str(context.exception) + + async def test_issue_credential_existing_cred(self): + stored_cx_rec = V20CredExRecord( + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + cred_issue=CRED_ISSUE, + ) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.issue_credential(stored_cx_rec) + assert "called multiple times" in str(context.exception) + + async def test_issue_credential_request_bad_state(self): + stored_cx_rec = V20CredExRecord( + state=V20CredExRecord.STATE_PROPOSAL_SENT, + ) + + with self.assertRaises(V20CredManagerError) as context: + await self.manager.issue_credential(stored_cx_rec) + assert " state " in str(context.exception) + + async def test_receive_cred(self): + connection_id = "test_conn_id" + + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ) + ], + requests_attach=[AttachDecorator.data_base64(INDY_CRED_REQ, ident="0")], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + cred_request=cred_request, + role=V20CredExRecord.ROLE_ISSUER, + ) + + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="0", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ) + ], + credentials_attach=[AttachDecorator.data_base64(INDY_CRED, ident="0")], + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredExRecord, + "retrieve_by_conn_and_thread", + async_mock.CoroutineMock(), + ) as mock_retrieve, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.receive_credential = async_mock.CoroutineMock() + mock_retrieve.return_value = stored_cx_rec + ret_cx_rec = await self.manager.receive_credential( + cred_issue, + connection_id, + ) + + mock_retrieve.assert_called_once_with( + self.session, + connection_id, + cred_issue._thread_id, + role=V20CredExRecord.ROLE_HOLDER, + ) + mock_save.assert_called_once() + mock_handler.return_value.receive_credential.assert_called_once_with( + ret_cx_rec, cred_issue, "0" + ) + assert ret_cx_rec.cred_issue.attachment() == INDY_CRED + assert ret_cx_rec.state == V20CredExRecord.STATE_CREDENTIAL_RECEIVED + + async def test_receive_cred_multiple_cred_flow(self): + connection_id = "test_conn_id" + + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-0"), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-1"), + ], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + cred_request=cred_request, + role=V20CredExRecord.ROLE_ISSUER, + ) + alt_stored_cx_rec = deepcopy(stored_cx_rec) + alt_stored_cx_rec.process_attach_id = ["indy"] + + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="indy-0", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-1", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + ], + credentials_attach=[ + AttachDecorator.data_base64(INDY_CRED, ident="indy-0"), + AttachDecorator.data_base64(INDY_CRED, ident="indy-1"), + ], + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredExRecord, + "retrieve_by_conn_and_thread", + async_mock.CoroutineMock(), + ) as mock_retrieve, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.receive_credential = async_mock.CoroutineMock() + mock_retrieve.return_value = stored_cx_rec + ret_cx_rec = await self.manager.receive_credential( + cred_issue, + connection_id, + ) + + assert ret_cx_rec.cred_issue.attachment_by_id("indy-0") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-1") == INDY_CRED + assert ret_cx_rec.state == V20CredExRecord.STATE_CREDENTIAL_RECEIVED + + mock_retrieve.return_value = alt_stored_cx_rec + with self.assertRaises(V20CredManagerError): + await self.manager.receive_credential( + V20CredIssue( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + ], + credentials_attach=[ + AttachDecorator.data_base64( + INDY_CRED, ident=V20CredFormat.Format.INDY.api + ), + ], + ), + connection_id, + ) + + async def test_receive_cred_multiple_cred_flow_existing_a(self): + connection_id = "test_conn_id" + + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc123", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64( + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc123"), + ], + ) + + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ) + ], + credentials_attach=[ + AttachDecorator.data_base64( + INDY_CRED, ident=V20CredFormat.Format.INDY.api + ) + ], + ) + + stored_cx_rec = V20CredExRecord( + cred_ex_id="dummy-cxid", + cred_issue=cred_issue, + connection_id=connection_id, + initiator=V20CredExRecord.INITIATOR_EXTERNAL, + cred_request=cred_request, + role=V20CredExRecord.ROLE_ISSUER, + processed_attach_ids=[V20CredFormat.Format.INDY.api], + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as mock_save, async_mock.patch.object( + V20CredExRecord, + "retrieve_by_conn_and_thread", + async_mock.CoroutineMock(), + ) as mock_retrieve, async_mock.patch.object( + V20CredFormat.Format, "handler" + ) as mock_handler: + mock_handler.return_value.receive_credential = async_mock.CoroutineMock() + mock_retrieve.return_value = stored_cx_rec + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="indy-abc123", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ) + ], + credentials_attach=[ + AttachDecorator.data_base64(INDY_CRED, ident="indy-abc123") + ], + more_available=1, + ) + ret_cx_rec = await self.manager.receive_credential( + cred_issue, + connection_id, + ) + assert ret_cx_rec.cred_issue.attachment_by_id("indy") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-abc123") == INDY_CRED + assert ret_cx_rec.state == V20CredExRecord.STATE_OFFER_RECEIVED + assert ( + ret_cx_rec.multiple_issuance_state + == V20CredExRecord.STATE_MULTIPLE_ISSUANCE_PENDING + ) + + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc123", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc321", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64( + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc123"), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc321"), + ], + ) + stored_cx_rec.cred_request = cred_request + mock_retrieve.return_value = stored_cx_rec + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="indy-abc321", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ) + ], + credentials_attach=[ + AttachDecorator.data_base64(INDY_CRED, ident="indy-abc321") + ], + more_available=0, + ) + ret_cx_rec = await self.manager.receive_credential( + cred_issue, + connection_id, + ) + + cred_request = V20CredRequest( + formats=[ + V20CredFormat( + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc123", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc321", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc456", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), + ], + requests_attach=[ + AttachDecorator.data_base64( + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc123"), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc321"), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc456"), + ], + ) + stored_cx_rec.cred_request = cred_request + stored_cx_rec.processed_attach_ids = ["indy", "indy-abc123", "indy-abc321"] + stored_cx_rec.multiple_issuance_state = ( + V20CredExRecord.STATE_MULTIPLE_ISSUANCE_PENDING + ) + mock_retrieve.return_value = stored_cx_rec + cred_issue = V20CredIssue( + formats=[ + V20CredFormat( + attach_id="indy-abc321", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + V20CredFormat( + attach_id="indy-abc456", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + ], + credentials_attach=[ + AttachDecorator.data_base64(INDY_CRED, ident="indy-abc321"), + AttachDecorator.data_base64(INDY_CRED, ident="indy-abc456"), + ], + more_available=0, ) - mock_handler.return_value.receive_request.assert_called_once_with( - cx_rec, cred_request + ret_cx_rec = await self.manager.receive_credential( + cred_issue, + connection_id, + ) + assert ret_cx_rec.cred_issue.attachment_by_id("indy") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-abc123") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-abc321") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-abc456") == INDY_CRED + assert ret_cx_rec.state == V20CredExRecord.STATE_CREDENTIAL_RECEIVED + assert ( + ret_cx_rec.multiple_issuance_state + == V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE ) - async def test_issue_credential(self): + async def test_receive_cred_multiple_cred_flow_existing_b(self): connection_id = "test_conn_id" thread_id = "thread-id" - comment = "comment" - attr_values = { - "legalName": "value", - "jurisdictionId": "value", - "incorporationDate": "value", - } - cred_preview = V20CredPreview( - attributes=[ - V20CredAttrSpec(name=k, value=v) for (k, v) in attr_values.items() - ] + self.context.update_settings( + { + "debug.disable_multiple_credential_flow": True, + } ) - cred_proposal = V20CredProposal( - credential_preview=cred_preview, + mock_responder = MockResponder() + self.context.injector.bind_instance(BaseResponder, mock_responder) + cred_request = V20CredRequest( formats=[ V20CredFormat( - attach_id="0", - format_=ATTACHMENT_FORMAT[CRED_20_PROPOSAL][ + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ V20CredFormat.Format.INDY.api ], - ) + ), + V20CredFormat( + attach_id="indy-abc123", + format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ + V20CredFormat.Format.INDY.api + ], + ), ], - filters_attach=[ + requests_attach=[ AttachDecorator.data_base64( - { - "schema_id": SCHEMA_ID, - "cred_def_id": CRED_DEF_ID, - }, - ident="0", - ) + INDY_CRED_REQ, ident=V20CredFormat.Format.INDY.api + ), + AttachDecorator.data_base64(INDY_CRED_REQ, ident="indy-abc123"), ], ) - cred_offer = V20CredOffer( + + cred_issue = V20CredIssue( formats=[ V20CredFormat( - attach_id="0", - format_=ATTACHMENT_FORMAT[CRED_20_OFFER][ + attach_id=V20CredFormat.Format.INDY.api, + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ V20CredFormat.Format.INDY.api ], ) ], - offers_attach=[AttachDecorator.data_base64(INDY_OFFER, ident="0")], - ) - cred_offer.assign_thread_id(thread_id) - cred_request = V20CredRequest( - formats=[ - V20CredFormat( - attach_id="0", - format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ - V20CredFormat.Format.INDY.api - ], + credentials_attach=[ + AttachDecorator.data_base64( + INDY_CRED, ident=V20CredFormat.Format.INDY.api ) ], - requests_attach=[AttachDecorator.data_base64(INDY_CRED_REQ, ident="0")], ) stored_cx_rec = V20CredExRecord( cred_ex_id="dummy-cxid", + cred_issue=cred_issue, connection_id=connection_id, - cred_proposal=cred_proposal, - cred_offer=cred_offer, + initiator=V20CredExRecord.INITIATOR_EXTERNAL, cred_request=cred_request, - initiator=V20CredExRecord.INITIATOR_SELF, role=V20CredExRecord.ROLE_ISSUER, - state=V20CredExRecord.STATE_REQUEST_RECEIVED, + processed_attach_ids=[V20CredFormat.Format.INDY.api], thread_id=thread_id, ) - issuer = async_mock.MagicMock() - cred_rev_id = "1000" - issuer.create_credential = async_mock.CoroutineMock( - return_value=(json.dumps(INDY_CRED), cred_rev_id) - ) - self.context.injector.bind_instance(IndyIssuer, issuer) - with async_mock.patch.object( V20CredExRecord, "save", autospec=True ) as mock_save, async_mock.patch.object( + V20CredExRecord, + "retrieve_by_conn_and_thread", + async_mock.CoroutineMock(), + ) as mock_retrieve, async_mock.patch.object( V20CredFormat.Format, "handler" ) as mock_handler: - mock_handler.return_value.issue_credential = async_mock.CoroutineMock( - return_value=( + mock_handler.return_value.receive_credential = async_mock.CoroutineMock() + mock_retrieve.return_value = stored_cx_rec + cred_issue = V20CredIssue( + formats=[ V20CredFormat( - attach_id=V20CredFormat.Format.INDY.api, + attach_id="indy-abc123", format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ V20CredFormat.Format.INDY.api ], - ), - AttachDecorator.data_base64( - INDY_CRED, ident=V20CredFormat.Format.INDY.api - ), - ) - ) - (ret_cx_rec, ret_cred_issue) = await self.manager.issue_credential( - stored_cx_rec, comment=comment + ) + ], + credentials_attach=[ + AttachDecorator.data_base64(INDY_CRED, ident="indy-abc123") + ], + more_available=1, ) - - mock_save.assert_called_once() - mock_handler.return_value.issue_credential.assert_called_once_with( - ret_cx_rec + ret_cx_rec = await self.manager.receive_credential( + cred_issue, + connection_id, ) - - assert ret_cx_rec.cred_issue.attachment() == INDY_CRED - assert ret_cred_issue.attachment() == INDY_CRED - assert ret_cx_rec.state == V20CredExRecord.STATE_ISSUED - assert ret_cred_issue._thread_id == thread_id - - async def test_issue_credential_x_no_formats(self): - comment = "comment" - - cred_request = V20CredRequest( - formats=[], - requests_attach=[], - ) - - cx_rec = V20CredExRecord( - cred_ex_id="dummy-cxid", - role=V20CredExRecord.ROLE_ISSUER, - state=V20CredExRecord.STATE_REQUEST_RECEIVED, - cred_request=cred_request, - ) - - with self.assertRaises(V20CredManagerError) as context: - await self.manager.issue_credential( - cred_ex_record=cx_rec, - comment=comment, + assert ret_cx_rec.cred_issue.attachment_by_id("indy") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("indy-abc123") == INDY_CRED + assert ret_cx_rec.state == V20CredExRecord.STATE_CREDENTIAL_RECEIVED + assert ( + ret_cx_rec.multiple_issuance_state + == V20CredExRecord.STATE_MULTIPLE_ISSUANCE_ABANDONED ) - assert "No supported formats" in str(context.exception) - - async def test_issue_credential_existing_cred(self): - stored_cx_rec = V20CredExRecord( - state=V20CredExRecord.STATE_REQUEST_RECEIVED, - cred_issue=CRED_ISSUE, - ) - - with self.assertRaises(V20CredManagerError) as context: - await self.manager.issue_credential(stored_cx_rec) - assert "called multiple times" in str(context.exception) - - async def test_issue_credential_request_bad_state(self): - stored_cx_rec = V20CredExRecord( - state=V20CredExRecord.STATE_PROPOSAL_SENT, - ) - - with self.assertRaises(V20CredManagerError) as context: - await self.manager.issue_credential(stored_cx_rec) - assert " state " in str(context.exception) - async def test_receive_cred(self): + async def test_receive_cred_multiple_cred_flow_x(self): connection_id = "test_conn_id" - cred_request = V20CredRequest( - formats=[ - V20CredFormat( - attach_id="0", - format_=ATTACHMENT_FORMAT[CRED_20_REQUEST][ - V20CredFormat.Format.INDY.api - ], - ) - ], - requests_attach=[AttachDecorator.data_base64(INDY_CRED_REQ, ident="0")], - ) - stored_cx_rec = V20CredExRecord( cred_ex_id="dummy-cxid", connection_id=connection_id, - initiator=V20CredExRecord.INITIATOR_EXTERNAL, - cred_request=cred_request, + initiator=V20CredExRecord.INITIATOR_SELF, role=V20CredExRecord.ROLE_ISSUER, + state=V20CredExRecord.STATE_REQUEST_RECEIVED, + multiple_issuance_state=V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE, ) cred_issue = V20CredIssue( @@ -1080,37 +2937,21 @@ async def test_receive_cred(self): ], ) ], - credentials_attach=[AttachDecorator.data_base64(INDY_CRED, ident="0")], + credentials_attach=[AttachDecorator.data_base64(LD_PROOF_VC, ident="0")], ) - with async_mock.patch.object( - V20CredExRecord, "save", autospec=True - ) as mock_save, async_mock.patch.object( - V20CredExRecord, - "retrieve_by_conn_and_thread", - async_mock.CoroutineMock(), - ) as mock_retrieve, async_mock.patch.object( - V20CredFormat.Format, "handler" - ) as mock_handler: - mock_handler.return_value.receive_credential = async_mock.CoroutineMock() + V20CredExRecord, "retrieve_by_conn_and_thread", async_mock.CoroutineMock() + ) as mock_retrieve: mock_retrieve.return_value = stored_cx_rec - ret_cx_rec = await self.manager.receive_credential( - cred_issue, - connection_id, - ) - - mock_retrieve.assert_called_once_with( - self.session, - connection_id, - cred_issue._thread_id, - role=V20CredExRecord.ROLE_HOLDER, - ) - mock_save.assert_called_once() - mock_handler.return_value.receive_credential.assert_called_once_with( - ret_cx_rec, cred_issue + with self.assertRaises(V20CredManagerError) as context: + await self.manager.receive_credential( + cred_issue, + connection_id, + ) + assert ( + f"in {V20CredExRecord.STATE_MULTIPLE_ISSUANCE_COMPLETE} multiple_issuance_state" + in str(context.exception) ) - assert ret_cx_rec.cred_issue.attachment() == INDY_CRED - assert ret_cx_rec.state == V20CredExRecord.STATE_CREDENTIAL_RECEIVED async def test_receive_cred_x_extra_formats(self): connection_id = "test_conn_id" @@ -1215,9 +3056,18 @@ async def test_store_credential(self): format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ V20CredFormat.Format.INDY.api ], - ) + ), + V20CredFormat( + attach_id="1", + format_=ATTACHMENT_FORMAT[CRED_20_ISSUE][ + V20CredFormat.Format.INDY.api + ], + ), + ], + credentials_attach=[ + AttachDecorator.data_base64(INDY_CRED, ident="0"), + AttachDecorator.data_base64(INDY_CRED, ident="1"), ], - credentials_attach=[AttachDecorator.data_base64(INDY_CRED, ident="0")], ) cred_id = "cred_id" @@ -1229,6 +3079,7 @@ async def test_store_credential(self): role=V20CredExRecord.ROLE_ISSUER, state=V20CredExRecord.STATE_CREDENTIAL_RECEIVED, auto_remove=True, + stored_attach_ids=["0"], thread_id=thread_id, ) @@ -1245,11 +3096,8 @@ async def test_store_credential(self): stored_cx_rec, cred_id=cred_id ) - mock_handler.return_value.store_credential.assert_called_once_with( - ret_cx_rec, cred_id - ) - - assert ret_cx_rec.cred_issue.attachment() == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("0") == INDY_CRED + assert ret_cx_rec.cred_issue.attachment_by_id("1") == INDY_CRED assert ret_cx_rec.state == V20CredExRecord.STATE_CREDENTIAL_RECEIVED async def test_store_credential_bad_state(self): @@ -1402,6 +3250,43 @@ async def test_receive_problem_report(self): assert ret_exchange.state == V20CredExRecord.STATE_ABANDONED + async def test_receive_problem_report_more_cred_flow(self): + connection_id = "connection-id" + stored_exchange = V20CredExRecord( + cred_ex_id="dummy-cxid", + connection_id=connection_id, + initiator=V20CredExRecord.INITIATOR_SELF, + role=V20CredExRecord.ROLE_ISSUER, + ) + problem = V20CredProblemReport( + description={ + "code": test_module.ProblemReportReason.STOP_MORE_CREDENTIAL_ISSUANCE.value, + "en": "Holder requests no more credentials of this type to be issued.", + } + ) + + with async_mock.patch.object( + V20CredExRecord, "save", autospec=True + ) as save_ex, async_mock.patch.object( + V20CredExRecord, + "retrieve_by_conn_and_thread", + async_mock.CoroutineMock(), + ) as retrieve_ex: + retrieve_ex.return_value = stored_exchange + + ret_exchange = await self.manager.receive_problem_report( + problem, connection_id + ) + retrieve_ex.assert_called_once_with( + self.session, connection_id, problem._thread_id + ) + save_ex.assert_called_once() + + assert ( + ret_exchange.multiple_issuance_state + == V20CredExRecord.STATE_MULTIPLE_ISSUANCE_ABANDONED + ) + async def test_receive_problem_report_x(self): connection_id = "connection-id" stored_exchange = V20CredExRecord( diff --git a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py index 845f21555c..8a1a773f60 100644 --- a/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py +++ b/aries_cloudagent/protocols/issue_credential/v2_0/tests/test_routes.py @@ -6,6 +6,7 @@ from .. import routes as test_module from ..formats.indy.handler import IndyCredFormatHandler from ..formats.ld_proof.handler import LDProofCredFormatHandler +from ..formats.ld_proof.models.tests import test_cred_detail as ld_proof_test_module from ..messages.cred_format import V20CredFormat from . import ( @@ -29,43 +30,44 @@ async def setUp(self): __getitem__=lambda _, k: self.request_dict[k], ) - async def test_validate_cred_filter_schema(self): - schema = test_module.V20CredFilterSchema() - schema.validate_fields({"indy": {"issuer_did": TEST_DID}}) - schema.validate_fields( - {"indy": {"issuer_did": TEST_DID, "schema_version": "1.0"}} - ) - schema.validate_fields( - { - "indy": {"issuer_did": TEST_DID}, - "ld_proof": {"credential": {}, "options": {}}, - } - ) - schema.validate_fields( + async def test_validate_create_schema(self): + schema = test_module.V20IssueCredSchemaCore() + schema.validate( { - "indy": {}, - "ld_proof": {"credential": {}, "options": {}}, + "filter_": {"indy-0": {"issuer_did": TEST_DID}}, + "credential_preview": {"..": ".."}, } ) + schema.validate({"filter_": {"ld_proof": ld_proof_test_module.VC_DETAIL}}) + + with self.assertRaises(test_module.ValidationError): + schema.validate({"filter_": {"indy": {"..": ".."}}}) with self.assertRaises(test_module.ValidationError): - schema.validate_fields({}) + schema.validate({"filter_": {"indy-0": {"issuer_did": TEST_DID}}}) with self.assertRaises(test_module.ValidationError): - schema.validate_fields(["hopeless", "stop"]) + schema.validate({"filter_": {"ld_proof": {"...": "..."}}}) with self.assertRaises(test_module.ValidationError): - schema.validate_fields({"veres-one": {"no": "support"}}) + schema.validate({"filter_": {"random": {"..": ".."}}}) - async def test_validate_create_schema(self): - schema = test_module.V20IssueCredSchemaCore() + async def test_validate_cred_issue_schema(self): + schema = test_module.V20CredIssueRequestSchema() schema.validate( { - "filter": {"indy": {"issuer_did": TEST_DID}}, - "credential_preview": {"..": ".."}, + "filter_": {"ld_proof": ld_proof_test_module.VC_DETAIL}, + "more_available": 1, + "comment": "test", } ) - schema.validate({"filter": {"ld_proof": {"..": ".."}}}) + with self.assertRaises(test_module.ValidationError): + schema.validate({"filter_": {"ld_proof": {"...": "..."}}}) + async def test_validate_request_free_schema(self): + schema = test_module.V20CredRequestFreeSchema() + schema.validate({"filter_": {"ld_proof": ld_proof_test_module.VC_DETAIL}}) with self.assertRaises(test_module.ValidationError): - schema.validate({"filter": {"indy": {"..": ".."}}}) + schema.validate({"filter_": {"ld_proof": {"...": "..."}}}) + with self.assertRaises(test_module.ValidationError): + schema.validate({"filter_": {"random": {"..": ".."}}}) async def test_validate_bound_offer_request_schema(self): schema = test_module.V20CredBoundOfferRequestSchema() @@ -74,12 +76,19 @@ async def test_validate_bound_offer_request_schema(self): {"filter_": {"indy": {"issuer_did": TEST_DID}}, "counter_preview": {}} ) schema.validate_fields( - {"filter_": {"ld_proof": {"issuer_did": TEST_DID}}, "counter_preview": {}} + { + "filter_": {"ld_proof": ld_proof_test_module.VC_DETAIL}, + "counter_preview": {}, + } ) with self.assertRaises(test_module.ValidationError): schema.validate_fields({"filter_": {"indy": {"issuer_did": TEST_DID}}}) + with self.assertRaises(test_module.ValidationError): schema.validate_fields({"filter_": {"ld_proof": {"issuer_did": TEST_DID}}}) + with self.assertRaises(test_module.ValidationError): schema.validate_fields({"counter_preview": {}}) + with self.assertRaises(test_module.ValidationError): + schema.validate_fields({"filter_": {"random": {}}, "counter_preview": {}}) async def test_credential_exchange_list(self): self.request.query = { @@ -149,9 +158,12 @@ async def test_credential_exchange_retrieve(self): mock_handler.return_value.get_detail_record = async_mock.CoroutineMock( side_effect=[ - async_mock.MagicMock( # indy - serialize=async_mock.MagicMock(return_value={"...": "..."}) - ), + [ + async_mock.MagicMock( # indy + serialize=async_mock.MagicMock(return_value={"...": "..."}), + attach_id="indy", + ) + ], None, # ld_proof ] ) @@ -185,12 +197,20 @@ async def test_credential_exchange_retrieve_indy_ld_proof(self): mock_handler.return_value.get_detail_record = async_mock.CoroutineMock( side_effect=[ - async_mock.MagicMock( # indy - serialize=async_mock.MagicMock(return_value={"in": "dy"}) - ), - async_mock.MagicMock( # ld_proof - serialize=async_mock.MagicMock(return_value={"ld": "proof"}) - ), + [ + async_mock.MagicMock( # indy + serialize=async_mock.MagicMock(return_value={"in": "dy"}), + attach_id=None, + ) + ], + [ + async_mock.MagicMock( # ld_proof + serialize=async_mock.MagicMock( + return_value={"ld": "proof"} + ), + attach_id="ld_proof", + ) + ], ] ) @@ -383,7 +403,10 @@ async def test_credential_exchange_send_request_no_conn_no_holder_did(self): await test_module.credential_exchange_send_bound_request(self.request) mock_cred_mgr.return_value.create_request.assert_called_once_with( - mock_cred_ex.retrieve_by_id.return_value, "holder-did" + cred_ex_record=mock_cred_ex.retrieve_by_id.return_value, + holder_did="holder-did", + exclude_attach_ids=[], + multiple_credential_flow=False, ) mock_response.assert_called_once_with( mock_cred_ex_record.serialize.return_value @@ -1214,9 +1237,12 @@ async def test_credential_exchange_issue(self): mock_handler.return_value.get_detail_record = async_mock.CoroutineMock( side_effect=[ - async_mock.MagicMock( # indy - serialize=async_mock.MagicMock(return_value={"...": "..."}) - ), + [ + async_mock.MagicMock( # indy + serialize=async_mock.MagicMock(return_value={"...": "..."}), + attach_id=None, + ) + ], None, # ld_proof ] ) @@ -1407,9 +1433,12 @@ async def test_credential_exchange_store(self): ) mock_handler.return_value.get_detail_record = async_mock.CoroutineMock( side_effect=[ - async_mock.MagicMock( # indy - serialize=async_mock.MagicMock(return_value={"...": "..."}) - ), + [ + async_mock.MagicMock( # indy + serialize=async_mock.MagicMock(return_value={"...": "..."}), + attach_id="indy", + ) + ], None, # ld_proof ] ) @@ -1458,9 +1487,12 @@ async def test_credential_exchange_store_bad_cred_id_json(self): mock_cx_rec = async_mock.MagicMock() - mock_indy_get_detail_record.return_value = async_mock.MagicMock( # indy - serialize=async_mock.MagicMock(return_value={"...": "..."}) - ) + mock_indy_get_detail_record.return_value = [ + async_mock.MagicMock( # indy + serialize=async_mock.MagicMock(return_value={"...": "..."}), + attach_id="indy-0", + ) + ] mock_ld_proof_get_detail_record.return_value = None # ld_proof mock_cred_mgr.return_value.store_credential.return_value = mock_cx_rec @@ -1474,7 +1506,7 @@ async def test_credential_exchange_store_bad_cred_id_json(self): mock_response.assert_called_once_with( { "cred_ex_record": mock_cx_rec.serialize.return_value, - "indy": {"...": "..."}, + "indy-0": {"...": "..."}, "ld_proof": None, } ) diff --git a/aries_cloudagent/resolver/routes.py b/aries_cloudagent/resolver/routes.py index 680353023a..515a653cb4 100644 --- a/aries_cloudagent/resolver/routes.py +++ b/aries_cloudagent/resolver/routes.py @@ -53,7 +53,7 @@ class ResolutionResultSchema(OpenAPISchema): """Result schema for did document query.""" - did_doc = fields.Dict(description="DID Document", required=True) + did_document = fields.Dict(description="DID Document", required=True) metadata = fields.Dict(description="Resolution metadata", required=True) diff --git a/aries_cloudagent/version.py b/aries_cloudagent/version.py index 7e5ca62006..29b55442af 100644 --- a/aries_cloudagent/version.py +++ b/aries_cloudagent/version.py @@ -1,4 +1,4 @@ """Library version information.""" -__version__ = "1.0.0-rc1" +__version__ = "0.8.0-rc0" RECORD_TYPE_ACAPY_VERSION = "acapy_version" diff --git a/docs/generated/aries_cloudagent.wallet.rst b/docs/generated/aries_cloudagent.wallet.rst index f5c66a5c5b..d7ef3b5efa 100644 --- a/docs/generated/aries_cloudagent.wallet.rst +++ b/docs/generated/aries_cloudagent.wallet.rst @@ -65,6 +65,14 @@ aries\_cloudagent.wallet.did\_method module :undoc-members: :show-inheritance: +aries\_cloudagent.wallet.did\_parameters\_validation module +----------------------------------------------------------- + +.. automodule:: aries_cloudagent.wallet.did_parameters_validation + :members: + :undoc-members: + :show-inheritance: + aries\_cloudagent.wallet.did\_posture module -------------------------------------------- diff --git a/open-api/openapi.json b/open-api/openapi.json index 2be5598c83..7655cac58b 100644 --- a/open-api/openapi.json +++ b/open-api/openapi.json @@ -1,7 +1,7 @@ { "swagger" : "2.0", "info" : { - "version" : "v1.0.0-rc1", + "version" : "v0.8.0-rc0", "title" : "Aries Cloud Agent" }, "tags" : [ {