diff --git a/app/main.py b/app/main.py index ab3f8572b..daa415162 100644 --- a/app/main.py +++ b/app/main.py @@ -22,10 +22,11 @@ sse, trust_registry, verifier, - wallet, webhooks, ) from app.routes.admin import tenants +from app.routes.wallet import credentials as wallet_credentials +from app.routes.wallet import dids as wallet_dids from shared.log_config import get_logger OPENAPI_NAME = os.getenv("OPENAPI_NAME", "OpenAPI") @@ -47,7 +48,8 @@ def create_app() -> FastAPI: oob, trust_registry, verifier, - wallet, + wallet_credentials, + wallet_dids, webhooks, sse, ] diff --git a/app/routes/wallet/__init__.py b/app/routes/wallet/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/routes/wallet/credentials.py b/app/routes/wallet/credentials.py new file mode 100644 index 000000000..2c70980bf --- /dev/null +++ b/app/routes/wallet/credentials.py @@ -0,0 +1,181 @@ +from typing import Optional + +from aries_cloudcontroller import ( + AttributeMimeTypesResult, + CredInfoList, + CredRevokedResult, + IndyCredInfo, + VCRecord, + VCRecordList, + W3CCredentialsListRequest, +) +from fastapi import APIRouter, Depends + +from app.dependencies.acapy_clients import client_from_auth +from app.dependencies.auth import AcaPyAuth, acapy_auth +from shared.log_config import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/wallet/credentials", tags=["wallet"]) + + +@router.get("", response_model=CredInfoList) +async def list_credentials( + count: Optional[str] = None, + start: Optional[str] = None, + wql: Optional[str] = None, + auth: AcaPyAuth = Depends(acapy_auth), +): + """Fetch a list of credentials from the wallet.""" + logger.info("GET request received: List credentials") + + async with client_from_auth(auth) as aries_controller: + logger.debug("Fetching credentials") + results = await aries_controller.credentials.get_records( + count=count, start=start, wql=wql + ) + + logger.info("Successfully listed credentials.") + return results + + +@router.get("/{credential_id}", response_model=IndyCredInfo) +async def get_credential_record( + credential_id: str, + auth: AcaPyAuth = Depends(acapy_auth), +): + """Fetch a specific credential by ID.""" + bound_logger = logger.bind(credential_id=credential_id) + bound_logger.info("GET request received: Fetch specific credential by ID") + + async with client_from_auth(auth) as aries_controller: + bound_logger.debug("Fetching credential") + result = await aries_controller.credentials.get_record( + credential_id=credential_id + ) + + bound_logger.info("Successfully fetched credential.") + return result + + +@router.delete("/{credential_id}", status_code=204) +async def delete_credential( + credential_id: str, + auth: AcaPyAuth = Depends(acapy_auth), +): + """Remove a specific credential from the wallet by ID.""" + bound_logger = logger.bind(credential_id=credential_id) + bound_logger.info("DELETE request received: Remove specific credential by ID") + + async with client_from_auth(auth) as aries_controller: + bound_logger.debug("Deleting credential") + result = await aries_controller.credentials.delete_record( + credential_id=credential_id + ) + + bound_logger.info("Successfully deleted credential.") + return result + + +@router.get("/{credential_id}/mime-types", response_model=AttributeMimeTypesResult) +async def get_credential_mime_types( + credential_id: str, + auth: AcaPyAuth = Depends(acapy_auth), +): + """Retrieve attribute MIME types of a specific credential by ID.""" + bound_logger = logger.bind(credential_id=credential_id) + bound_logger.info( + "GET request received: Retrieve attribute MIME types for a specific credential" + ) + + async with client_from_auth(auth) as aries_controller: + bound_logger.debug("Fetching MIME types") + result = await aries_controller.credentials.get_credential_mime_types( + credential_id=credential_id + ) + + bound_logger.info("Successfully fetched attribute MIME types.") + return result + + +@router.get("/{credential_id}/revocation-status", response_model=CredRevokedResult) +async def get_credential_revocation_status( + credential_id: str, + from_: Optional[str] = None, + to: Optional[str] = None, + auth: AcaPyAuth = Depends(acapy_auth), +): + """Query the revocation status of a specific credential by ID.""" + bound_logger = logger.bind(credential_id=credential_id) + bound_logger.info( + "GET request received: Query revocation status for a specific credential" + ) + + async with client_from_auth(auth) as aries_controller: + bound_logger.debug("Fetching revocation status") + result = await aries_controller.credentials.get_revocation_status( + credential_id=credential_id, from_=from_, to=to + ) + + bound_logger.info("Successfully fetched revocation status.") + return result + + +@router.get("/w3c", response_model=VCRecordList) +async def list_w3c_credentials( + count: Optional[str] = None, + start: Optional[str] = None, + wql: Optional[str] = None, + body: Optional[W3CCredentialsListRequest] = None, + auth: AcaPyAuth = Depends(acapy_auth), +): + """Fetch a list of W3C credentials from the wallet.""" + logger.info("GET request received: List W3C credentials") + + async with client_from_auth(auth) as aries_controller: + logger.debug("Fetching W3C credentials") + results = await aries_controller.credentials.get_w3c_credentials( + count=count, start=start, wql=wql, body=body + ) + + logger.info("Successfully listed W3C credentials.") + return results + + +@router.get("/w3c/{credential_id}", response_model=VCRecord) +async def get_w3c_credential( + credential_id: str, + auth: AcaPyAuth = Depends(acapy_auth), +): + """Fetch a specific W3C credential by ID.""" + bound_logger = logger.bind(credential_id=credential_id) + bound_logger.info("GET request received: Fetch specific W3C credential by ID") + + async with client_from_auth(auth) as aries_controller: + bound_logger.debug("Fetching W3C credential") + result = await aries_controller.credentials.get_w3c_credential( + credential_id=credential_id + ) + + bound_logger.info("Successfully fetched W3C credential.") + return result + + +@router.delete("/w3c/{credential_id}", status_code=204) +async def delete_w3c_credential( + credential_id: str, + auth: AcaPyAuth = Depends(acapy_auth), +): + """Remove a specific W3C credential from the wallet by ID.""" + bound_logger = logger.bind(credential_id=credential_id) + bound_logger.info("DELETE request received: Remove specific W3C credential by ID") + + async with client_from_auth(auth) as aries_controller: + bound_logger.debug("Deleting W3C credential") + result = await aries_controller.credentials.delete_w3c_credential( + credential_id=credential_id + ) + + bound_logger.info("Successfully deleted W3C credential.") + return result diff --git a/app/routes/wallet.py b/app/routes/wallet/dids.py similarity index 100% rename from app/routes/wallet.py rename to app/routes/wallet/dids.py diff --git a/app/tests/e2e/issuer/did_key_ed/test_ld_ed25519.py b/app/tests/e2e/issuer/did_key_ed/test_ld_ed25519.py index 8f6a89e1d..6f811e24e 100644 --- a/app/tests/e2e/issuer/did_key_ed/test_ld_ed25519.py +++ b/app/tests/e2e/issuer/did_key_ed/test_ld_ed25519.py @@ -8,7 +8,7 @@ from app.routes.connections import router as con_router from app.routes.issuer import router as issuer_router from app.routes.oob import router as oob_router -from app.routes.wallet import router as wallet_router +from app.routes.wallet.dids import router as wallet_router from app.tests.util.ecosystem_connections import FaberAliceConnect from app.tests.util.trust_registry import DidKey from app.tests.util.webhooks import check_webhook_state diff --git a/app/tests/e2e/issuer/did_sov/test_v2_ld.py b/app/tests/e2e/issuer/did_sov/test_v2_ld.py index 69eed2572..4ce178a0c 100644 --- a/app/tests/e2e/issuer/did_sov/test_v2_ld.py +++ b/app/tests/e2e/issuer/did_sov/test_v2_ld.py @@ -12,7 +12,7 @@ from app.models.issuer import SendCredential from app.routes.issuer import router as issuer_router from app.routes.oob import router as oob_router -from app.routes.wallet import router as wallet_router +from app.routes.wallet.dids import router as wallet_router from app.tests.util.ecosystem_connections import FaberAliceConnect from app.tests.util.webhooks import check_webhook_state from shared import RichAsyncClient diff --git a/app/tests/e2e/test_credentials.py b/app/tests/e2e/test_credentials.py index c0505dc5e..56da9e232 100644 --- a/app/tests/e2e/test_credentials.py +++ b/app/tests/e2e/test_credentials.py @@ -145,7 +145,9 @@ async def issue_credential_to_alice( }, } - listener = SseListener(topic="credentials", wallet_id=alice_tenant.tenant_id) + alice_credentials_listener = SseListener( + topic="credentials", wallet_id=alice_tenant.tenant_id + ) # create and send credential offer- issuer await faber_client.post( @@ -153,7 +155,7 @@ async def issue_credential_to_alice( json=credential, ) - payload = await listener.wait_for_event( + payload = await alice_credentials_listener.wait_for_event( field="connection_id", field_id=faber_and_alice_connection.alice_connection_id, desired_state="offer-received", @@ -166,7 +168,7 @@ async def issue_credential_to_alice( f"{CREDENTIALS_BASE_PATH}/{alice_credential_id}/request", json={} ) - await listener.wait_for_event( + await alice_credentials_listener.wait_for_event( field="credential_id", field_id=alice_credential_id, desired_state="done" ) diff --git a/app/tests/e2e/test_wallet_credentials.py b/app/tests/e2e/test_wallet_credentials.py new file mode 100644 index 000000000..4dd1bacfd --- /dev/null +++ b/app/tests/e2e/test_wallet_credentials.py @@ -0,0 +1,65 @@ +import logging + +import pytest +from fastapi import HTTPException + +from app.routes.wallet.credentials import router +from shared import RichAsyncClient +from shared.models.topics.base import CredentialExchange + +WALLET_CREDENTIALS_PATH = router.prefix + +logger = logging.getLogger(__name__) + + +@pytest.mark.anyio +async def test_get_credentials(alice_member_client: RichAsyncClient): + # Assert empty list is returned for empty wallet when fetching all credentials + response = await alice_member_client.get(WALLET_CREDENTIALS_PATH) + + assert response.status_code == 200 + response = response.json() + logger.info("response: %s", response) + assert response == {"results": []} + + +@pytest.mark.anyio +async def test_get_and_delete_credential_record( + alice_member_client: RichAsyncClient, issue_credential_to_alice: CredentialExchange +): + credentials_response = await alice_member_client.get(WALLET_CREDENTIALS_PATH) + + assert credentials_response.status_code == 200 + credentials_response = credentials_response.json()["results"] + + assert len(credentials_response) == 1 + + credential_id = credentials_response[0]["referent"] + # While in the broader context of Aries and credentials, referent can refer to specific attributes, + # when dealing with the wallet's stored credentials, the referent becomes synonymous with a credential_id + # specific to the wallet. It's how the wallet references and retrieves that particular credential record. + + fetch_response = await alice_member_client.get( + f"{WALLET_CREDENTIALS_PATH}/{credential_id}" + ) + assert fetch_response.status_code == 200 + fetch_response = fetch_response.json() + logger.info("fetch_response: %s", fetch_response) + + # Assert we can delete this credential + delete_response = await alice_member_client.delete( + f"{WALLET_CREDENTIALS_PATH}/{credential_id}" + ) + assert delete_response.status_code == 204 + + # Assert credential list is now empty + credentials_response = await alice_member_client.get(WALLET_CREDENTIALS_PATH) + assert credentials_response.status_code == 200 + assert credentials_response.json() == {"results": []} + + # Assert fetching deleted credential yields 404 + with pytest.raises(HTTPException) as exc: + credentials_response = await alice_member_client.get( + f"{WALLET_CREDENTIALS_PATH}/{credential_id}" + ) + assert exc.value.status_code == 404 diff --git a/app/tests/e2e/test_wallet.py b/app/tests/e2e/test_wallet_dids.py similarity index 98% rename from app/tests/e2e/test_wallet.py rename to app/tests/e2e/test_wallet_dids.py index 3e683124c..d045dc191 100644 --- a/app/tests/e2e/test_wallet.py +++ b/app/tests/e2e/test_wallet_dids.py @@ -5,7 +5,7 @@ import app.services.acapy_wallet as wallet_facade from app.dependencies.auth import AcaPyAuthVerified from app.models.wallet import SetDidEndpointRequest -from app.routes.wallet import ( +from app.routes.wallet.dids import ( get_did_endpoint, get_public_did, list_dids, diff --git a/app/tests/util/trust_registry.py b/app/tests/util/trust_registry.py index 7b080ff28..ae11acc3c 100644 --- a/app/tests/util/trust_registry.py +++ b/app/tests/util/trust_registry.py @@ -4,7 +4,7 @@ import pytest -from app.routes.wallet import router +from app.routes.wallet.dids import router as wallet_router from app.services.trust_registry import ( Actor, actor_by_did, @@ -15,7 +15,7 @@ ) from shared import RichAsyncClient -WALLET_BASE_PATH = router.prefix +WALLET_BASE_PATH = wallet_router.prefix async def register_issuer(issuer_client: RichAsyncClient, schema_id: str):