Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions acapy_agent/anoncreds/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
FIRST_REGISTRY_TAG = str(0) # This tag is used to signify it is the first registry


# Schema finished event
SCHEMA_FINISHED_EVENT = "anoncreds::schema::finished"

# Initial credential definition event, kicks off the revocation setup process
CRED_DEF_FINISHED_EVENT = "anoncreds::credential-definition::finished"

Expand Down Expand Up @@ -102,6 +105,7 @@ class CredDefFinishedPayload(NamedTuple):
issuer_id: str
support_revocation: bool
max_cred_num: int
tag: str
options: dict


Expand Down Expand Up @@ -131,6 +135,7 @@ def with_payload(
issuer_id: str,
support_revocation: bool,
max_cred_num: int,
tag: str,
options: Optional[dict] = None,
):
"""With payload."""
Expand All @@ -140,6 +145,7 @@ def with_payload(
issuer_id=issuer_id,
support_revocation=support_revocation,
max_cred_num=max_cred_num,
tag=tag,
options=options or {},
)
return cls(payload)
Expand All @@ -150,6 +156,62 @@ def payload(self) -> CredDefFinishedPayload:
return self._payload


class SchemaFinishedPayload(NamedTuple):
"""Payload of schema finished event."""

schema_id: str
issuer_id: str
name: str
version: str
attr_names: list
options: dict


class SchemaFinishedEvent(Event):
"""Event for schema finished."""

event_topic = SCHEMA_FINISHED_EVENT

def __init__(
self,
payload: SchemaFinishedPayload,
):
"""Initialize an instance.

Args:
payload: SchemaFinishedPayload

"""
self._topic = self.event_topic
self._payload = payload

@classmethod
def with_payload(
cls,
schema_id: str,
issuer_id: str,
name: str,
version: str,
attr_names: list,
options: Optional[dict] = None,
):
"""With payload."""
payload = SchemaFinishedPayload(
schema_id=schema_id,
issuer_id=issuer_id,
name=name,
version=version,
attr_names=attr_names,
options=options or {},
)
return cls(payload)

@property
def payload(self) -> SchemaFinishedPayload:
"""Return payload."""
return self._payload


class RevRegDefFinishedPayload(NamedTuple):
"""Payload of rev reg def finished event."""

Expand Down
39 changes: 36 additions & 3 deletions acapy_agent/anoncreds/issuer.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
STATE_FINISHED,
)
from .error_messages import ANONCREDS_PROFILE_REQUIRED_MSG
from .events import CredDefFinishedEvent
from .events import CredDefFinishedEvent, SchemaFinishedEvent
from .models.credential_definition import CredDef, CredDefResult
from .models.schema import AnonCredsSchema, GetSchemaResult, SchemaResult, SchemaState
from .registry import AnonCredsRegistry
Expand Down Expand Up @@ -156,6 +156,18 @@ async def store_schema(
"state": result.schema_state.state,
},
)

if result.schema_state.state == STATE_FINISHED:
await self.notify(
SchemaFinishedEvent.with_payload(
schema_id=result.schema_state.schema_id,
issuer_id=result.schema_state.schema.issuer_id,
name=result.schema_state.schema.name,
version=result.schema_state.schema.version,
attr_names=result.schema_state.schema.attr_names,
options={},
)
)
except DBError as err:
raise AnonCredsIssuerError("Error storing schema") from err

