diff --git a/app/routes/revocation.py b/app/routes/revocation.py index d6036801f..73e73f9c1 100644 --- a/app/routes/revocation.py +++ b/app/routes/revocation.py @@ -1,12 +1,12 @@ import asyncio from typing import Optional -from aries_cloudcontroller import IssuerCredRevRecord +from aries_cloudcontroller import IssuerCredRevRecord, RevRegWalletUpdatedResult from fastapi import APIRouter, Depends from app.dependencies.acapy_clients import client_from_auth from app.dependencies.auth import AcaPyAuth, acapy_auth_from_header -from app.exceptions import CloudApiException +from app.exceptions import CloudApiException, handle_acapy_call from app.models.issuer import ( ClearPendingRevocationsRequest, ClearPendingRevocationsResult, @@ -313,3 +313,59 @@ async def get_pending_revocations( bound_logger.debug("Successfully fetched pending revocations.") return PendingRevocations(pending_cred_rev_ids=result) + + +@router.put( + "/fix-revocation-registry/{revocation_registry_id}", + summary="Fix Revocation Registry Entry State", +) +async def fix_revocation_registry_entry_state( + revocation_registry_id: str, + apply_ledger_update: bool = False, + auth: AcaPyAuth = Depends(acapy_auth_from_header), +) -> RevRegWalletUpdatedResult: + """ + Fix Revocation Registry Entry State + --- + Fix the revocation registry entry state for a given revocation registry ID. + + If issuer's revocation registry wallet state is out of sync with the ledger, + this endpoint can be used to fix/update the ledger state. + + Path Parameters: + --- + revocation_registry_id: str + The ID of the revocation registry for which to fix the state + + Query Parameters: + --- + apply_ledger_update: bool + Apply changes to ledger (default: False). If False, only computes the difference + between the wallet and ledger state. + + Returns: + --- + RevRegWalletUpdatedResult: + accum_calculated: The calculated accumulator value for any revocations not yet published to ledger + accum_fixed: The result of applying the ledger transaction to synchronize revocation state + rev_reg_delta: The delta between wallet and ledger state for this revocation registry + """ + bound_logger = logger.bind( + body={ + "revocation_registry_id": revocation_registry_id, + "apply_ledger_update": apply_ledger_update, + } + ) + bound_logger.debug("PUT request received: Fix revocation registry entry state") + + async with client_from_auth(auth) as aries_controller: + bound_logger.debug("Fixing revocation registry entry state") + response = await handle_acapy_call( + logger=bound_logger, + acapy_call=aries_controller.revocation.update_rev_reg_revoked_state, + rev_reg_id=revocation_registry_id, + apply_ledger_update=apply_ledger_update, + ) + + bound_logger.debug("Successfully fixed revocation registry entry state.") + return response diff --git a/app/tests/e2e/test_revocation.py b/app/tests/e2e/test_revocation.py index da9aa4a8e..c0d745224 100644 --- a/app/tests/e2e/test_revocation.py +++ b/app/tests/e2e/test_revocation.py @@ -9,7 +9,7 @@ from shared import RichAsyncClient from shared.models.credential_exchange import CredentialExchange -CREDENTIALS_BASE_PATH = router.prefix +REVOCATION_BASE_PATH = router.prefix VERIFIER_BASE_PATH = verifier_router.prefix skip_regression_test_reason = "Skip publish-revocations in regression mode" @@ -26,7 +26,7 @@ async def test_clear_pending_revokes( ): faber_cred_ex_id = revoke_alice_creds[0].credential_exchange_id revocation_record_response = await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/revocation/record" + f"{REVOCATION_BASE_PATH}/revocation/record" + "?credential_exchange_id=" + faber_cred_ex_id ) @@ -35,7 +35,7 @@ async def test_clear_pending_revokes( cred_rev_id = revocation_record_response.json()["cred_rev_id"] clear_revoke_response = await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/clear-pending-revocations", + f"{REVOCATION_BASE_PATH}/clear-pending-revocations", json={"revocation_registry_credential_map": {rev_reg_id: [cred_rev_id]}}, ) revocation_registry_credential_map = clear_revoke_response.json()[ @@ -48,7 +48,7 @@ async def test_clear_pending_revokes( ), "We expect at least two cred_rev_ids per rev_reg_id after revoking one" clear_revoke_response = await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/clear-pending-revocations", + f"{REVOCATION_BASE_PATH}/clear-pending-revocations", json={"revocation_registry_credential_map": {rev_reg_id: []}}, ) revocation_registry_credential_map = clear_revoke_response.json()[ @@ -60,7 +60,7 @@ async def test_clear_pending_revokes( for cred in revoke_alice_creds: rev_record = ( await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/revocation/record" + f"{REVOCATION_BASE_PATH}/revocation/record" + "?credential_exchange_id=" + cred.credential_exchange_id ) @@ -71,7 +71,7 @@ async def test_clear_pending_revokes( # Test for cred_rev_id not pending with pytest.raises(HTTPException) as exc: await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/clear-pending-revocations", + f"{REVOCATION_BASE_PATH}/clear-pending-revocations", json={"revocation_registry_credential_map": {rev_reg_id: [cred_rev_id]}}, ) assert exc.value.status_code == 404 @@ -88,7 +88,7 @@ async def test_clear_pending_revokes_no_map( ): # clear_revoke_response = ( await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/clear-pending-revocations", + f"{REVOCATION_BASE_PATH}/clear-pending-revocations", json={"revocation_registry_credential_map": {}}, ) # ).json()["revocation_registry_credential_map"] @@ -98,7 +98,7 @@ async def test_clear_pending_revokes_no_map( for cred in revoke_alice_creds: rev_record = ( await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/revocation/record" + f"{REVOCATION_BASE_PATH}/revocation/record" + "?credential_exchange_id=" + cred.credential_exchange_id ) @@ -117,7 +117,7 @@ async def test_clear_pending_revokes_bad_payload( ): with pytest.raises(HTTPException) as exc: await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/clear-pending-revocations", + f"{REVOCATION_BASE_PATH}/clear-pending-revocations", json={"revocation_registry_credential_map": "bad"}, ) @@ -125,7 +125,7 @@ async def test_clear_pending_revokes_bad_payload( with pytest.raises(HTTPException) as exc: await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/clear-pending-revocations", + f"{REVOCATION_BASE_PATH}/clear-pending-revocations", json={"revocation_registry_credential_map": {"bad": "bad"}}, ) @@ -133,7 +133,7 @@ async def test_clear_pending_revokes_bad_payload( with pytest.raises(HTTPException) as exc: await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/clear-pending-revocations", + f"{REVOCATION_BASE_PATH}/clear-pending-revocations", json={ "revocation_registry_credential_map": { "WgWxqztrNooG92RXvxSTWv:4:WgWxqztrNooG92RXvxSTWv:3:CL:20:tag:CL_ACCUM:0": [] @@ -156,7 +156,7 @@ async def test_publish_all_revocations_for_rev_reg_id( faber_cred_ex_id = revoke_alice_creds[0].credential_exchange_id response = ( await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/revocation/record" + f"{REVOCATION_BASE_PATH}/revocation/record" + "?credential_exchange_id=" + faber_cred_ex_id ) @@ -165,14 +165,14 @@ async def test_publish_all_revocations_for_rev_reg_id( rev_reg_id = response["rev_reg_id"] await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/publish-revocations", + f"{REVOCATION_BASE_PATH}/publish-revocations", json={"revocation_registry_credential_map": {rev_reg_id: []}}, ) for cred in revoke_alice_creds: rev_record = ( await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/revocation/record" + f"{REVOCATION_BASE_PATH}/revocation/record" + "?credential_exchange_id=" + cred.credential_exchange_id ) @@ -191,14 +191,14 @@ async def test_publish_all_revocations_no_payload( revoke_alice_creds: List[CredentialExchange], ): await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/publish-revocations", + f"{REVOCATION_BASE_PATH}/publish-revocations", json={"revocation_registry_credential_map": {}}, ) for cred in revoke_alice_creds: rev_record = ( await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/revocation/record" + f"{REVOCATION_BASE_PATH}/revocation/record" + "?credential_exchange_id=" + cred.credential_exchange_id ) @@ -219,7 +219,7 @@ async def test_publish_one_revocation( faber_cred_ex_id = revoke_alice_creds[0].credential_exchange_id response = ( await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/revocation/record" + f"{REVOCATION_BASE_PATH}/revocation/record" + "?credential_exchange_id=" + faber_cred_ex_id ) @@ -228,14 +228,14 @@ async def test_publish_one_revocation( rev_reg_id = response["rev_reg_id"] cred_rev_id = response["cred_rev_id"] await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/publish-revocations", + f"{REVOCATION_BASE_PATH}/publish-revocations", json={"revocation_registry_credential_map": {rev_reg_id: [cred_rev_id]}}, ) for cred in revoke_alice_creds: rev_record = ( await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/revocation/record" + f"{REVOCATION_BASE_PATH}/revocation/record" + "?credential_exchange_id=" + cred.credential_exchange_id ) @@ -249,7 +249,7 @@ async def test_publish_one_revocation( # Test for cred_rev_id not pending with pytest.raises(HTTPException) as exc: await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/publish-revocations", + f"{REVOCATION_BASE_PATH}/publish-revocations", json={"revocation_registry_credential_map": {rev_reg_id: [cred_rev_id]}}, ) @@ -266,7 +266,7 @@ async def test_publish_revocations_bad_payload( ): with pytest.raises(HTTPException) as exc: await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/publish-revocations", + f"{REVOCATION_BASE_PATH}/publish-revocations", json={"revocation_registry_credential_map": "bad"}, ) @@ -274,7 +274,7 @@ async def test_publish_revocations_bad_payload( with pytest.raises(HTTPException) as exc: await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/publish-revocations", + f"{REVOCATION_BASE_PATH}/publish-revocations", json={"revocation_registry_credential_map": {"bad": "bad"}}, ) @@ -282,7 +282,7 @@ async def test_publish_revocations_bad_payload( with pytest.raises(HTTPException) as exc: await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/publish-revocations", + f"{REVOCATION_BASE_PATH}/publish-revocations", json={ "revocation_registry_credential_map": { "WgWxqztrNooG92RXvxSTWv:4:WgWxqztrNooG92RXvxSTWv:3:CL:20:tag:CL_ACCUM:0": [] @@ -304,7 +304,7 @@ async def test_get_pending_revocations( ): faber_cred_ex_id = revoke_alice_creds[0].credential_exchange_id revocation_record_response = await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/revocation/record" + f"{REVOCATION_BASE_PATH}/revocation/record" + "?credential_exchange_id=" + faber_cred_ex_id ) @@ -313,7 +313,7 @@ async def test_get_pending_revocations( pending_revocations = ( await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/get-pending-revocations/{rev_reg_id}" + f"{REVOCATION_BASE_PATH}/get-pending-revocations/{rev_reg_id}" ) ).json()["pending_cred_rev_ids"] @@ -322,13 +322,13 @@ async def test_get_pending_revocations( ) # we expect at least 3 cred_rev_ids can be more if whole module is run await faber_client.post( - f"{CREDENTIALS_BASE_PATH}/clear-pending-revocations", + f"{REVOCATION_BASE_PATH}/clear-pending-revocations", json={"revocation_registry_credential_map": {}}, ) pending_revocations = ( await faber_client.get( - f"{CREDENTIALS_BASE_PATH}/get-pending-revocations/{rev_reg_id}" + f"{REVOCATION_BASE_PATH}/get-pending-revocations/{rev_reg_id}" ) ).json()["pending_cred_rev_ids"] @@ -344,6 +344,33 @@ async def test_get_pending_revocations_bad_payload( faber_client: RichAsyncClient, ): with pytest.raises(HTTPException) as exc: - await faber_client.get(f"{CREDENTIALS_BASE_PATH}/get-pending-revocations/bad") + await faber_client.get(f"{REVOCATION_BASE_PATH}/get-pending-revocations/bad") assert exc.value.status_code == 422 + + +@pytest.mark.anyio +@pytest.mark.skipif( + TestMode.regression_run in TestMode.fixture_params, + reason=skip_regression_test_reason, +) +@pytest.mark.parametrize( + "rev_reg_id, status_code", + [ + ( + "Ddhz428iyF5h96uLUgiuFa:4:Ddhz428iyF5h96uLUgiuFa:3:CL:8:Epic:CL_ACCUM:2e292c76-bc43-496c-a65a-297fc49c21c6", + 404, + ), + ("bad_format", 422), + ], +) +async def test_fix_rev_reg_bad_id( + faber_client: RichAsyncClient, rev_reg_id: str, status_code: int +): + with pytest.raises(HTTPException) as exc: + await faber_client.put( + f"{REVOCATION_BASE_PATH}/fix-revocation-registry/{rev_reg_id}", + params={"apply_ledger_update": False}, + ) + + assert exc.value.status_code == status_code diff --git a/app/tests/routes/revocation/test_fix_revocation_registry.py b/app/tests/routes/revocation/test_fix_revocation_registry.py new file mode 100644 index 000000000..52436aadb --- /dev/null +++ b/app/tests/routes/revocation/test_fix_revocation_registry.py @@ -0,0 +1,147 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from aries_cloudcontroller import RevRegWalletUpdatedResult + +from app.routes.revocation import fix_revocation_registry_entry_state + +rev_reg_id_valid = ( + "RiDathgbUA5Z9fYTqF35Jd:4:RiDathgbUA5Z9fYTqF35Jd:3:CL:8:Epic" + ":CL_ACCUM:1dae952e-b58a-4269-a4b7-526ae1c69156" +) + +rev_reg_no_update = RevRegWalletUpdatedResult( + accum_calculated={ + "ver": "1.0", + "value": { + "prevAccum": "21 some_value FF", + "accum": "21 some_other_value 56", + "revoked": [14, 23, 21, 7, 5], + }, + }, + accum_fixed={}, + rev_reg_delta={ + "ver": "1.0", + "value": { + "accum": "21 some_value FF", + "issued": [], + "revoked": [1, 3, 17], + }, + }, +) + +rev_reg_update = RevRegWalletUpdatedResult( + accum_calculated={ + "ver": "1.0", + "value": { + "prevAccum": "21 some_value FF", + "accum": "21 some_new_value 56", + "revoked": [23, 21, 7, 14, 5], + }, + }, + accum_fixed={ + "txn": { + "type": "114", + "data": { + "revocDefType": "CL_ACCUM", + "revocRegDefId": rev_reg_id_valid, + "value": { + "accum": "21 some_new_value 56", + "prevAccum": "21 some-value FF", + "revoked": [23, 21, 7, 14, 5], + }, + }, + "protocolVersion": 2, + "metadata": { + "from": "RiDathgbUA5Z9fYTqF35Jd", + "reqId": 1732708291768573000, + "taaAcceptance": { + "mechanism": "service_agreement", + "taaDigest": "0be4d87dec17a7901cb8ba8bb4239ee34d4f6e08906f3dad81d1d052dccc078f", + "time": 1732665600, + }, + "digest": "cbbdd50e91571c1919e87823f7c4263817660335ba76276374dada33230dff01", + "payloadDigest": "50886760cea64c365c8a84ef882ede28c8be4ceece2472c3c008c3e7c7e3720d", + }, + }, + "txnMetadata": { + "txnId": f"5:{rev_reg_id_valid}", + "txnTime": 1732708291, + "seqNo": 19, + }, + "reqSignature": { + "type": "ED25519", + "values": [ + { + "from": "RiDathgbUA5Z9fYTqF35Jd", + "value": "2obhaXnyBvaFUBTw6z37Qt8LsX48v959JBQSueRnkc3cjC7Eqchi9x2K5TMDzomProbQ2r4GszSkL5LzZxdMQrvB", + } + ], + }, + "ver": "1", + "rootHash": "4yesC2SPB6SCri6vaJKF3Qu286q2qgqYoQYPazhP4sbE", + "auditPath": [ + "ENZGPfL9Y4d76y3SLjSTjZr5sdnV5mbputNFbwgkYAA6", + "3yhCxb4npZTA3DeTJ6priQUyLFYhfKXpUkUH6rsfcCfR", + ], + }, + rev_reg_delta={ + "ver": "1.0", + "value": { + "accum": "21 some_value FF", + "issued": [], + "revoked": [1, 3, 17], + }, + }, +) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "rev_reg_id, apply_ledger_update, response", + [ + ( + rev_reg_id_valid, + False, + rev_reg_no_update, + ), + ( + rev_reg_id_valid, + True, + rev_reg_update, + ), + ], +) +async def test_fix_revocation_registry_entry_state_success( + rev_reg_id, apply_ledger_update, response +): + mock_aries_controller = AsyncMock() + mock_handle_acapy_call = AsyncMock() + + with patch( + "app.routes.revocation.client_from_auth" + ) as mock_client_from_auth, patch( + "app.routes.revocation.handle_acapy_call", + ) as mock_handle_acapy_call, patch( + "app.routes.revocation.logger" + ) as mock_logger: + + mock_client_from_auth.return_value.__aenter__.return_value = ( + mock_aries_controller + ) + mock_handle_acapy_call.return_value = response + + result = await fix_revocation_registry_entry_state( + revocation_registry_id=rev_reg_id, + apply_ledger_update=apply_ledger_update, + auth="mocked_auth", + ) + + mock_handle_acapy_call.assert_awaited_once_with( + logger=mock_logger.bind(), + acapy_call=mock_aries_controller.revocation.update_rev_reg_revoked_state, + rev_reg_id=rev_reg_id, + apply_ledger_update=apply_ledger_update, + ) + + assert response == result diff --git a/app/tests/services/issuer/test_issuer.py b/app/tests/services/issuer/test_issuer.py index 628d73aa4..c30edaa47 100644 --- a/app/tests/services/issuer/test_issuer.py +++ b/app/tests/services/issuer/test_issuer.py @@ -7,8 +7,7 @@ import app.routes.issuer as test_module from app.dependencies.auth import AcaPyAuth from app.exceptions import CloudApiException -from app.models.issuer import CredentialBase, IndyCredential, RevokeCredential -from app.services import revocation_registry +from app.models.issuer import CredentialBase, IndyCredential from app.services.issuer.acapy_issuer_v2 import IssuerV2 from app.tests.util.mock import to_async from shared.models.credential_exchange import CredentialExchange