diff --git a/app/models/jws.py b/app/models/jws.py index 0f83f0fa7..6b6b0c01a 100644 --- a/app/models/jws.py +++ b/app/models/jws.py @@ -10,7 +10,7 @@ class JWSCreateRequest(BaseModel): None, examples=["did:key:z6MkjCjxuTXxVPWS9JYj2ZiKtKvSS1srC6kBRes4WCB2mSWq"] ) headers: Dict = Field(default={}) - payload: Dict = Field(default={}) + payload: Dict = Field(description="Payload to sign") verification_method: Optional[str] = Field( None, description="Information used for proof verification", @@ -26,8 +26,20 @@ def check_at_least_one_field_is_populated(cls, values): did, verification_method = values.get("did"), values.get("verification_method") if not did and not verification_method: raise CloudApiValueError( - "At least one of `did` or `verification_method` must be populated." + "One of `did` or `verification_method` must be populated." ) + if did and verification_method: + raise CloudApiValueError( + "Only one of `did` or `verification_method` can be populated." + ) + return values + + @model_validator(mode="before") + @classmethod + def check_payload_is_populated(cls, values): + payload = values.get("payload") + if not payload: + raise CloudApiValueError("`payload` must be populated.") return values diff --git a/app/routes/wallet/jws.py b/app/routes/wallet/jws.py index ab8329d8f..db3a8164a 100644 --- a/app/routes/wallet/jws.py +++ b/app/routes/wallet/jws.py @@ -23,15 +23,72 @@ "/sign", response_model=JWSCreateResponse, summary="Sign JWS", - description=""" -Sign JSON Web Signature (JWS) - -See https://www.rfc-editor.org/rfc/rfc7515.html for the JWS spec.""", ) async def sign_jws( body: JWSCreateRequest, auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> JWSCreateResponse: + """ + Sign a JSON Web Signature (JWS). + --- + + This endpoint allows users to sign a JSON payload, creating a JWS, + using either a DID or a specific verification method. + + **Usage:** + + - **DID-Based Signing:** Provide the `did` field with a valid DID. + The Aries agent will automatically select the appropriate verification key associated with the DID. + + - **Verification Method-Based Signing:** Provide the `verification_method` field with a specific verification method + (DID with a verkey) to explicitly specify which key to use for signing. + + **Notes:** + + - If the issuer uses a `did:sov` DID, ensure that the DID is public. + - The `header` field is optional. While you can specify custom headers, the `typ`, `alg`, + and `kid` fields are automatically populated by the Aries agent based on the signing method. + + Example request body: + ```json + { + "did": "did:sov:WWMjrBJkUzz9suEtwKxmiY", + "payload": { + "credential_subject": "reference_to_holder", + "name": "Alice", + "surname": "Demo" + } + } + ``` + **OR** + ```json + { + "payload": { + "subject": "reference_to_holder", + "name": "Alice", + "surname": "Demo" + }, + "verification_method": "did:key:z6Mkprf81ujG1n48n5LMD...M6S3#z6Mkprf81ujG1n48n5LMDaxyCLLFrnqCRBPhkTWsPfA8M6S3" + } + ``` + + Request Body: + --- + JWSCreateRequest: + `did` (str, optional): The DID to sign the JWS with. + `verification_method` (str, optional): The verification method (DID with verkey) to use for signing. + `payload` (dict): The JSON payload to be signed. + `headers` (dict, optional): Custom headers for the JWS. + + Response: + --- + JWSCreateResponse: + `jws` (str): The resulting JWS string representing the signed JSON Web Signature. + + **References:** + + - [JSON Web Signature (JWS) Specification](https://www.rfc-editor.org/rfc/rfc7515.html) + """ bound_logger = logger.bind( # Do not log payload: body=body.model_dump(exclude="payload") @@ -64,15 +121,41 @@ async def sign_jws( "/verify", response_model=JWSVerifyResponse, summary="Verify JWS", - description=""" -Verify JSON Web Signature (JWS) - -See https://www.rfc-editor.org/rfc/rfc7515.html for the JWS spec.""", ) async def verify_jws( body: JWSVerifyRequest, auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> JWSVerifyResponse: + """ + Verify a JSON Web Signature (JWS) + --- + + This endpoint allows users to verify the authenticity and integrity of a JWS string previously generated + by the /sign endpoint. It decodes the JWS to retrieve the payload and headers and assesses its validity. + + Request Body: + --- + JWSVerifyRequest: The JWS to verify. + jws: str + + Returns: + --- + JWSVerifyResponse + payload: dict: + The payload of the JWS. + headers: dict: + The headers of the JWS. + kid: str: + The key id of the signer. + valid: bool: + Whether the JWS is valid. + error: str: + The error message if the JWS is invalid. + + **References:** + + - [JSON Web Signature (JWS) Specification](https://www.rfc-editor.org/rfc/rfc7515.html) + """ bound_logger = logger.bind(body=body) bound_logger.debug("POST request received: Verify JWS") diff --git a/app/routes/wallet/sd_jws.py b/app/routes/wallet/sd_jws.py index d41ad6aab..31d5033e7 100644 --- a/app/routes/wallet/sd_jws.py +++ b/app/routes/wallet/sd_jws.py @@ -23,16 +23,91 @@ "/sign", response_model=SDJWSCreateResponse, summary="Sign SD-JWS", - description=""" -Sign Select Disclosure for JWS (SD-JWS) - -See https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html for the SD-JWT / SD-JWS spec. -""", ) async def sign_sd_jws( body: SDJWSCreateRequest, auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> SDJWSCreateResponse: + """ + Sign a Selective Disclosure JSON Web Signature (SD-JWS). + --- + + This endpoint allows users to create a Selective Disclosure JSON Web Signature (SD-JWS). + The SD-JWS enables the selective disclosure of specific attributes to a verifier while keeping others confidential. + + **Usage:** + + - **DID-Based Signing:** Provide the `did` field with a valid DID. + The Aries agent will automatically select the appropriate verification key associated with the DID. + + - **Verification Method-Based Signing:** Provide the `verification_method` field with a specific verification method + (DID with verkey) to explicitly specify which key to use for signing. + + **Notes:** + + - If the issuer uses a `did:sov` DID, ensure that the DID is public. + - The `headers` field is optional. Custom headers can be specified, but the `typ`, `alg`, + and `kid` fields are automatically populated by the Aries agent based on the signing method. + - The `non_sd_list` field specifies attributes that are **not** selectively disclosed. + Attributes listed here will always be included in the SD-JWS. + + **Non-Selective Disclosure (`non_sd_list`):** + + - To exclude list elements: + - Use the format `"[start:end]"` where `start` and `end` define the range + (e.g., `"nationalities[1:3]"`). + - To exclude specific dictionary attributes: + - Use the format `"."` (e.g., `"address.street_address"`). + + **Example Request Body:** + ```json + { + "did": "did:sov:39TXHazGAYif5FUFCjQhYX", + "payload": { + "credential_subject": "reference_to_holder", + "given_name": "John", + "family_name": "Doe", + "email": "johndoe@example.com", + "phone_number": "+27-123-4567", + "nationalities": ["a","b","c","d"], + "address": { + "street_address": "123 Main St", + "locality": "Anytown", + "region": "Anywhere", + "country": "ZA" + }, + "birthdate": "1940-01-01" + }, + "non_sd_list": [ + "given_name", + "address", + "address.street_address", + "nationalities", + "nationalities[1:3]" + ] + } + ``` + + Request Body: + --- + SDJWSCreateRequest: + `did` (str, optional): The DID to sign the SD-JWS with. + `verification_method` (str, optional): The verification method (DID with verkey) to use for signing. + `payload` (dict): The JSON payload to be signed. + `headers` (dict, optional): Custom headers for the SD-JWS. + `non_sd_list` (List[str], optional): List of attributes excluded from selective disclosure. + + Response: + --- + SDJWSCreateResponse: + `sd_jws` (str): The resulting SD-JWS string concatenated with the necessary disclosures in the format + `~~~...~`. + + **References:** + + - [Selective Disclosure JSON Web Token (SD-JWT) + Specification](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html) + """ bound_logger = logger.bind( # Do not log payload: body=body.model_dump(exclude="payload") @@ -65,16 +140,57 @@ async def sign_sd_jws( "/verify", response_model=SDJWSVerifyResponse, summary="Verify SD-JWS", - description=""" -Verify Select Disclosure for JWS (SD-JWS) - -See https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html for the SD-JWT / SD-JWS spec. -""", ) async def verify_sd_jws( body: SDJWSVerifyRequest, auth: AcaPyAuth = Depends(acapy_auth_from_header), ) -> SDJWSVerifyResponse: + """ + Verify a Selective Disclosure JSON Web Signature (SD-JWS). + --- + + This endpoint allows users to verify the authenticity and integrity of a Selective Disclosure + JSON Web Signature (SD-JWS). It decodes the SD-JWS to retrieve the payload and headers, + assesses its validity, and processes the disclosures. + + **Usage:** + + - Submit the SD-JWS string concatenated with the necessary disclosures to this endpoint. + - The format should be: `~~~...~`. + - The holder provides the SD-JWS along with the required disclosures based on the verifier's request. + + **Notes:** + + - Only the disclosures relevant to the verifier's request needs to be provided. + Other disclosures can remain confidential. + + **Example Request Body:** + ```json + { + "sd_jws": "~~~...~" + } + ``` + + Request Body: + --- + SDJWSVerifyRequest: + `sd_jws` (str): The concatenated SD-JWS and disclosures to verify and reveal. + + Response: + --- + SDJWSVerifyResponse: + `valid` (bool): Indicates whether the SD-JWS is valid. + `payload` (dict): The decoded payload of the SD-JWS. + `headers` (dict): The headers extracted from the SD-JWS. + `kid` (str): The Key ID of the signer. + `disclosed_attributes` (dict): The selectively disclosed attributes based on the provided disclosures. + `error` (str, optional): Error message if the SD-JWS verification fails. + + **References:** + + - [Selective Disclosure JSON Web Token (SD-JWT) + Specification](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html) + """ bound_logger = logger.bind(body=body) bound_logger.debug("POST request received: Verify SD-JWS") diff --git a/app/tests/models/test_jws.py b/app/tests/models/test_jws.py new file mode 100644 index 000000000..64e20d04c --- /dev/null +++ b/app/tests/models/test_jws.py @@ -0,0 +1,32 @@ +import pytest + +from app.models.jws import JWSCreateRequest +from shared.exceptions.cloudapi_value_error import CloudApiValueError + + +def test_jws_create_request(): + # no did or verification_method + with pytest.raises(CloudApiValueError) as exc: + JWSCreateRequest(payload={"test": "test_value"}) + + assert exc.value.detail == ( + "One of `did` or `verification_method` must be populated." + ) + + # did and verification_method + with pytest.raises(CloudApiValueError) as exc: + JWSCreateRequest( + did="did:sov:AGguR4mc186Tw11KeWd4qq", + payload={"test": "test_value"}, + verification_method="did:sov:AGguR4mc186Tw11KeWd4qq", + ) + + assert exc.value.detail == ( + "Only one of `did` or `verification_method` can be populated." + ) + + # no payload + with pytest.raises(CloudApiValueError) as exc: + JWSCreateRequest(did="did:sov:AGguR4mc186Tw11KeWd4qq") + + assert exc.value.detail == ("`payload` must be populated.") diff --git a/app/tests/routes/wallet/jws/test_sign_jws.py b/app/tests/routes/wallet/jws/test_sign_jws.py new file mode 100644 index 000000000..442cc879f --- /dev/null +++ b/app/tests/routes/wallet/jws/test_sign_jws.py @@ -0,0 +1,94 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from aries_cloudcontroller import JWSCreate +from pydantic import ValidationError + +from app.exceptions import CloudApiException +from app.models.jws import JWSCreateRequest +from app.routes.wallet.jws import sign_jws + + +@pytest.mark.anyio +async def test_sign_jws_success(): + + jws = ( + "eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJFZERTQSIsICJraWQiOiAiZGlkOnNvdjpBR2d1UjRtYzE4NlR3MTFLZVdkNHFxI2" + "tleS0xIn0.eyJ0ZXN0IjogInRlc3RfdmFsdWUifQ.3IxwPkA2niDxCsd12kDRVveR-aPBJx7YibWy9fbrFTSWbITQ16CqA0" + "AR5_M4StTauO3_t063Mjno32O0wqcbDg" + ) + + mock_aries_controller = AsyncMock() + mock_handle_acapy_call = AsyncMock() + mock_handle_acapy_call.return_value = jws + request_body = JWSCreateRequest( + did="did:sov:AGguR4mc186Tw11KeWd4qq", payload={"test": "test_value"} + ) + + payload = JWSCreate(**request_body.model_dump()) + + with patch( + "app.routes.wallet.jws.client_from_auth" + ) as mock_client_from_auth, patch( + "app.routes.wallet.jws.handle_acapy_call", mock_handle_acapy_call + ), patch( + "app.routes.wallet.jws.logger" + ) as mock_logger: + + mock_client_from_auth.return_value.__aenter__.return_value = ( + mock_aries_controller + ) + + result = await sign_jws(body=request_body, auth="mocked_auth") + + mock_handle_acapy_call.assert_awaited_once_with( + logger=mock_logger.bind(), + acapy_call=mock_aries_controller.wallet.sign_jwt, + body=payload, + ) + + assert result.jws == jws + + +@pytest.mark.anyio +async def test_sign_jws_validation_error(): + error_msg = "Validation error message" + + # Create a request that will trigger a ValidationError + request_body = JWSCreateRequest( + did="did:sov:AGguR4mc186Tw11KeWd4qq", payload={"test": "test_value"} + ) + + # Mock the JWSCreate to raise ValidationError + mock_validation_error = ValidationError.from_exception_data( + title="ValidationError", + line_errors=[ + { + "loc": ("field",), + "msg": "error message", + "type": "value_error", + "input": "invalid_input", + "ctx": {"error": "some context"}, + } + ], + ) + + with patch("app.routes.wallet.jws.JWSCreate") as mock_jws_create, patch( + "app.routes.wallet.jws.logger" + ) as mock_logger, patch( + "app.routes.wallet.jws.extract_validation_error_msg", return_value=error_msg + ): + mock_jws_create.side_effect = mock_validation_error + + # Assert that the function raises CloudApiException with correct status code + with pytest.raises(CloudApiException) as exc_info: + await sign_jws(body=request_body, auth="mocked_auth") + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == error_msg + + # Verify logging calls + mock_logger.bind.assert_called_once() + mock_logger.bind().info.assert_called_once_with( + "Bad request: Validation error from JWSCreateRequest body: {}", error_msg + ) diff --git a/app/tests/routes/wallet/jws/test_verify_jws.py b/app/tests/routes/wallet/jws/test_verify_jws.py new file mode 100644 index 000000000..fefe98001 --- /dev/null +++ b/app/tests/routes/wallet/jws/test_verify_jws.py @@ -0,0 +1,114 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aries_cloudcontroller import JWSVerify +from pydantic import ValidationError + +from app.exceptions import CloudApiException +from app.models.jws import JWSVerifyRequest, JWSVerifyResponse +from app.routes.wallet.jws import verify_jws + + +@pytest.mark.anyio +async def test_verify_jws_success(): + # Sample JWS string + jws = ( + "eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJFZERTQSIsICJraWQiOiAiZGlkOnNvdjpBR2d1UjRtYzE4NlR3MTFLZVdkNHFxI2" + "tleS0xIn0.eyJ0ZXN0IjogInRlc3RfdmFsdWUifQ.3IxwPkA2niDxCsd12kDRVveR-aPBJx7YibWy9fbrFTSWbITQ16CqA0" + "AR5_M4StTauO3_t063Mjno32O0wqcbDg" + ) + + # Mock response data + verify_result_data = { + "payload": {"test": "test_value"}, + "headers": { + "typ": "JWT", + "alg": "EdDSA", + "kid": "did:sov:AGguR4mc186Tw11KeWd4qq#key-1", + }, + "kid": "did:sov:AGguR4mc186Tw11KeWd4qq#key-1", + "valid": True, + "error": None, + } + + mock_aries_controller = AsyncMock() + mock_handle_acapy_call = AsyncMock() + mock_verify_result = MagicMock() + mock_verify_result.model_dump.return_value = verify_result_data + mock_handle_acapy_call.return_value = mock_verify_result + + request_body = JWSVerifyRequest(jws=jws) + verify_request = JWSVerify(jwt=request_body.jws) + + with patch( + "app.routes.wallet.jws.client_from_auth" + ) as mock_client_from_auth, patch( + "app.routes.wallet.jws.handle_acapy_call", mock_handle_acapy_call + ), patch( + "app.routes.wallet.jws.logger" + ) as mock_logger: + mock_client_from_auth.return_value.__aenter__.return_value = ( + mock_aries_controller + ) + result = await verify_jws(body=request_body, auth="mocked_auth") + + # Assert the acapy call was made correctly + mock_handle_acapy_call.assert_awaited_once_with( + logger=mock_logger.bind(), + acapy_call=mock_aries_controller.wallet.verify_jwt, + body=verify_request, + ) + + # Assert the response matches expected data + assert isinstance(result, JWSVerifyResponse) + assert result.payload == verify_result_data["payload"] + assert result.headers == verify_result_data["headers"] + assert result.kid == verify_result_data["kid"] + assert result.valid == verify_result_data["valid"] + assert result.error == verify_result_data["error"] + + +@pytest.mark.anyio +async def test_verify_jws_validation_error(): + error_msg = "field required" + modified_error_msg = error_msg.replace( + "jwt", "jws" + ) # Match the error message modification in the code + + # Create a request that will trigger a ValidationError + request_body = JWSVerifyRequest(jws="invalid_jws") + + # Create a ValidationError with proper error data structure + mock_validation_error = ValidationError.from_exception_data( + title="ValidationError", + line_errors=[ + { + "loc": ("jwt",), + "msg": error_msg, + "type": "value_error", + "input": "invalid_input", + "ctx": {"error": "some context"}, + } + ], + ) + + with patch("app.routes.wallet.jws.JWSVerify") as mock_jws_verify, patch( + "app.routes.wallet.jws.logger" + ) as mock_logger, patch( + "app.routes.wallet.jws.extract_validation_error_msg", return_value=error_msg + ): + mock_jws_verify.side_effect = mock_validation_error + + # Assert that the function raises CloudApiException with correct status code + with pytest.raises(CloudApiException) as exc_info: + await verify_jws(body=request_body, auth="mocked_auth") + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == modified_error_msg + + # Verify logging calls + mock_logger.bind.assert_called_once() + mock_logger.bind().info.assert_called_once_with( + "Bad request: Validation error from JWSVerifyRequest body: {}", + modified_error_msg, + ) diff --git a/app/tests/routes/wallet/sd_jws/test_sign_sd_jws.py b/app/tests/routes/wallet/sd_jws/test_sign_sd_jws.py new file mode 100644 index 000000000..89c7df01e --- /dev/null +++ b/app/tests/routes/wallet/sd_jws/test_sign_sd_jws.py @@ -0,0 +1,117 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from aries_cloudcontroller import SDJWSCreate +from pydantic import ValidationError + +from app.exceptions import CloudApiException +from app.models.sd_jws import SDJWSCreateRequest +from app.routes.wallet.sd_jws import sign_sd_jws + + +@pytest.mark.anyio +async def test_sign_jws_success(): + + sd_jws = ( + "eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJFZERTQSIsICJraWQiOiAiZGlkOnNvdjpBR2d1UjRtYzE4NlR3MTFLZVdkNHFxI2" + "tleS0xIn0.eyJ0ZXN0IjogInRlc3RfdmFsdWUifQ.3IxwPkA2niDxCsd12kDRVveR-aPBJx7YibWy9fbrFTSWbITQ16CqA0" + "AR5_M4StTauO3_t063Mjno32O0wqcbDg" + ) + mock_aries_controller = AsyncMock() + mock_handle_acapy_call = AsyncMock() + mock_handle_acapy_call.return_value = sd_jws + request_body = SDJWSCreateRequest( + did="did:sov:ULAXi4asp1MCvFg3QAFpxt", + payload={ + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "given_name": "John", + "family_name": "Doe", + "email": "johndoe@example.com", + "phone_number": "+1-202-555-0101", + "address": { + "street_address": "123 Main St", + "locality": "Anytown", + "region": "Anystate", + "country": "US", + }, + "birthdate": "1940-01-01", + }, + ) + + payload = SDJWSCreate(**request_body.model_dump()) + + with patch( + "app.routes.wallet.sd_jws.client_from_auth" + ) as mock_client_from_auth, patch( + "app.routes.wallet.sd_jws.handle_acapy_call", mock_handle_acapy_call + ), patch( + "app.routes.wallet.sd_jws.logger" + ) as mock_logger: + + mock_client_from_auth.return_value.__aenter__.return_value = ( + mock_aries_controller + ) + + result = await sign_sd_jws(body=request_body, auth="mocked_auth") + + mock_handle_acapy_call.assert_awaited_once_with( + logger=mock_logger.bind(), + acapy_call=mock_aries_controller.wallet.sign_sd_jwt, + body=payload, + ) + + assert result.sd_jws == sd_jws + + +@pytest.mark.anyio +async def test_sign_jws_validation_error(): + error_msg = "Validation error message" + + request_body = SDJWSCreateRequest( + did="did:sov:ULAXi4asp1MCvFg3QAFpxt", + payload={ + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "given_name": "John", + "family_name": "Doe", + "email": "johndoe@example.com", + "phone_number": "+1-202-555-0101", + "address": { + "street_address": "123 Main St", + "locality": "Anytown", + "region": "Anystate", + "country": "US", + }, + "birthdate": "1940-01-01", + }, + ) + + mock_validation_error = ValidationError.from_exception_data( + title="ValidationError", + line_errors=[ + { + "loc": ("field",), + "msg": "error message", + "type": "value_error", + "input": "invalid_input", + "ctx": {"error": "some context"}, + } + ], + ) + + with patch("app.routes.wallet.sd_jws.SDJWSCreate") as mock_jws_create, patch( + "app.routes.wallet.sd_jws.logger" + ) as mock_logger, patch( + "app.routes.wallet.sd_jws.extract_validation_error_msg", return_value=error_msg + ): + mock_jws_create.side_effect = mock_validation_error + + with pytest.raises(CloudApiException) as exc_info: + await sign_sd_jws(body=request_body, auth="mocked_auth") + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == error_msg + + mock_logger.bind.assert_called_once() + mock_logger.bind().info.assert_called_once_with( + "Bad request: Validation error from SDJWSCreateRequest body: {}", error_msg + ) diff --git a/app/tests/routes/wallet/sd_jws/test_verify_sd_jws.py b/app/tests/routes/wallet/sd_jws/test_verify_sd_jws.py new file mode 100644 index 000000000..ed2e75d0d --- /dev/null +++ b/app/tests/routes/wallet/sd_jws/test_verify_sd_jws.py @@ -0,0 +1,141 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aries_cloudcontroller import SDJWSVerify +from pydantic import ValidationError + +from app.exceptions import CloudApiException +from app.models.sd_jws import SDJWSVerifyRequest, SDJWSVerifyResponse +from app.routes.wallet.sd_jws import verify_sd_jws + + +@pytest.mark.anyio +async def test_verify_jws_success(): + sd_jws = ( + "eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJFZERTQSIsICJraWQiOiAiZGlkOnNvdjpBR2d1UjRtYzE4NlR3MTFLZVdkNHFxI2" + "tleS0xIn0.eyJ0ZXN0IjogInRlc3RfdmFsdWUifQ.3IxwPkA2niDxCsd12kDRVveR-aPBJx7YibWy9fbrFTSWbITQ16CqA0" + "AR5_M4StTauO3_t063Mjno32O0wqcbDg" + ) + + verify_result_data = { + "error": None, + "headers": { + "typ": "JWT", + "alg": "EdDSA", + "kid": "did:sov:ULAXi4asp1MCvFg3QAFpxt#key-1", + }, + "kid": "did:sov:ULAXi4asp1MCvFg3QAFpxt#key-1", + "payload": { + "_sd": [ + "EC11V8vqqgEzOa3AN8yVLXRxccwlvgsLnnE65sswodc", + "L3qtnA4G4qPgeQRpQB-ElVjiVb359mUekdSthnlbSm4", + "YM5B1pv75DyS-NrV9pp0MSjsQ-flZGWLRH4LIFIB-Ak", + "_T5gi7uVTGnVYO-ZlCf1Kpi2hin6bbQEHVBcyc2Eoos", + "c08PRylz5JC48qTKCS2gB8m8w5_rwdyR_21rU-Lihy4", + "iCFc_OttaWX4-xyQKvtiap2lj23559F9L71dGRgxBtU", + "jD2CdSZhoXqmWU8cLpVDO--jmGMUA9X69egNct1Fy3o", + ], + "_sd_alg": "sha-256", + }, + "valid": True, + "disclosures": [ + ["jSJmzgsBzr5FihgNN-c-cQ", "sub", "6c5c0a49-b589-431d-bae7-219122a9ec2c"], + ["_NNXgraBDZ4sBj1FuHhR9A", "phone_number", "+1-202-555-0101"], + ["eHyW2lNnhV6leIAKKknw1g", "given_name", "John"], + ["W7_cQ9t7Ku51QsnmGs6N2Q", "family_name", "Doe"], + ["ZXAQfGxkHJQW_710pAYBOQ", "email", "johndoe@example.com"], + ["14nrze3QYl7kp9T6EMSVgA", "birthdate", "1940-01-01"], + ["Sb_A9zLRuIM7GuqoifMxtg", "street_address", "123 Main St"], + ["nVNPkPSS3LAegaMpxcdZug", "region", "Anystate"], + ["qYjnqrWKte6xqch5i09ifQ", "locality", "Anytown"], + ["XuUKAq8jhvviIa5NmUMvJg", "country", "US"], + [ + "WkL3DTJPeIuOpYtI-o4O8w", + "address", + { + "_sd": [ + "26rGNWx31pylSeigFTp9pgNknJEHugnQ2z2Dw61j4UU", + "FlZhssejmKlEuy_iCNYdaSlkNJA9WoANlQvGA6x7to4", + "su6kv3Dx1hurEcMAWTeRUY4uq70zhaQLu81132LYcyE", + "wVRA97TzcFLZGBzBDSAECSjdJ7TKVRSyuKxqtr6Hg_E", + ] + }, + ], + ], + } + + mock_aries_controller = AsyncMock() + mock_handle_acapy_call = AsyncMock() + mock_verify_result = MagicMock() + mock_verify_result.model_dump.return_value = verify_result_data + mock_handle_acapy_call.return_value = mock_verify_result + + request_body = SDJWSVerifyRequest(sd_jws=sd_jws) + verify_request = SDJWSVerify(sd_jwt=request_body.sd_jws) + + with patch( + "app.routes.wallet.sd_jws.client_from_auth" + ) as mock_client_from_auth, patch( + "app.routes.wallet.sd_jws.handle_acapy_call", mock_handle_acapy_call + ), patch( + "app.routes.wallet.sd_jws.logger" + ) as mock_logger: + mock_client_from_auth.return_value.__aenter__.return_value = ( + mock_aries_controller + ) + result = await verify_sd_jws(body=request_body, auth="mocked_auth") + + # Assert the acapy call was made correctly + mock_handle_acapy_call.assert_awaited_once_with( + logger=mock_logger.bind(), + acapy_call=mock_aries_controller.wallet.verify_sd_jwt, + body=verify_request, + ) + + # Assert the response matches expected data + assert isinstance(result, SDJWSVerifyResponse) + assert result.payload == verify_result_data["payload"] + assert result.headers == verify_result_data["headers"] + assert result.kid == verify_result_data["kid"] + assert result.valid == verify_result_data["valid"] + assert result.error == verify_result_data["error"] + assert result.disclosures == verify_result_data["disclosures"] + + +@pytest.mark.anyio +async def test_verify_jws_validation_error(): + error_msg = "field required" + modified_error_msg = error_msg.replace("jwt", "jws") + request_body = SDJWSVerifyRequest(sd_jws="invalid_sd_jws") + + mock_validation_error = ValidationError.from_exception_data( + title="ValidationError", + line_errors=[ + { + "loc": ("jwt",), + "msg": error_msg, + "type": "value_error", + "input": "invalid_input", + "ctx": {"error": "some context"}, + } + ], + ) + + with patch("app.routes.wallet.sd_jws.SDJWSVerify") as mock_jws_verify, patch( + "app.routes.wallet.sd_jws.logger" + ) as mock_logger, patch( + "app.routes.wallet.sd_jws.extract_validation_error_msg", return_value=error_msg + ): + mock_jws_verify.side_effect = mock_validation_error + + with pytest.raises(CloudApiException) as exc_info: + await verify_sd_jws(body=request_body, auth="mocked_auth") + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail == modified_error_msg + + mock_logger.bind.assert_called_once() + mock_logger.bind().info.assert_called_once_with( + "Bad request: Validation error from SDJWSVerifyRequest body: {}", + modified_error_msg, + )