Expand Down Expand Up @@ -240,9 +252,25 @@ async def create_and_register_schema(
async def finish_schema(self, job_id: str, schema_id: str) -> None:
"""Mark a schema as finished."""
async with self.profile.transaction() as txn:
await self._finish_registration(txn, CATEGORY_SCHEMA, job_id, schema_id)
entry = await self._finish_registration(
txn, CATEGORY_SCHEMA, job_id, schema_id
)
await txn.commit()

from .models.schema import AnonCredsSchema

schema = AnonCredsSchema.from_json(entry.value)
await self.notify(
SchemaFinishedEvent.with_payload(
schema_id=schema_id,
issuer_id=schema.issuer_id,
name=schema.name,
version=schema.version,
attr_names=schema.attr_names,
options={},
)
)

async def get_created_schemas(
self,
name: Optional[str] = None,
Expand Down Expand Up @@ -434,13 +462,17 @@ async def store_credential_definition(
await txn.commit()

if cred_def_result.credential_definition_state.state == STATE_FINISHED:
cred_def = (
cred_def_result.credential_definition_state.credential_definition
)
await self.notify(
CredDefFinishedEvent.with_payload(
schema_id=schema_result.schema_id,
cred_def_id=identifier,
issuer_id=cred_def_result.credential_definition_state.credential_definition.issuer_id,
issuer_id=cred_def.issuer_id,
support_revocation=support_revocation,
max_cred_num=max_cred_num,
tag=cred_def.tag,
options=options,
)
)
Expand Down Expand Up @@ -477,6 +509,7 @@ async def finish_cred_def(
issuer_id=cred_def.issuer_id,
support_revocation=support_revocation,
max_cred_num=max_cred_num,
tag=cred_def.tag,
options=options,
)
)
Expand Down
87 changes: 80 additions & 7 deletions acapy_agent/anoncreds/tests/test_issuer.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from ...tests import mock
from ...utils.testing import create_test_profile
from .. import issuer as test_module
from ..events import CredDefFinishedEvent, SchemaFinishedEvent


class MockSchemaEntry:
Expand Down Expand Up @@ -156,10 +157,13 @@ async def test_create_and_register_schema_finds_schema_raises_x(
attr_names=["attr1", "attr2"],
)

@mock.patch.object(test_module.AnonCredsIssuer, "notify")
@mock.patch.object(test_module.AnonCredsIssuer, "store_schema")
@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_create_and_register_schema(self, mock_session_handle):
async def test_create_and_register_schema(
self, mock_session_handle, mock_store_schema, mock_notify
):
mock_session_handle.fetch_all = mock.CoroutineMock(return_value=[])
mock_session_handle.insert = mock.CoroutineMock(return_value=None)
self.profile.inject = mock.Mock(
return_value=mock.MagicMock(
register_schema=mock.CoroutineMock(return_value=get_mock_schema_result())
Expand All @@ -174,11 +178,12 @@ async def test_create_and_register_schema(self, mock_session_handle):

assert result is not None
mock_session_handle.fetch_all.assert_called_once()
mock_session_handle.insert.assert_called_once()
mock_store_schema.assert_called_once()

@mock.patch.object(test_module.AnonCredsIssuer, "notify")
@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_create_and_register_schema_missing_schema_id_or_job_id(
self, mock_session_handle
self, mock_session_handle, mock_notify
):
mock_session_handle.fetch_all = mock.CoroutineMock(return_value=[])
mock_session_handle.insert = mock.CoroutineMock(return_value=None)
Expand Down Expand Up @@ -278,9 +283,10 @@ async def test_create_and_register_schema_fail_insert(self, mock_session_handle)
mock_session_handle.fetch_all.assert_called_once()
mock_session_handle.insert.assert_called_once()

@mock.patch.object(test_module.AnonCredsIssuer, "notify")
@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_create_and_register_schema_already_exists_but_not_in_wallet(
self, mock_session_handle
self, mock_session_handle, mock_notify
):
mock_session_handle.fetch_all = mock.CoroutineMock(return_value=[])
mock_session_handle.insert = mock.CoroutineMock(return_value=None)
Expand Down Expand Up @@ -308,9 +314,10 @@ async def test_create_and_register_schema_already_exists_but_not_in_wallet(
attr_names=["attr1", "attr2"],
)

@mock.patch.object(test_module.AnonCredsIssuer, "notify")
@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_create_and_register_schema_without_job_id_or_schema_id_raises_x(
self, mock_session_handle
self, mock_session_handle, mock_notify
):
mock_session_handle.fetch_all = mock.CoroutineMock(return_value=[])
mock_session_handle.insert = mock.CoroutineMock(return_value=None)
Expand Down Expand Up @@ -381,14 +388,68 @@ async def test_create_and_register_schema_with_endorsed_transaction_response_doe
assert isinstance(result, SchemaResult)
assert mock_store_schema.called

async def test_finish_schema(self):
@mock.patch.object(test_module.AnonCredsIssuer, "notify")
@mock.patch.object(test_module.AnonCredsIssuer, "_finish_registration")
async def test_finish_schema(self, mock_finish_registration, mock_notify):
# Mock entry with valid schema JSON
mock_entry = mock.MagicMock()
mock_entry.value = json.dumps(
{
"issuerId": "issuer-id",
"name": "name",
"version": "1.0",
"attrNames": ["attr1", "attr2"],
}
)
mock_finish_registration.return_value = mock_entry
self.profile.transaction = mock.Mock(
return_value=mock.MagicMock(
commit=mock.CoroutineMock(return_value=None),
)
)
await self.issuer.finish_schema(job_id="job-id", schema_id="schema-id")

# Verify schema event was emitted with correct payload
mock_notify.assert_called_once()
call_args = mock_notify.call_args
assert isinstance(call_args[0][0], SchemaFinishedEvent)
event = call_args[0][0]
assert event.payload.schema_id == "schema-id"
assert event.payload.issuer_id == "issuer-id"
assert event.payload.name == "name"
assert event.payload.version == "1.0"
assert event.payload.attr_names == ["attr1", "attr2"]
assert event.payload.options == {}

@mock.patch.object(test_module.AnonCredsIssuer, "notify")
async def test_store_schema_emits_event(self, mock_notify):
"""Test that store_schema emits SchemaFinishedEvent when state is finished."""
# Mock profile.session() for store_schema - it returns an async context manager
mock_session_handle = mock.MagicMock()
mock_session_handle.insert = mock.CoroutineMock(return_value=None)
mock_session = mock.MagicMock()
mock_session.handle = mock_session_handle # Set handle property
# __aenter__ and __aexit__ must be coroutines (async methods)
mock_session.__aenter__ = mock.CoroutineMock(return_value=mock_session)
mock_session.__aexit__ = mock.CoroutineMock(return_value=None)
# profile.session() is a method that returns an async context manager (not a coroutine)
self.profile.session = mock.Mock(return_value=mock_session)

schema_result = get_mock_schema_result()
await self.issuer.store_schema(schema_result)

# Verify schema event was emitted
mock_notify.assert_called_once()
call_args = mock_notify.call_args
assert isinstance(call_args[0][0], SchemaFinishedEvent)
event = call_args[0][0]
assert event.payload.schema_id == "schema-id"
assert event.payload.issuer_id == "issuer-id"
assert event.payload.name == "name"
assert event.payload.version == "1.0"
assert event.payload.attr_names == ["attr1", "attr2"]
assert event.payload.options == {}

@mock.patch.object(AskarAnonCredsProfileSession, "handle")
async def test_get_created_schemas(self, mock_session_handle):
mock_session_handle.fetch_all = mock.CoroutineMock(
Expand Down Expand Up @@ -673,7 +734,19 @@ async def test_create_and_register_credential_definition_finishes(self, mock_not
)

assert isinstance(result, CredDefResult)
# Verify cred def event was emitted with tag
mock_notify.assert_called_once()
call_args = mock_notify.call_args
assert isinstance(call_args[0][0], CredDefFinishedEvent)
event = call_args[0][0]
assert event.payload.schema_id == "schema-id"
# When job_id exists, identifier is job_id, not cred_def_id
assert event.payload.cred_def_id == "job-id"
assert event.payload.issuer_id == "did:sov:3avoBCqDMFHFaKUHug9s8W"
assert event.payload.tag == "tag" # Verify tag is included in event
assert event.payload.support_revocation is False
assert event.payload.max_cred_num == 1000 # Default value
assert event.payload.options == {}

@mock.patch.object(test_module.AnonCredsIssuer, "notify")
async def test_create_and_register_credential_definition_errors(self, mock_notify):
Expand Down
Loading