diff --git a/plan/BUILD_LEARNINGS.md b/plan/BUILD_LEARNINGS.md index 95ed7314d..46aeca81f 100644 --- a/plan/BUILD_LEARNINGS.md +++ b/plan/BUILD_LEARNINGS.md @@ -79,3 +79,14 @@ update **both** `pyproject.toml` markers AND any `.github/workflows/` YAML files reference the mark by name. A renamed mark in test files without a corresponding workflow update causes `pytest` to select 0 tests and exit with code 5 (no tests collected), failing CI even though the rename itself is correct. + +### 2026-06-12 USE-CASE-SPLIT-881 — flat-to-subpackage test migration pattern + +When migrating flat test files into per-submodule subdirectories, the existing +test classes map cleanly onto the semantic clusters already established by the +source split. Removing the old flat files and creating new per-submodule files +(rather than leaving both) avoids duplicate test collection and keeps the +layout strictly mirrored. The parent `conftest.py` fixtures are automatically +inherited via pytest's upward conftest search, so only the vocabulary +registration side-effect import needs copying into each new subdirectory +conftest. diff --git a/plan/history/2606/implementation/ISSUE-881.md b/plan/history/2606/implementation/ISSUE-881.md new file mode 100644 index 000000000..7ad066159 --- /dev/null +++ b/plan/history/2606/implementation/ISSUE-881.md @@ -0,0 +1,28 @@ +--- +source: ISSUE-881 +timestamp: '2026-06-12T20:04:33.391050+00:00' +title: Split received/case.py and received/actor.py into subpackages +type: implementation +--- + +## Issue #881 — Split large received use-case modules + +Converted two 800+ line use-case modules into focused subpackages. + +**`received/case/`** submodules: `_helpers.py`, `create.py`, `update.py`, +`engage_defer.py`, `lifecycle.py`, `validate.py`, `__init__.py`. All submodules +under 500 lines (max: 273). + +**`received/actor/`** submodules: `suggest.py`, `case_manager_role.py`, +`ownership.py`, `invite.py`, `announce.py`, `__init__.py`. All submodules +under 500 lines (max: 270). + +Test layout migrated to mirror source split: flat `test_case.py`, +`test_actor.py`, `test_case_bootstrap_trust.py`, `test_actor_announce_case.py` +replaced by per-submodule files in `test/core/use_cases/received/case/` and +`test/core/use_cases/received/actor/`. + +All existing import paths preserved via re-exporting `__init__.py` (AC-3). +3205 unit tests pass, all four linters clean. + +PR: [#941](https://github.com/CERTCC/Vultron/pull/941) diff --git a/test/core/use_cases/received/actor/__init__.py b/test/core/use_cases/received/actor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/core/use_cases/received/actor/conftest.py b/test/core/use_cases/received/actor/conftest.py new file mode 100644 index 000000000..c652385d2 --- /dev/null +++ b/test/core/use_cases/received/actor/conftest.py @@ -0,0 +1,14 @@ +""" +Fixtures for test/core/use_cases/received tests. + +Imports VulnerabilityCase (and related wire-layer types) as a side effect so +that the global vocabulary registry is populated before any test in this +directory runs. Without this import the registry may be empty when tests run +in isolation, causing TinyDB's record_to_object() to fall back to returning a +raw Document instead of a deserialized domain object. +""" + +# noqa: F401 — imported for vocabulary registration side-effect +from vultron.wire.as2.vocab.objects.vulnerability_case import ( # noqa: F401 + VulnerabilityCase, +) diff --git a/test/core/use_cases/received/test_actor_announce_case.py b/test/core/use_cases/received/actor/test_announce.py similarity index 99% rename from test/core/use_cases/received/test_actor_announce_case.py rename to test/core/use_cases/received/actor/test_announce.py index 967899cd2..40f19859b 100644 --- a/test/core/use_cases/received/test_actor_announce_case.py +++ b/test/core/use_cases/received/actor/test_announce.py @@ -18,7 +18,7 @@ from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer from vultron.core.models.report_case_link import VultronReportCaseLink -from vultron.core.use_cases.received.actor import ( +from vultron.core.use_cases.received.actor.announce import ( AnnounceVulnerabilityCaseReceivedUseCase, ) from vultron.wire.as2.factories import announce_vulnerability_case_activity diff --git a/test/core/use_cases/received/actor/test_case_manager_role.py b/test/core/use_cases/received/actor/test_case_manager_role.py new file mode 100644 index 000000000..32c447318 --- /dev/null +++ b/test/core/use_cases/received/actor/test_case_manager_role.py @@ -0,0 +1,257 @@ +# Copyright (c) 2025-2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University +"""Tests for case manager role delegation received use cases.""" + +import logging +from unittest.mock import MagicMock + +from vultron.core.use_cases.received.actor.case_manager_role import ( + AcceptCaseManagerRoleReceivedUseCase, + OfferCaseManagerRoleReceivedUseCase, + RejectCaseManagerRoleReceivedUseCase, +) +from vultron.wire.as2.factories import ( + accept_case_manager_role_activity, + offer_case_manager_role_activity, + reject_case_manager_role_activity, +) + + +class TestCaseManagerRoleDelegationUseCases: + """Tests for offer/accept/reject CASE_MANAGER role delegation use cases. + + DEMOMA-08-002: CASE_MANAGER delegation is distinct from ownership transfer. + """ + + _VENDOR_URI = "https://example.org/actors/vendor" + _CASE_ACTOR_URI = "https://example.org/actors/case-actor" + _CASE_URI = "https://example.org/cases/urn:uuid:test-case-mgr" + _PARTICIPANT_URI = ( + "https://example.org/participants/urn:uuid:case-actor-participant" + ) + + def _make_offer(self): + from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, + ) + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + case = VulnerabilityCase(id_=self._CASE_URI, name="CASE-MGR-TEST") + participant = CaseParticipant( + id_=self._PARTICIPANT_URI, + attributed_to=self._CASE_ACTOR_URI, + context=self._CASE_URI, + ) + return offer_case_manager_role_activity( + case, + target=participant, + actor=self._VENDOR_URI, + ) + + def test_offer_case_manager_role_persists_offer(self, make_payload): + """OfferCaseManagerRoleReceivedUseCase persists the offer activity.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + offer = self._make_offer() + event = make_payload(offer) + + OfferCaseManagerRoleReceivedUseCase(dl, event).execute() + + stored = dl.get(offer.type_.value, offer.id_) + assert stored is not None + + def test_offer_case_manager_role_idempotent(self, make_payload): + """Repeated execution of OfferCaseManagerRoleReceivedUseCase is a no-op.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + offer = self._make_offer() + event = make_payload(offer) + + OfferCaseManagerRoleReceivedUseCase(dl, event).execute() + OfferCaseManagerRoleReceivedUseCase(dl, event).execute() + + stored = dl.get(offer.type_.value, offer.id_) + assert stored is not None + + def test_accept_case_manager_role_persists_acceptance(self, make_payload): + """AcceptCaseManagerRoleReceivedUseCase persists the acceptance activity.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + offer = self._make_offer() + accept = accept_case_manager_role_activity( + offer, actor=self._CASE_ACTOR_URI + ) + event = make_payload(accept) + + AcceptCaseManagerRoleReceivedUseCase(dl, event).execute() + + stored = dl.get(accept.type_.value, accept.id_) + assert stored is not None + + def test_accept_case_manager_role_logs_acceptance( + self, caplog, make_payload + ): + """AcceptCaseManagerRoleReceivedUseCase logs acceptance without raising.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + offer = self._make_offer() + accept = accept_case_manager_role_activity( + offer, actor=self._CASE_ACTOR_URI + ) + event = make_payload(accept) + + with caplog.at_level(logging.INFO): + AcceptCaseManagerRoleReceivedUseCase(dl, event).execute() + + assert any("accepted" in r.message.lower() for r in caplog.records) + + def test_reject_case_manager_role_logs_warning(self, caplog, make_payload): + """RejectCaseManagerRoleReceivedUseCase logs a warning without raising.""" + offer = self._make_offer() + reject = reject_case_manager_role_activity( + offer, actor=self._CASE_ACTOR_URI + ) + event = make_payload(reject) + + with caplog.at_level(logging.WARNING): + RejectCaseManagerRoleReceivedUseCase(MagicMock(), event).execute() + + assert any("rejected" in r.message.lower() for r in caplog.records) + + def test_offer_case_manager_role_auto_accepts_when_trigger_given( + self, make_payload + ): + """OfferCaseManagerRoleReceivedUseCase auto-accepts when trigger_activity provided.""" + from unittest.mock import MagicMock, patch + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Organization + + dl = SqliteDataLayer("sqlite:///:memory:") + local_actor = as_Organization(id_=self._CASE_ACTOR_URI) + dl.create(local_actor) + + offer = self._make_offer() + event = make_payload(offer) + + trigger = MagicMock() + trigger.accept_case_manager_role.return_value = ( + "https://example.org/activities/accept-1" + ) + + with patch( + "vultron.core.use_cases.triggers._helpers.add_activity_to_outbox" + ): + OfferCaseManagerRoleReceivedUseCase( + dl, event, trigger_activity=trigger + ).execute() + + trigger.accept_case_manager_role.assert_called_once() + call_kwargs = trigger.accept_case_manager_role.call_args + assert call_kwargs.kwargs["offer_id"] == offer.id_ + assert call_kwargs.kwargs["vendor_id"] == self._VENDOR_URI + + def test_offer_case_manager_role_no_auto_accept_without_trigger( + self, make_payload + ): + """OfferCaseManagerRoleReceivedUseCase skips auto-accept when trigger_activity is None.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + offer = self._make_offer() + event = make_payload(offer) + + # No trigger_activity — should not raise + OfferCaseManagerRoleReceivedUseCase(dl, event).execute() + + stored = dl.get(offer.type_.value, offer.id_) + assert stored is not None + + def test_accept_case_manager_role_sends_bootstrap_when_reporter_found( + self, make_payload + ): + """AcceptCaseManagerRoleReceivedUseCase sends Create(Case) to Reporter on accept.""" + from unittest.mock import MagicMock, patch + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Organization + from vultron.core.models.vultron_types import VultronParticipant + from vultron.core.states.roles import CVDRole + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = SqliteDataLayer("sqlite:///:memory:") + vendor = as_Organization(id_=self._VENDOR_URI) + reporter_id = "https://example.org/actors/reporter" + reporter_participant_id = f"{self._CASE_URI}/participants/reporter" + reporter_participant = VultronParticipant( + id_=reporter_participant_id, + attributed_to=reporter_id, + context=self._CASE_URI, + name="Reporter", + case_roles=[CVDRole.FINDER, CVDRole.REPORTER], + ) + case = VulnerabilityCase(id_=self._CASE_URI, name="BOOTSTRAP-TEST") + case.actor_participant_index[reporter_id] = reporter_participant_id + dl.create(vendor) + dl.create(reporter_participant) + dl.create(case) + + offer = self._make_offer() + accept = accept_case_manager_role_activity( + offer, actor=self._CASE_ACTOR_URI + ) + event = make_payload(accept) + + trigger = MagicMock() + trigger.create_case.return_value = ( + "https://example.org/activities/create-1", + {}, + ) + + with patch( + "vultron.core.use_cases.triggers._helpers.add_activity_to_outbox" + ): + AcceptCaseManagerRoleReceivedUseCase( + dl, event, trigger_activity=trigger + ).execute() + + trigger.create_case.assert_called_once() + call_kwargs = trigger.create_case.call_args + assert call_kwargs.kwargs.get("to") == [reporter_id] or ( + len(call_kwargs.args) >= 3 and reporter_id in call_kwargs.args + ) + + def test_accept_case_manager_role_no_bootstrap_without_trigger( + self, make_payload + ): + """AcceptCaseManagerRoleReceivedUseCase skips bootstrap when trigger_activity is None.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + offer = self._make_offer() + accept = accept_case_manager_role_activity( + offer, actor=self._CASE_ACTOR_URI + ) + event = make_payload(accept) + + # No trigger_activity — should not raise + AcceptCaseManagerRoleReceivedUseCase(dl, event).execute() + + stored = dl.get(accept.type_.value, accept.id_) + assert stored is not None diff --git a/test/core/use_cases/received/actor/test_invite.py b/test/core/use_cases/received/actor/test_invite.py new file mode 100644 index 000000000..7a2d16aa6 --- /dev/null +++ b/test/core/use_cases/received/actor/test_invite.py @@ -0,0 +1,374 @@ +# Copyright (c) 2025-2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University +"""Tests for actor invitation received use cases.""" + +from typing import Any, cast +from unittest.mock import MagicMock + +from vultron.core.use_cases.received.actor.invite import ( + AcceptInviteActorToCaseReceivedUseCase, + InviteActorToCaseReceivedUseCase, + RejectInviteActorToCaseReceivedUseCase, +) +from vultron.wire.as2.factories import ( + rm_accept_invite_to_case_activity, + rm_invite_to_case_activity, + rm_reject_invite_to_case_activity, +) +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCaseStub, +) + + +class TestInviteActorUseCases: + """Tests for invite_actor_to_case, accept_invite_actor_to_case, + and reject_invite_actor_to_case.""" + + def test_invite_actor_to_case_stores_invite( + self, monkeypatch, make_payload + ): + """InviteActorToCaseReceivedUseCase persists the Invite activity to the DataLayer.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + + invite = rm_invite_to_case_activity( + as_Actor(id_="https://example.org/users/coordinator"), + target="https://example.org/cases/case1", + actor="https://example.org/users/owner", + id_="https://example.org/cases/case1/invitations/1", + ) + + event = make_payload(invite) + + InviteActorToCaseReceivedUseCase(dl, event).execute() + + stored = dl.get(invite.type_.value, invite.id_) + assert stored is not None + + def test_invite_actor_to_case_idempotent(self, monkeypatch, make_payload): + """InviteActorToCaseReceivedUseCase skips storing a duplicate Invite.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + + invite = rm_invite_to_case_activity( + as_Actor(id_="https://example.org/users/coordinator"), + target="https://example.org/cases/case1", + actor="https://example.org/users/owner", + id_="https://example.org/cases/case1/invitations/2", + ) + + event = make_payload(invite) + + InviteActorToCaseReceivedUseCase(dl, event).execute() + InviteActorToCaseReceivedUseCase( + dl, event + ).execute() # second call is no-op + + stored = dl.get(invite.type_.value, invite.id_) + assert stored is not None + + def test_reject_invite_actor_to_case_ledgers_rejection(self, make_payload): + """RejectInviteActorToCaseReceivedUseCase logs without raising.""" + invite = rm_invite_to_case_activity( + as_Actor(id_="https://example.org/users/coordinator"), + target="https://example.org/cases/case1", + actor="https://example.org/users/owner", + id_="https://example.org/cases/case1/invitations/3", + ) + reject = rm_reject_invite_to_case_activity( + invite, + actor="https://example.org/users/coordinator", + ) + + event = make_payload(reject) + + result = RejectInviteActorToCaseReceivedUseCase( + MagicMock(), event + ).execute() + assert result is None + + def test_accept_invite_actor_to_case_adds_participant( + self, monkeypatch, make_payload + ): + """AcceptInviteActorToCaseReceivedUseCase creates a CaseParticipant and adds them to the case.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Organization + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = SqliteDataLayer("sqlite:///:memory:") + invitee_id = "https://example.org/users/coordinator" + invitee = as_Organization(id_=invitee_id) + case = VulnerabilityCase( + id_="https://example.org/cases/caseIA1", + name="TEST-ACCEPT-INVITE", + ) + invite = rm_invite_to_case_activity( + invitee, + target=VulnerabilityCaseStub(id_=case.id_), + actor="https://example.org/users/owner", + id_="https://example.org/cases/caseIA1/invitations/1", + ) + dl.create(invitee) + dl.create(case) + dl.create(invite) + + accept = rm_accept_invite_to_case_activity( + invite, + actor=invitee_id, + ) + + event = make_payload(accept) + + AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() + + case = dl.read(case.id_) + assert case is not None + case = cast(VulnerabilityCase, case) + assert invitee_id in case.actor_participant_index + + def test_accept_invite_actor_to_case_records_active_embargo( + self, monkeypatch, make_payload + ): + """AcceptInviteActorToCaseReceivedUseCase records the active embargo ID on the new participant (CM-10-001, CM-10-003).""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.core.states.em import EM + from vultron.wire.as2.vocab.base.objects.actors import as_Organization + from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = SqliteDataLayer("sqlite:///:memory:") + invitee_id = "https://example.org/users/coordinator" + invitee = as_Organization(id_=invitee_id) + embargo = EmbargoEvent( + id_="https://example.org/cases/caseIA2/embargo_events/e1", + content="Active embargo", + ) + case = VulnerabilityCase( + id_="https://example.org/cases/caseIA2", + name="TEST-ACCEPT-INVITE-EMBARGO", + ) + case.active_embargo = embargo.id_ + case.current_status.em_state = EM.ACTIVE + invite = rm_invite_to_case_activity( + invitee, + target=VulnerabilityCaseStub(id_=case.id_), + actor="https://example.org/users/owner", + id_="https://example.org/cases/caseIA2/invitations/1", + ) + dl.create(invitee) + dl.create(case) + dl.create(embargo) + dl.create(invite) + + accept = rm_accept_invite_to_case_activity( + invite, + actor=invitee_id, + ) + + event = make_payload(accept) + + AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() + + case = dl.read(case.id_) + assert case is not None + case = cast(VulnerabilityCase, case) + participant_id = case.actor_participant_index.get(invitee_id) + assert participant_id is not None + participant_obj = dl.get(id_=participant_id) + assert participant_obj is not None + participant_obj = cast(Any, participant_obj) + assert embargo.id_ in participant_obj.accepted_embargo_ids + + def test_accept_invite_participant_can_reach_rm_accepted( + self, make_payload + ): + """Accepted invite advances the participant to RM.ACCEPTED via BT. + + PCR-08-010: Accept(Invite) IS the engage decision. The use case + delegates to AcceptInviteActorToCaseBT which records the participant + at RM.ACCEPTED — no separate engage-case BT or proxy RmEngageCaseActivity + is emitted. + """ + from typing import Any, cast + + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Organization + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + from vultron.core.states.rm import RM + + dl = SqliteDataLayer("sqlite:///:memory:") + invitee_id = "https://example.org/users/coordinator_rm1" + invitee = as_Organization(id_=invitee_id) + owner_id = "https://example.org/users/owner" + case = VulnerabilityCase( + id_="https://example.org/cases/caseRM001", + name="TEST-RM-LIFECYCLE", + ) + invite = rm_invite_to_case_activity( + invitee, + target=VulnerabilityCaseStub(id_=case.id_), + actor=owner_id, + id_="https://example.org/cases/caseRM001/invitations/1", + ) + dl.create(invitee) + dl.create(case) + dl.create(invite) + + accept = rm_accept_invite_to_case_activity( + invite, + actor=invitee_id, + ) + event = make_payload(accept) + + # No TriggerActivityAdapter needed: RM.ACCEPTED is reached via BT. + AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() + + updated_case = cast(Any, dl.read(case.id_)) + participant_id = updated_case.actor_participant_index.get(invitee_id) + participant_obj = cast(Any, dl.get(id_=participant_id)) + latest_status = participant_obj.participant_statuses[-1] + assert latest_status.rm_state == RM.ACCEPTED + + def test_accept_invite_no_identity_spoofing(self, make_payload): + """PCR-07-008: AcceptInviteActorToCaseReceivedUseCase MUST NOT emit + RmEngageCaseActivity (Join) with actor=invitee_id from the Case Actor + context. The Accept(Invite) IS the engage decision; the BT records + RM.ACCEPTED for the invitee without spoofing the invitee's identity. + """ + from typing import Any, cast + + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Organization + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + from vultron.core.models.vultron_types import VultronParticipant + from vultron.core.states.rm import RM + from vultron.core.states.roles import CVDRole + + dl = SqliteDataLayer("sqlite:///:memory:") + invitee_id = "https://example.org/users/coordinator_rm2" + invitee = as_Organization(id_=invitee_id) + owner_id = "https://example.org/users/owner" + case_manager_participant_id = ( + "https://example.org/cases/caseRM002/participants/case-manager" + ) + case_manager_participant = VultronParticipant( + id_=case_manager_participant_id, + attributed_to=owner_id, + context="https://example.org/cases/caseRM002", + name="CaseManager", + case_roles=[CVDRole.CASE_MANAGER], + ) + case = VulnerabilityCase( + id_="https://example.org/cases/caseRM002", + name="TEST-RM-AUTO-ENGAGE", + case_participants=[case_manager_participant_id], + actor_participant_index={owner_id: case_manager_participant_id}, + ) + invite = rm_invite_to_case_activity( + invitee, + target=VulnerabilityCaseStub(id_=case.id_), + actor=owner_id, + id_="https://example.org/cases/caseRM002/invitations/1", + ) + dl.create(invitee) + dl.create(case_manager_participant) + dl.create(case) + dl.create(invite) + + accept = rm_accept_invite_to_case_activity( + invite, + actor=invitee_id, + ) + event = make_payload(accept) + + AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() + + # PCR-07-008: no RmEngageCaseActivity (Join) with actor=invitee_id + # should be queued — the BT records RM.ACCEPTED for the invitee + # without spoofing the invitee's identity. + outbox_items = dl.clone_for_actor(invitee_id).outbox_list() + for item_id in outbox_items: + candidate = cast(Any, dl.read(item_id)) + if candidate is not None and str(candidate.type_) == "Join": + assert False, ( + f"PCR-07-008 violation: RmEngageCaseActivity (Join) with " + f"actor={invitee_id!r} found in outbox — identity spoofing" + ) + + # The participant should be at RM.ACCEPTED (via BT). + updated_case = cast(Any, dl.read(case.id_)) + participant_id = updated_case.actor_participant_index.get(invitee_id) + assert participant_id is not None + participant_obj = cast(Any, dl.get(id_=participant_id)) + assert participant_obj is not None + latest_status = participant_obj.participant_statuses[-1] + assert latest_status.rm_state == RM.ACCEPTED, ( + f"Expected RM.ACCEPTED after BT transition, " + f"got {latest_status.rm_state}" + ) + + def test_accept_invite_actor_to_case_records_case_event( + self, monkeypatch, make_payload + ): + """AcceptInviteActorToCaseReceivedUseCase appends a trusted-timestamp event to case.events (CM-02-009).""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Organization + from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, + ) + + dl = SqliteDataLayer("sqlite:///:memory:") + invitee_id = "https://example.org/users/coordinator" + invitee = as_Organization(id_=invitee_id) + case = VulnerabilityCase( + id_="https://example.org/cases/caseIA3", + name="TEST-ACCEPT-INVITE-EVENT", + ) + invite = rm_invite_to_case_activity( + invitee, + target=VulnerabilityCaseStub(id_=case.id_), + actor="https://example.org/users/owner", + id_="https://example.org/cases/caseIA3/invitations/1", + ) + dl.create(invitee) + dl.create(case) + dl.create(invite) + + accept = rm_accept_invite_to_case_activity( + invite, + actor=invitee_id, + ) + + event = make_payload(accept) + + assert len(case.events) == 0 + + AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() + + case = dl.read(case.id_) + assert case is not None + case = cast(VulnerabilityCase, case) + assert len(case.events) >= 1 + event_types = [e.event_type for e in case.events] + assert "participant_joined" in event_types diff --git a/test/core/use_cases/received/actor/test_ownership.py b/test/core/use_cases/received/actor/test_ownership.py new file mode 100644 index 000000000..74dd1a5bd --- /dev/null +++ b/test/core/use_cases/received/actor/test_ownership.py @@ -0,0 +1,124 @@ +# Copyright (c) 2025-2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University +"""Tests for ownership transfer received use cases.""" + +import logging +from typing import Any, cast +from unittest.mock import MagicMock + +from vultron.core.use_cases.received.actor.ownership import ( + AcceptCaseOwnershipTransferReceivedUseCase, + OfferCaseOwnershipTransferReceivedUseCase, + RejectCaseOwnershipTransferReceivedUseCase, +) +from vultron.wire.as2.factories import ( + accept_case_ownership_transfer_activity, + offer_case_ownership_transfer_activity, + reject_case_ownership_transfer_activity, +) +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, +) + + +class TestOwnershipTransferUseCases: + """Tests for offer/accept/reject ownership transfer use cases.""" + + def test_offer_case_ownership_transfer_persists_offer( + self, monkeypatch, make_payload + ): + """OfferCaseOwnershipTransferReceivedUseCase persists the offer.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + + case = VulnerabilityCase( + id_="https://example.org/cases/case_ot1", + name="OT Case 1", + ) + activity = offer_case_ownership_transfer_activity( + case, + target="https://example.org/users/coordinator", + actor="https://example.org/users/vendor", + ) + event = make_payload(activity) + + OfferCaseOwnershipTransferReceivedUseCase(dl, event).execute() + + stored = dl.get(activity.type_.value, activity.id_) + assert stored is not None + + def test_accept_case_ownership_transfer_updates_attributed_to( + self, monkeypatch, make_payload + ): + """AcceptCaseOwnershipTransferReceivedUseCase updates case.attributed_to to new owner.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + + dl = SqliteDataLayer("sqlite:///:memory:") + case = VulnerabilityCase( + id_="https://example.org/cases/case_ot2", + name="OT Case 2", + attributed_to="https://example.org/users/vendor", + ) + dl.create(case) + + offer = offer_case_ownership_transfer_activity( + case, + target="https://example.org/users/coordinator", + actor="https://example.org/users/vendor", + id_="https://example.org/activities/offer_ot2", + ) + dl.create(offer) + + activity = accept_case_ownership_transfer_activity( + offer, + actor="https://example.org/users/coordinator", + ) + event = make_payload(activity) + + AcceptCaseOwnershipTransferReceivedUseCase(dl, event).execute() + + updated_record = dl.get(case.type_.value, case.id_) + assert updated_record is not None + data = cast(Any, updated_record).get("data_", updated_record) + assert ( + data.get("attributed_to") + == "https://example.org/users/coordinator" + ) + + def test_reject_case_ownership_transfer_logs_rejection( + self, monkeypatch, caplog, make_payload + ): + """RejectCaseOwnershipTransferReceivedUseCase logs rejection; ownership unchanged.""" + case = VulnerabilityCase( + id_="https://example.org/cases/case_ot3", + name="OT Case 3", + ) + offer = offer_case_ownership_transfer_activity( + case, + target="https://example.org/users/coordinator", + actor="https://example.org/users/vendor", + id_="https://example.org/activities/offer_ot3", + ) + activity = reject_case_ownership_transfer_activity( + offer, + actor="https://example.org/users/coordinator", + ) + event = make_payload(activity) + + with caplog.at_level(logging.INFO): + RejectCaseOwnershipTransferReceivedUseCase( + MagicMock(), event + ).execute() + + assert any("rejected" in r.message.lower() for r in caplog.records) diff --git a/test/core/use_cases/received/actor/test_suggest.py b/test/core/use_cases/received/actor/test_suggest.py new file mode 100644 index 000000000..5a109eb36 --- /dev/null +++ b/test/core/use_cases/received/actor/test_suggest.py @@ -0,0 +1,274 @@ +# Copyright (c) 2025-2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University +"""Tests for actor suggestion received use cases.""" + +import logging +from typing import Any, cast +from unittest.mock import MagicMock + +import py_trees +import pytest + +from vultron.adapters.driven.trigger_activity_adapter import ( + TriggerActivityAdapter, +) +from vultron.core.use_cases.received.actor.suggest import ( + AcceptSuggestActorToCaseReceivedUseCase, + RejectSuggestActorToCaseReceivedUseCase, + SuggestActorToCaseReceivedUseCase, +) +from vultron.wire.as2.factories import ( + accept_actor_recommendation_activity, + recommend_actor_activity, + reject_actor_recommendation_activity, +) +from vultron.wire.as2.vocab.base.objects.actors import as_Actor +from vultron.wire.as2.vocab.objects.vulnerability_case import ( + VulnerabilityCase, +) + + +class TestSuggestActorUseCases: + """Tests for suggest_actor_to_case, accept/reject suggest_actor use cases.""" + + def test_suggest_actor_to_case_persists_recommendation( + self, monkeypatch, make_payload + ): + """SuggestActorToCaseReceivedUseCase persists the RecommendActor offer.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Actor + + dl = SqliteDataLayer("sqlite:///:memory:") + + coordinator = as_Actor(id_="https://example.org/users/coordinator") + case = VulnerabilityCase( + id_="https://example.org/cases/case_sa1", + name="SA Case 1", + ) + activity = recommend_actor_activity( + coordinator, + target=case, + actor="https://example.org/users/finder", + to="https://example.org/users/vendor", + ) + + event = make_payload(activity) + + SuggestActorToCaseReceivedUseCase(dl, event).execute() + + stored = dl.get(activity.type_.value, activity.id_) + assert stored is not None + + def test_suggest_actor_to_case_idempotent(self, monkeypatch, make_payload): + """SuggestActorToCaseReceivedUseCase is idempotent — second call is a no-op.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Actor + + dl = SqliteDataLayer("sqlite:///:memory:") + + coordinator = as_Actor(id_="https://example.org/users/coordinator") + case = VulnerabilityCase( + id_="https://example.org/cases/case_sa2", + name="SA Case 2", + ) + activity = recommend_actor_activity( + coordinator, + target=case, + actor="https://example.org/users/finder", + ) + event = make_payload(activity) + + SuggestActorToCaseReceivedUseCase(dl, event).execute() + SuggestActorToCaseReceivedUseCase(dl, event).execute() + + stored = dl.get(activity.type_.value, activity.id_) + assert stored is not None + + def test_accept_suggest_actor_to_case_persists_acceptance( + self, monkeypatch, make_payload + ): + """AcceptSuggestActorToCaseReceivedUseCase persists the AcceptActorRecommendation.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Actor + + dl = SqliteDataLayer("sqlite:///:memory:") + + coordinator = as_Actor(id_="https://example.org/users/coordinator") + case = VulnerabilityCase( + id_="https://example.org/cases/case_sa3", + name="SA Case 3", + ) + recommendation = recommend_actor_activity( + coordinator, + target=case, + actor="https://example.org/users/finder", + ) + activity = accept_actor_recommendation_activity( + recommendation, + target=case, + actor="https://example.org/users/vendor", + ) + event = make_payload(activity) + + AcceptSuggestActorToCaseReceivedUseCase(dl, event).execute() + + stored = dl.get(activity.type_.value, activity.id_) + assert stored is not None + + def test_reject_suggest_actor_to_case_ledgers_rejection( + self, monkeypatch, caplog, make_payload + ): + """RejectSuggestActorToCaseReceivedUseCase logs rejection without state change.""" + from vultron.wire.as2.vocab.base.objects.actors import as_Actor + + coordinator = as_Actor(id_="https://example.org/users/coordinator") + case = VulnerabilityCase( + id_="https://example.org/cases/case_sa4", + name="SA Case 4", + ) + recommendation = recommend_actor_activity( + coordinator, + target=case, + actor="https://example.org/users/finder", + ) + activity = reject_actor_recommendation_activity( + recommendation, + target=case, + actor="https://example.org/users/vendor", + ) + event = make_payload(activity) + + with caplog.at_level(logging.INFO): + RejectSuggestActorToCaseReceivedUseCase( + MagicMock(), event + ).execute() + + assert any("rejected" in r.message.lower() for r in caplog.records) + + @pytest.fixture(autouse=True) + def clear_blackboard(self): + py_trees.blackboard.Blackboard.storage.clear() + yield + py_trees.blackboard.Blackboard.storage.clear() + + def _setup_dl_with_owner(self): + """Return a DataLayer seeded with a local Service actor and a case.""" + from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer + from vultron.wire.as2.vocab.base.objects.actors import as_Service + + dl = SqliteDataLayer("sqlite:///:memory:") + local_actor_id = "https://example.org/actors/local-coordinator" + local_actor = as_Service(id_=local_actor_id) + case_id = "https://example.org/cases/suggest-test-case" + case = VulnerabilityCase( + id_=case_id, + name="SUGGEST-TEST", + attributed_to=local_actor_id, + ) + dl.create(local_actor) + dl.create(case) + return dl, local_actor_id, case_id + + def test_suggest_actor_emits_both_activities_when_owner( + self, make_payload + ): + """Owner emits Accept + Invite when receiving a recommendation.""" + dl, local_actor_id, case_id = self._setup_dl_with_owner() + recommender_id = "https://example.org/actors/finder" + invitee_id = "https://example.org/actors/vendor" + invitee = as_Actor(id_=invitee_id) + + recommendation = recommend_actor_activity( + invitee, + target=case_id, + actor=recommender_id, + to=[local_actor_id], + id_="https://example.org/activities/rec-001", + ) + event = make_payload(recommendation) + + SuggestActorToCaseReceivedUseCase( + dl, event, trigger_activity=TriggerActivityAdapter(dl) + ).execute() + + outbox = dl.outbox_list() + assert ( + len(outbox) == 2 + ), f"Expected 2 outbox entries (Accept + Invite), got {len(outbox)}" + + def test_suggest_actor_skips_when_not_case_owner(self, make_payload): + """Non-owner silently skips — no outbox entries emitted.""" + dl, local_actor_id, case_id = self._setup_dl_with_owner() + # Override case with a different owner + case = dl.read(case_id) + other_owner = "https://example.org/actors/other-owner" + case = cast(Any, case) + case = case.model_copy(update={"attributed_to": other_owner}) + dl.save(case) + + recommender_id = "https://example.org/actors/finder" + invitee_id = "https://example.org/actors/vendor" + invitee = as_Actor(id_=invitee_id) + + recommendation = recommend_actor_activity( + invitee, + target=case_id, + actor=recommender_id, + to=[local_actor_id], + id_="https://example.org/activities/rec-002", + ) + event = make_payload(recommendation) + + SuggestActorToCaseReceivedUseCase(dl, event).execute() + + outbox = dl.outbox_list() + assert len(outbox) == 0, ( + "Expected no outbox entries for non-owner, " f"got {len(outbox)}" + ) + + def test_suggest_actor_idempotent_when_invite_exists(self, make_payload): + """Second execute() adds no new outbox entries.""" + dl, local_actor_id, case_id = self._setup_dl_with_owner() + recommender_id = "https://example.org/actors/finder" + invitee_id = "https://example.org/actors/vendor" + invitee = as_Actor(id_=invitee_id) + + recommendation = recommend_actor_activity( + invitee, + target=case_id, + actor=recommender_id, + to=[local_actor_id], + id_="https://example.org/activities/rec-003", + ) + event = make_payload(recommendation) + + # First execution + SuggestActorToCaseReceivedUseCase( + dl, event, trigger_activity=TriggerActivityAdapter(dl) + ).execute() + outbox_after_first = len(dl.outbox_list()) + + # Second execution (should be a no-op) + py_trees.blackboard.Blackboard.storage.clear() + SuggestActorToCaseReceivedUseCase( + dl, event, trigger_activity=TriggerActivityAdapter(dl) + ).execute() + outbox_after_second = len(dl.outbox_list()) + + assert ( + outbox_after_first == 2 + ), f"Expected 2 entries after first run, got {outbox_after_first}" + assert outbox_after_second == outbox_after_first, ( + "Expected no new entries on second run (idempotency), " + f"got {outbox_after_second - outbox_after_first} extra" + ) diff --git a/test/core/use_cases/received/case/__init__.py b/test/core/use_cases/received/case/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/core/use_cases/received/case/conftest.py b/test/core/use_cases/received/case/conftest.py new file mode 100644 index 000000000..c652385d2 --- /dev/null +++ b/test/core/use_cases/received/case/conftest.py @@ -0,0 +1,14 @@ +""" +Fixtures for test/core/use_cases/received tests. + +Imports VulnerabilityCase (and related wire-layer types) as a side effect so +that the global vocabulary registry is populated before any test in this +directory runs. Without this import the registry may be empty when tests run +in isolation, causing TinyDB's record_to_object() to fall back to returning a +raw Document instead of a deserialized domain object. +""" + +# noqa: F401 — imported for vocabulary registration side-effect +from vultron.wire.as2.vocab.objects.vulnerability_case import ( # noqa: F401 + VulnerabilityCase, +) diff --git a/test/core/use_cases/received/case/test_bootstrap_participants.py b/test/core/use_cases/received/case/test_bootstrap_participants.py new file mode 100644 index 000000000..be50a7d8d --- /dev/null +++ b/test/core/use_cases/received/case/test_bootstrap_participants.py @@ -0,0 +1,296 @@ +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute +# to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype +# is licensed under a MIT (SEI)-style license, please see LICENSE.md +# distributed with this Software or contact permission@sei.cmu.edu for full +# terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University +"""Tests for embedded-participant storage during case bootstrap (CBT-05-005/006). + +Covers: + CBT-05-005 Embedded CaseParticipant objects are stored as independent + DataLayer records so BT nodes (CheckParticipantExists, + AppendParticipantStatusNode) can look them up by UUID. + CBT-05-006 AddParticipantStatusBT succeeds on the reporter's replica after + bootstrap (regression for #563 — M4 timeout in two-actor demo). +""" + +from typing import cast + +import pytest + +from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer +from vultron.core.models.report_case_link import VultronReportCaseLink +from vultron.core.states.cs import CS_vfd +from vultron.core.states.roles import CVDRole +from vultron.core.use_cases.received.case.create import ( + CreateCaseReceivedUseCase, +) +from vultron.core.use_cases.received.status import ( + AddParticipantStatusToParticipantReceivedUseCase, +) +from vultron.wire.as2.factories import ( + add_status_to_participant_activity, + create_case_activity, +) +from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, +) +from vultron.wire.as2.vocab.objects.case_status import ( + ParticipantStatus as WireParticipantStatus, +) +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase + +# --------------------------------------------------------------------------- +# Shared constants +# --------------------------------------------------------------------------- + +_CREATOR_ID = "https://example.org/actors/creator" +_CASE_ACTOR_ID = "https://example.org/actors/case-actor" +_VENDOR_ID = "https://example.org/actors/vendor" +_CASE_ID = "https://example.org/cases/cbt-bp-001" +_REPORT_ID = "https://example.org/reports/cbt-bp-report-001" +_PARTICIPANT_ID = f"{_CASE_ID}/participants/case-actor" +_VENDOR_PARTICIPANT_ID = f"{_CASE_ID}/participants/vendor" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _build_link( + *, + trusted_case_creator_id: str | None = _CREATOR_ID, + case_id: str | None = None, + trusted_case_actor_id: str | None = None, +) -> VultronReportCaseLink: + return VultronReportCaseLink( + report_id=_REPORT_ID, + case_id=case_id, + trusted_case_creator_id=trusted_case_creator_id, + trusted_case_actor_id=trusted_case_actor_id, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def dl(): + return SqliteDataLayer("sqlite:///:memory:") + + +@pytest.fixture() +def case_with_two_participants(): + """VulnerabilityCase with CASE_MANAGER and vendor participants inline.""" + case_actor_p = CaseParticipant( + case_roles=[CVDRole.CASE_MANAGER], + id_=_PARTICIPANT_ID, + attributed_to=_CASE_ACTOR_ID, + context=_CASE_ID, + ) + vendor_p = CaseParticipant( + id_=_VENDOR_PARTICIPANT_ID, + attributed_to=_VENDOR_ID, + context=_CASE_ID, + ) + case = VulnerabilityCase( + id_=_CASE_ID, + name="CBT-05-005/006 participant storage case", + case_participants=[case_actor_p, vendor_p], + ) + case.actor_participant_index[_CASE_ACTOR_ID] = _PARTICIPANT_ID + case.actor_participant_index[_VENDOR_ID] = _VENDOR_PARTICIPANT_ID + return case, case_actor_p, vendor_p + + +@pytest.fixture() +def create_event(make_payload, case_with_two_participants): + case, _, _ = case_with_two_participants + activity = create_case_activity(case, actor=_CREATOR_ID) + return make_payload(activity) + + +# --------------------------------------------------------------------------- +# CBT-05-005: Embedded participants are stored as separate DataLayer records +# --------------------------------------------------------------------------- + + +class TestBootstrapParticipantStorage: + """CBT-05-005 — bootstrap Create stores embedded participants in DataLayer. + + BT nodes ``CheckParticipantExists`` (#561) and ``AppendParticipantStatus`` + (#562) look up participants by UUID via ``datalayer.read(participant_id)``. + After a bootstrap ``Create(VulnerabilityCase)`` those participant records + MUST exist as independent DataLayer entries so the BT nodes can find them. + """ + + def test_embedded_participant_stored_after_bootstrap( + self, dl, create_event + ): + """Embedded CaseParticipant is stored as an independent DataLayer + record after a valid bootstrap (CBT-05-005, fixes #561 and #562). + """ + link = _build_link() + dl.save(link) + + CreateCaseReceivedUseCase(dl, create_event).execute() + + stored = dl.read(_PARTICIPANT_ID) + assert stored is not None, ( + "Embedded CaseActorParticipant must be stored as an independent " + "DataLayer record after bootstrap so BT nodes can look it up by ID" + ) + + def test_participant_stored_when_case_already_existed( + self, dl, create_event, case_with_two_participants + ): + """Participants are stored even when the case replica was already seeded + (e.g. by ``_store_nested_inbox_object`` before dispatch) — #561, #562. + """ + link = _build_link() + dl.save(link) + + # Pre-seed the case to trigger the idempotency guard in _handle_bootstrap + case, _, _ = case_with_two_participants + dl.create(case) + + CreateCaseReceivedUseCase(dl, create_event).execute() + + stored = dl.read(_PARTICIPANT_ID) + assert stored is not None, ( + "Participant must be stored even when the case was already seeded " + "before _handle_bootstrap ran" + ) + + def test_save_failure_propagates_from_store_embedded_participants( + self, dl, create_event + ): + """A DataLayer failure in _store_embedded_participants propagates as an + exception rather than being silently swallowed (leaves replica + consistent — fail loudly instead of leaving participants missing). + """ + import unittest.mock as mock + + link = _build_link() + dl.save(link) + + # Patch dl.save to raise after the first successful call (link save) + original_save = dl.save + call_count = {"n": 0} + + def _patched_save(obj): + call_count["n"] += 1 + if call_count["n"] > 1: + raise RuntimeError("storage failure") + return original_save(obj) + + with mock.patch.object(dl, "save", side_effect=_patched_save): + with pytest.raises(RuntimeError, match="storage failure"): + CreateCaseReceivedUseCase(dl, create_event).execute() + + +# --------------------------------------------------------------------------- +# CBT-05-006: M4 AddParticipantStatusBT succeeds after bootstrap (#563) +# --------------------------------------------------------------------------- + + +class TestM4AddParticipantStatusAfterBootstrap: + """CBT-05-006 — AddParticipantStatusBT succeeds on reporter's replica. + + Regression test for #563: M4 timeout in two-actor demo. + + Before the fix (PRs #561, #562): + - ``_store_embedded_participants`` did not persist each embedded participant + as an independent DataLayer record, so vendor's ``CaseParticipant`` could + not be found by its UUID after bootstrap. + - ``AppendParticipantStatusNode`` did ``dl.read(vendor_participant_id)`` + → ``None`` → ``FAILURE``, leaving finder's replica without the vendor's + ``vfd_state`` update. + - Finder's M4 poll returned 404 until timeout. + + After the fix: + - ``_store_embedded_participants`` stores all embedded participant objects + during bootstrap (CBT-05-005). + - ``AppendParticipantStatusNode`` finds the participant and appends the + status successfully. + - M4 completes without timeout. + """ + + def test_add_participant_status_succeeds_after_bootstrap( + self, dl, make_payload, case_with_two_participants + ): + """AddParticipantStatusBT appends VFd status on finder's replica. + + Full M4 path: bootstrap → verify participant stored → receive + Add(ParticipantStatus) from case-actor → assert VFd status on vendor + participant. Regression for #563. + """ + _vfd_status_id = f"{_VENDOR_PARTICIPANT_ID}/statuses/vfd-s1" + case, _, vendor_p = case_with_two_participants + + activity = create_case_activity(case, actor=_CREATOR_ID) + bootstrap_event = make_payload(activity) + + link = _build_link() + dl.save(link) + + # Step 1: bootstrap — _store_embedded_participants saves vendor's + # CaseParticipant as an independent DataLayer record (CBT-05-005). + CreateCaseReceivedUseCase(dl, bootstrap_event).execute() + + # Step 2: confirm vendor participant is independently stored (core fix). + stored_p = dl.read(_VENDOR_PARTICIPANT_ID) + assert ( + stored_p is not None + ), "Vendor CaseParticipant must be stored during bootstrap (CBT-05-005)" + + # Step 3: case-actor broadcasts Add(ParticipantStatus, vendor_p) to + # finder. actor=_CASE_ACTOR_ID so VerifySenderIsParticipantNode passes + # (case.actor_participant_index contains _CASE_ACTOR_ID). + # The status is NOT pre-created — it arrives inline in the activity, so + # AppendParticipantStatusNode must resolve it from the fallback and + # persist it independently. + status = WireParticipantStatus( + id_=_vfd_status_id, + context=_CASE_ID, + vfd_state=CS_vfd.VFd, + ) + activity = add_status_to_participant_activity( + status, + target=vendor_p, + actor=_CASE_ACTOR_ID, + ) + event = make_payload(activity) + + AddParticipantStatusToParticipantReceivedUseCase(dl, event).execute() + + # Step 4: vendor participant now has the VFd status — M4 can observe it. + updated_p = dl.read(_VENDOR_PARTICIPANT_ID) + assert ( + updated_p is not None + ), "Vendor participant must still exist after AddParticipantStatus" + updated_p = cast(CaseParticipant, updated_p) + status_ids = [ + getattr(s, "id_", s) for s in updated_p.participant_statuses + ] + assert _vfd_status_id in status_ids, ( + "Vendor participant must have the VFd status after M4 broadcast " + "(regression for #563)" + ) + # The status object must also exist as an independent DataLayer record. + stored_status = dl.read(_vfd_status_id) + assert stored_status is not None, ( + "ParticipantStatus must be persisted as an independent DataLayer" + " record by AddParticipantStatusToParticipantReceivedUseCase" + ) diff --git a/test/core/use_cases/received/case/test_create.py b/test/core/use_cases/received/case/test_create.py new file mode 100644 index 000000000..fc90258f9 --- /dev/null +++ b/test/core/use_cases/received/case/test_create.py @@ -0,0 +1,316 @@ +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute +# to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype +# is licensed under a MIT (SEI)-style license, please see LICENSE.md +# distributed with this Software or contact permission@sei.cmu.edu for full +# terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University +"""Tests for Case Bootstrap Trust (CBT-05-001 through CBT-05-004). + +Covers: + CBT-05-001 Reporter accepts CaseActor Announce after bootstrap Create. + CBT-05-002 Bootstrap Create rejected when sender ≠ trusted_case_creator_id. + CBT-05-003 No-link path: Create without a matching ReportCaseLink is a + no-op (receiver is not the original reporter). + CBT-05-004 trusted_case_actor_id is extracted from the CASE_MANAGER participant + in the bootstrap snapshot and recorded in the ReportCaseLink. + +See also: + test_bootstrap_participants.py CBT-05-005/006 embedded participant storage. + test_helpers.py CBT-05-006/007 reporter participant seeding + and RM.ACCEPTED upgrade (#589, #624). +""" + +import pytest + +from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer +from vultron.core.models.report_case_link import VultronReportCaseLink +from vultron.core.states.roles import CVDRole +from vultron.core.use_cases.received.actor import _find_case_actor_id +from vultron.core.use_cases.received.actor.announce import ( + AnnounceVulnerabilityCaseReceivedUseCase, +) +from vultron.core.use_cases.received.case.create import ( + CreateCaseReceivedUseCase, +) +from vultron.wire.as2.factories import ( + announce_vulnerability_case_activity, + create_case_activity, +) +from vultron.wire.as2.vocab.objects.case_participant import ( + CaseParticipant, +) +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase + +# --------------------------------------------------------------------------- +# Shared constants +# --------------------------------------------------------------------------- + +_CREATOR_ID = "https://example.org/actors/creator" +_REPORTER_ID = "https://example.org/actors/reporter" +_IMPOSTER_ID = "https://example.org/actors/imposter" +_CASE_ACTOR_ID = "https://example.org/actors/case-actor" +_VENDOR_ID = "https://example.org/actors/vendor" +_CASE_ID = "https://example.org/cases/cbt-test-001" +_REPORT_ID = "https://example.org/reports/cbt-report-001" +_PARTICIPANT_ID = f"{_CASE_ID}/participants/case-actor" +_VENDOR_PARTICIPANT_ID = f"{_CASE_ID}/participants/vendor" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _case_with_case_actor_participant() -> tuple: + """Build a VulnerabilityCase whose participant list includes a CASE_MANAGER. + + Returns a tuple of (VulnerabilityCase, CaseActorParticipant). The + participant is embedded INLINE in the case snapshot (not just an ID), + matching what a real bootstrap ``Create(VulnerabilityCase)`` would carry. + """ + participant = CaseParticipant( + case_roles=[CVDRole.CASE_MANAGER], + id_=_PARTICIPANT_ID, + attributed_to=_CASE_ACTOR_ID, + context=_CASE_ID, + name="CaseActor", + ) + case = VulnerabilityCase( + id_=_CASE_ID, + name="CBT test case", + case_participants=[participant], + ) + return case, participant + + +def _build_link( + *, + trusted_case_creator_id: str | None = _CREATOR_ID, + case_id: str | None = None, + trusted_case_actor_id: str | None = None, +) -> VultronReportCaseLink: + return VultronReportCaseLink( + report_id=_REPORT_ID, + case_id=case_id, + trusted_case_creator_id=trusted_case_creator_id, + trusted_case_actor_id=trusted_case_actor_id, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def dl(): + return SqliteDataLayer("sqlite:///:memory:") + + +@pytest.fixture() +def case_with_participant(): + """Return (VulnerabilityCase, VultronParticipant) with CASE_MANAGER role.""" + return _case_with_case_actor_participant() + + +@pytest.fixture() +def create_activity(case_with_participant): + case, _ = case_with_participant + return create_case_activity(case, actor=_CREATOR_ID) + + +@pytest.fixture() +def create_event(make_payload, create_activity): + return make_payload(create_activity) + + +# --------------------------------------------------------------------------- +# CBT-05-001: Bootstrap Create from trusted creator seeds the case replica +# --------------------------------------------------------------------------- + + +class TestBootstrapCreateAccepted: + """CBT-05-001 — reporter accepts bootstrap Create from trusted creator.""" + + def test_case_seeded_in_datalayer( + self, dl, create_event, case_with_participant + ): + """After a valid bootstrap, the case replica exists in the DataLayer.""" + link = _build_link() + dl.save(link) + + CreateCaseReceivedUseCase(dl, create_event).execute() + + stored = dl.read(_CASE_ID) + assert ( + stored is not None + ), "Case should be seeded after valid bootstrap" + + def test_report_case_link_updated_with_case_id( + self, dl, create_event, case_with_participant + ): + """Bootstrap updates ReportCaseLink.case_id (CBT-01-006).""" + link = _build_link() + dl.save(link) + + CreateCaseReceivedUseCase(dl, create_event).execute() + + updated = dl.read(link.id_) + assert isinstance(updated, VultronReportCaseLink) + assert updated.case_id == _CASE_ID + + def test_report_case_link_updated_with_trusted_case_actor_id( + self, dl, create_event, case_with_participant + ): + """Bootstrap extracts trusted_case_actor_id from CASE_MANAGER participant + (CBT-01-003, CBT-01-006).""" + link = _build_link() + dl.save(link) + + CreateCaseReceivedUseCase(dl, create_event).execute() + + updated = dl.read(link.id_) + assert isinstance(updated, VultronReportCaseLink) + assert updated.trusted_case_actor_id == _CASE_ACTOR_ID + + +# --------------------------------------------------------------------------- +# CBT-05-002: Bootstrap Create rejected when sender ≠ trusted_case_creator_id +# --------------------------------------------------------------------------- + + +class TestBootstrapCreateRejectedBadSender: + """CBT-05-002 — imposter sender is rejected; case is NOT seeded.""" + + @pytest.fixture() + def imposter_activity(self, case_with_participant): + case, _ = case_with_participant + return create_case_activity(case, actor=_IMPOSTER_ID) + + @pytest.fixture() + def imposter_event(self, make_payload, imposter_activity): + return make_payload(imposter_activity) + + def test_case_not_created(self, dl, imposter_event, case_with_participant): + """Case must NOT be seeded when sender ≠ trusted_case_creator_id.""" + link = _build_link(trusted_case_creator_id=_CREATOR_ID) + dl.save(link) + + CreateCaseReceivedUseCase(dl, imposter_event).execute() + + stored = dl.read(_CASE_ID) + assert ( + stored is None + ), "Case must not be seeded when sender is not trusted creator" + + def test_link_not_updated(self, dl, imposter_event, case_with_participant): + """ReportCaseLink must NOT be updated when bootstrap is rejected.""" + link = _build_link(trusted_case_creator_id=_CREATOR_ID) + dl.save(link) + + CreateCaseReceivedUseCase(dl, imposter_event).execute() + + updated = dl.read(link.id_) + assert isinstance(updated, VultronReportCaseLink) + assert updated.case_id is None + assert updated.trusted_case_actor_id is None + + +# --------------------------------------------------------------------------- +# CBT-05-003: No ReportCaseLink means receiver is not the reporter — no-op +# --------------------------------------------------------------------------- + + +class TestBootstrapCreateNoLink: + """CBT-05-003 — no matching ReportCaseLink → case is not a known reporter.""" + + def test_case_not_created_without_link( + self, dl, create_event, case_with_participant + ): + """Without a ReportCaseLink, CreateCaseReceivedUseCase is a no-op.""" + # Do NOT create a VultronReportCaseLink + + CreateCaseReceivedUseCase(dl, create_event).execute() + + stored = dl.read(_CASE_ID) + assert ( + stored is None + ), "Case should not be seeded when receiver has no matching ReportCaseLink" + + +# --------------------------------------------------------------------------- +# CBT-05-004: trusted_case_actor_id gates subsequent Announce acceptance +# --------------------------------------------------------------------------- + + +class TestAnnounceValidatedByTrustedCaseActorId: + """CBT-05-004 — only the bootstrapped CaseActor may push Announce updates.""" + + @pytest.fixture() + def case_obj(self): + return VulnerabilityCase(id_=_CASE_ID, name="CBT announce gate case") + + @pytest.fixture() + def announce_from_trusted(self, case_obj): + return announce_vulnerability_case_activity( + case_obj, actor=_CASE_ACTOR_ID + ) + + @pytest.fixture() + def announce_from_imposter(self, case_obj): + return announce_vulnerability_case_activity( + case_obj, actor=_IMPOSTER_ID + ) + + def test_trusted_actor_announce_accepted( + self, dl, make_payload, case_obj, announce_from_trusted + ): + """Announce from trusted_case_actor_id is accepted (CBT-05-004).""" + link = _build_link( + case_id=_CASE_ID, trusted_case_actor_id=_CASE_ACTOR_ID + ) + dl.save(link) + + event = make_payload(announce_from_trusted) + AnnounceVulnerabilityCaseReceivedUseCase(dl, event).execute() + + stored = dl.read(_CASE_ID) + assert ( + stored is not None + ), "Announce from trusted CaseActor must seed the case" + + def test_imposter_announce_rejected( + self, dl, make_payload, case_obj, announce_from_imposter + ): + """Announce from actor other than trusted_case_actor_id is rejected.""" + link = _build_link( + case_id=_CASE_ID, trusted_case_actor_id=_CASE_ACTOR_ID + ) + dl.save(link) + + event = make_payload(announce_from_imposter) + AnnounceVulnerabilityCaseReceivedUseCase(dl, event).execute() + + stored = dl.read(_CASE_ID) + assert stored is None, ( + "Announce from imposter must be rejected when trusted_case_actor_id " + "is set (CBT-05-004, PCR-03-001)" + ) + + def test_find_case_actor_id_prefers_link_over_service(self, dl, case_obj): + """_find_case_actor_id returns trusted_case_actor_id from link first.""" + link = _build_link( + case_id=_CASE_ID, trusted_case_actor_id=_CASE_ACTOR_ID + ) + dl.save(link) + + result = _find_case_actor_id(dl, _CASE_ID) + assert result == _CASE_ACTOR_ID diff --git a/test/core/use_cases/received/case/test_engage_defer.py b/test/core/use_cases/received/case/test_engage_defer.py new file mode 100644 index 000000000..5f09f0ce2 --- /dev/null +++ b/test/core/use_cases/received/case/test_engage_defer.py @@ -0,0 +1,204 @@ +# Copyright (c) 2025-2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University +"""Tests for case-related use-case engage/defer handlers.""" + +import logging + +import pytest + +from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer +from vultron.core.models.base import VultronObject +from vultron.core.models.case import VultronCase +from vultron.core.models.events import MessageSemantics +from vultron.core.models.events.case import ( + DeferCaseReceivedEvent, + EngageCaseReceivedEvent, +) +from vultron.core.models.participant import VultronParticipant +from vultron.core.use_cases.received.case.engage_defer import ( + DeferCaseReceivedUseCase, + EngageCaseReceivedUseCase, +) + + +class TestEngageDeferCaseBTFailureReason: + """Regression tests for BUG-471.6. + + When EngageCaseBT or DeferCaseBT fails (e.g., no participant record + exists for the given actor), the WARNING log must include a non-empty + failure reason — not a trailing colon with nothing after it. + """ + + @pytest.fixture + def dl(self): + return SqliteDataLayer("sqlite:///:memory:") + + @pytest.fixture + def actor_id(self): + return "https://example.org/actors/vendor" + + @pytest.fixture + def case_id(self): + return "urn:uuid:338a1bc3-0000-0000-0000-000000000001" + + def _engage_event( + self, actor_id: str, case_id: str + ) -> EngageCaseReceivedEvent: + return EngageCaseReceivedEvent( + activity_id="https://example.org/activities/engage-001", + actor_id=actor_id, + object_=VultronObject(id_=case_id), + semantic_type=MessageSemantics.ENGAGE_CASE, + ) + + def _defer_event( + self, actor_id: str, case_id: str + ) -> DeferCaseReceivedEvent: + return DeferCaseReceivedEvent( + activity_id="https://example.org/activities/defer-001", + actor_id=actor_id, + object_=VultronObject(id_=case_id), + semantic_type=MessageSemantics.DEFER_CASE, + ) + + def test_engage_case_failure_reason_is_nonempty( + self, dl, actor_id, case_id, caplog + ): + """EngageCaseBT WARNING includes a non-empty failure reason. + + When CheckParticipantExists fails (no participant record), + the warning must name the failing node, not end with a bare colon. + """ + event = self._engage_event(actor_id, case_id) + + with caplog.at_level(logging.WARNING): + EngageCaseReceivedUseCase(dl, event).execute() + + records = [ + r + for r in caplog.records + if "EngageCaseBT did not succeed" in r.message + ] + assert records, "Expected EngageCaseBT warning to be emitted" + reason = records[0].message.rsplit(":", 1)[-1].strip() + assert reason, ( + "EngageCaseBT warning must include a non-empty failure reason; " + f"got: {records[0].message!r}" + ) + + def test_defer_case_failure_reason_is_nonempty( + self, dl, actor_id, case_id, caplog + ): + """DeferCaseBT WARNING includes a non-empty failure reason. + + When CheckParticipantExists fails (no participant record), + the warning must name the failing node, not end with a bare colon. + """ + event = self._defer_event(actor_id, case_id) + + with caplog.at_level(logging.WARNING): + DeferCaseReceivedUseCase(dl, event).execute() + + records = [ + r + for r in caplog.records + if "DeferCaseBT did not succeed" in r.message + ] + assert records, "Expected DeferCaseBT warning to be emitted" + reason = records[0].message.rsplit(":", 1)[-1].strip() + assert reason, ( + "DeferCaseBT warning must include a non-empty failure reason; " + f"got: {records[0].message!r}" + ) + + +class TestEngageCaseStoresEmbeddedParticipants: + """EngageCaseReceivedUseCase must call _store_embedded_participants (#573). + + Regression tests: when Join(VulnerabilityCase) arrives with inline + participant objects, those objects must be persisted as independent + DataLayer records before the BT runs — matching the pattern already + established for Create (#564) and Announce (#566) paths. + """ + + _ACTOR_ID = "https://vendor.example.org/actors/vendor" + _CASE_ID = "https://example.org/cases/case-573-001" + _PARTICIPANT_ID = f"{_CASE_ID}/participants/vendor" + + @pytest.fixture + def dl(self): + return SqliteDataLayer("sqlite:///:memory:") + + @pytest.fixture + def case_with_inline_participant(self): + """VultronCase carrying a fully inline VultronParticipant.""" + participant = VultronParticipant( + id_=self._PARTICIPANT_ID, + attributed_to=self._ACTOR_ID, + context=self._CASE_ID, + ) + case = VultronCase(id_=self._CASE_ID) + case.case_participants = [participant] + return case + + @pytest.fixture + def engage_event_with_inline_case(self, case_with_inline_participant): + return EngageCaseReceivedEvent( + activity_id="https://example.org/activities/engage-573", + actor_id=self._ACTOR_ID, + object_=case_with_inline_participant, + semantic_type=MessageSemantics.ENGAGE_CASE, + ) + + def test_inline_participant_stored_even_when_bt_fails( + self, dl, engage_event_with_inline_case + ): + """Embedded CaseParticipant is persisted before EngageCaseBT runs. + + Even when the BT fails (no pre-registered participant in the DataLayer), + _store_embedded_participants must run first and persist the inline + participant object (#573 regression). + """ + EngageCaseReceivedUseCase(dl, engage_event_with_inline_case).execute() + + stored = dl.read(self._PARTICIPANT_ID) + assert stored is not None, ( + "CaseParticipant embedded in Join(VulnerabilityCase) must be " + "stored as an independent DataLayer record before the BT runs " + "(EngageCaseReceivedUseCase regression #573)" + ) + + def test_bare_string_participant_is_not_stored(self, dl): + """When case_participants contains bare strings, nothing is stored. + + _store_embedded_participants is idempotent on strings; no error and + no false record is created (#573 does not regress bare-string path). + """ + case_str_participants = VultronCase(id_=self._CASE_ID) + case_str_participants.case_participants = [ + self._PARTICIPANT_ID + ] # bare string + event = EngageCaseReceivedEvent( + activity_id="https://example.org/activities/engage-573-str", + actor_id=self._ACTOR_ID, + object_=case_str_participants, + semantic_type=MessageSemantics.ENGAGE_CASE, + ) + EngageCaseReceivedUseCase(dl, event).execute() + + stored = dl.read(self._PARTICIPANT_ID) + assert stored is None, ( + "_store_embedded_participants must skip bare string participant " + "refs — no VultronParticipant record should be created for a bare " + "string" + ) diff --git a/test/core/use_cases/received/case/test_helpers.py b/test/core/use_cases/received/case/test_helpers.py new file mode 100644 index 000000000..8633d640e --- /dev/null +++ b/test/core/use_cases/received/case/test_helpers.py @@ -0,0 +1,321 @@ +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute +# to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype +# is licensed under a MIT (SEI)-style license, please see LICENSE.md +# distributed with this Software or contact permission@sei.cmu.edu for full +# terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University +"""Tests for _ensure_reporter_participant helper and EnsureReporterParticipantAtAcceptedNode +(CBT-05-006/007, #589, #624). + +Covers: + CBT-05-006 Bootstrap Create seeds the reporter participant at RM.ACCEPTED + when the participant arrives as a bare string ID (fix for #589). + CBT-05-007 Bootstrap Create upgrades an existing RM.START participant to + RM.ACCEPTED (fix for #624). + +Both requirements are now exercised via ``EnsureReporterParticipantAtAcceptedNode`` +(a BT leaf node) called through BTBridge from ``CreateCaseReceivedUseCase._handle_bootstrap`` +(BT-06-001, BT-15-001, #943). +""" + +import pytest + +from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer +from vultron.core.models.participant import VultronParticipant +from vultron.core.models.participant_status import ParticipantStatus +from vultron.core.models.report import VultronReport +from vultron.core.models.report_case_link import VultronReportCaseLink +from vultron.core.states.rm import RM +from vultron.core.states.roles import CVDRole +from vultron.core.use_cases.received.case.create import ( + CreateCaseReceivedUseCase, +) +from vultron.wire.as2.factories import create_case_activity +from vultron.wire.as2.vocab.objects.case_participant import CaseParticipant +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase + +# --------------------------------------------------------------------------- +# CBT-05-006: Reporter participant seeded with RM.ACCEPTED on bootstrap (#589) +# --------------------------------------------------------------------------- + + +class TestBootstrapCreateReporterParticipant: + """Bootstrap Create must seed the reporter's participant at RM.ACCEPTED. + + When Create(VulnerabilityCase) arrives with participant IDs as bare + strings, _store_embedded_participants skips them. The reporter's own + participant record would then be absent from their DataLayer, causing + SvcAddParticipantStatusUseCase._resolve_current_participant_state to + fall back to RM.START — the root cause of #589. + + The fix: _handle_bootstrap calls EnsureReporterParticipantAtAcceptedNode + via BTBridge, which infers from the reporter's submitted report that they + have already RM.ACCEPTED and creates the participant record with that state + if it is not already present (BT-06-001, BT-15-001, #943). + """ + + _VENDOR_ID = "https://vendor.example.org/actors/vendor-589" + _FINDER_ID = "https://finder.example.org/actors/finder-589" + _CASE_ID = "https://example.org/cases/case-589" + _REPORT_ID = "https://example.org/reports/report-589" + _FINDER_PARTICIPANT_ID = f"{_CASE_ID}/participants/finder-589" + _VENDOR_PARTICIPANT_ID = f"{_CASE_ID}/participants/vendor-589" + + @pytest.fixture() + def dl(self): + return SqliteDataLayer("sqlite:///:memory:") + + @pytest.fixture() + def seeded_dl(self, dl): + """DataLayer with the Finder's pre-existing report and case link.""" + report = VultronReport( + id_=self._REPORT_ID, + attributed_to=self._FINDER_ID, + ) + dl.create(report) + + link = VultronReportCaseLink( + report_id=self._REPORT_ID, + trusted_case_creator_id=self._VENDOR_ID, + ) + dl.save(link) + return dl + + @pytest.fixture() + def case_with_string_participants(self): + """VulnerabilityCase whose participants are bare string IDs. + + This is the common wire representation when the sender serialises the + domain VultronCase (which stores participant IDs, not objects). + The fixture also includes a CASE_MANAGER participant inline so that + the bootstrap trust path extracts a trusted_case_actor_id. + """ + case_actor_participant = CaseParticipant( + case_roles=[CVDRole.CASE_MANAGER], + id_=self._VENDOR_PARTICIPANT_ID, + attributed_to=self._VENDOR_ID, + context=self._CASE_ID, + ) + case = VulnerabilityCase( + id_=self._CASE_ID, + name="Bug #589 regression case", + case_participants=[ + case_actor_participant, # inline so CBT-01-003 can extract it + self._FINDER_PARTICIPANT_ID, # bare string — typical case + ], + ) + case.actor_participant_index[self._VENDOR_ID] = ( + self._VENDOR_PARTICIPANT_ID + ) + case.actor_participant_index[self._FINDER_ID] = ( + self._FINDER_PARTICIPANT_ID + ) + return case + + @pytest.fixture() + def create_event(self, make_payload, case_with_string_participants): + activity = create_case_activity( + case_with_string_participants, actor=self._VENDOR_ID + ) + return make_payload(activity) + + def test_reporter_participant_created_after_bootstrap( + self, seeded_dl, create_event + ): + """Reporter participant must exist in DataLayer after bootstrap (#589). + + When the bootstrap Create(VulnerabilityCase) carries the reporter's + participant as a bare string ID, the DataLayer must still produce a + standalone participant record for the reporter so that subsequent + SvcAddParticipantStatusUseCase calls can read it. + """ + CreateCaseReceivedUseCase(seeded_dl, create_event).execute() + + stored = seeded_dl.read(self._FINDER_PARTICIPANT_ID) + assert stored is not None, ( + "Reporter participant must be created in the DataLayer after " + "bootstrap even when case_participants contains a bare string ID " + "(regression #589)" + ) + + def test_reporter_participant_has_rm_accepted_after_bootstrap( + self, seeded_dl, create_event + ): + """Reporter participant must start at RM.ACCEPTED after bootstrap. + + The reporter submitted a report — by definition they have accepted the + vulnerability from their own RM perspective. The seeded participant + must reflect this so that _resolve_current_participant_state returns + RM.ACCEPTED rather than RM.START (#589). + """ + CreateCaseReceivedUseCase(seeded_dl, create_event).execute() + + stored = seeded_dl.read(self._FINDER_PARTICIPANT_ID) + assert stored is not None + statuses = getattr(stored, "participant_statuses", []) + assert statuses, ( + "Reporter participant must have at least one ParticipantStatus " + "after bootstrap (#589)" + ) + latest = statuses[-1] + rm_state = getattr(latest, "rm_state", None) + assert rm_state == RM.ACCEPTED, ( + f"Reporter participant must have rm_state=RM.ACCEPTED after " + f"bootstrap; got {rm_state!r} (#589)" + ) + + +# --------------------------------------------------------------------------- +# CBT-05-007: Reporter participant upgraded from RM.START to RM.ACCEPTED (#624) +# --------------------------------------------------------------------------- + + +class TestBootstrapReporterUpgradesFromStart: + """Bootstrap Create upgrades an existing RM.START participant to RM.ACCEPTED. + + When ``_store_embedded_participants`` stores the wire-layer snapshot, it may + seed the reporter's participant with ``rm_state=RM.START`` (the wire default). + ``EnsureReporterParticipantAtAcceptedNode`` must detect this and upgrade the + participant to ``RM.ACCEPTED`` via BTBridge (#624, BT-06-001, BT-15-001, + #943). + """ + + _VENDOR_ID = "https://vendor.example.org/actors/vendor-624" + _FINDER_ID = "https://finder.example.org/actors/finder-624" + _CASE_ID = "https://example.org/cases/case-624" + _REPORT_ID = "https://example.org/reports/report-624" + _FINDER_PARTICIPANT_ID = f"{_CASE_ID}/participants/finder-624" + _VENDOR_PARTICIPANT_ID = f"{_CASE_ID}/participants/vendor-624" + + @pytest.fixture() + def dl(self): + return SqliteDataLayer("sqlite:///:memory:") + + @pytest.fixture() + def base_dl(self, dl): + """DataLayer with report and link pre-seeded.""" + report = VultronReport( + id_=self._REPORT_ID, + attributed_to=self._FINDER_ID, + ) + dl.create(report) + + link = VultronReportCaseLink( + report_id=self._REPORT_ID, + trusted_case_creator_id=self._VENDOR_ID, + ) + dl.save(link) + return dl + + @pytest.fixture() + def case_with_string_participants(self): + case_actor_participant = CaseParticipant( + case_roles=[CVDRole.CASE_MANAGER], + id_=self._VENDOR_PARTICIPANT_ID, + attributed_to=self._VENDOR_ID, + context=self._CASE_ID, + ) + case = VulnerabilityCase( + id_=self._CASE_ID, + name="Bug #624 regression case", + case_participants=[ + case_actor_participant, + self._FINDER_PARTICIPANT_ID, # bare string + ], + ) + case.actor_participant_index[self._VENDOR_ID] = ( + self._VENDOR_PARTICIPANT_ID + ) + case.actor_participant_index[self._FINDER_ID] = ( + self._FINDER_PARTICIPANT_ID + ) + return case + + def _create_event(self, make_payload, case): + activity = create_case_activity(case, actor=self._VENDOR_ID) + return make_payload(activity) + + def _pre_seed_participant(self, dl, rm_state: RM) -> VultronParticipant: + """Store a finder participant at the given rm_state before bootstrap.""" + status = ParticipantStatus( + rm_state=rm_state, + context=self._CASE_ID, + attributed_to=self._FINDER_ID, + ) + participant = VultronParticipant( + id_=self._FINDER_PARTICIPANT_ID, + attributed_to=self._FINDER_ID, + context=self._CASE_ID, + participant_statuses=[status], + ) + dl.create(participant) + return participant + + def test_reporter_participant_upgraded_from_start_to_accepted( + self, base_dl, make_payload, case_with_string_participants + ): + """Reporter participant at RM.START must be upgraded to RM.ACCEPTED (#624). + + Pre-condition: reporter's participant is already in the DataLayer at + RM.START (seeded by _store_embedded_participants or a prior bootstrap). + Post-condition: after CreateCaseReceivedUseCase, the participant's latest + rm_state is RM.ACCEPTED. + """ + self._pre_seed_participant(base_dl, RM.START) + event = self._create_event(make_payload, case_with_string_participants) + + CreateCaseReceivedUseCase(base_dl, event).execute() + + stored = base_dl.read(self._FINDER_PARTICIPANT_ID) + assert stored is not None + statuses = getattr(stored, "participant_statuses", []) + assert statuses, "Reporter participant must have at least one status" + latest_rm = statuses[-1].rm_state + assert latest_rm == RM.ACCEPTED, ( + f"Reporter participant must be upgraded to RM.ACCEPTED from " + f"RM.START; got {latest_rm!r} (#624)" + ) + + def test_reporter_participant_noop_if_already_accepted( + self, base_dl, make_payload, case_with_string_participants + ): + """Reporter participant already at RM.ACCEPTED must not be modified (#624).""" + self._pre_seed_participant(base_dl, RM.ACCEPTED) + event = self._create_event(make_payload, case_with_string_participants) + + CreateCaseReceivedUseCase(base_dl, event).execute() + + stored = base_dl.read(self._FINDER_PARTICIPANT_ID) + assert stored is not None + statuses = getattr(stored, "participant_statuses", []) + assert len(statuses) == 1, ( + "Reporter participant already at RM.ACCEPTED must not gain extra " + f"statuses; got {len(statuses)} (#624)" + ) + assert statuses[0].rm_state == RM.ACCEPTED + + def test_reporter_participant_noop_if_already_closed( + self, base_dl, make_payload, case_with_string_participants + ): + """Reporter participant already at RM.CLOSED must not be downgraded (#624).""" + self._pre_seed_participant(base_dl, RM.CLOSED) + event = self._create_event(make_payload, case_with_string_participants) + + CreateCaseReceivedUseCase(base_dl, event).execute() + + stored = base_dl.read(self._FINDER_PARTICIPANT_ID) + assert stored is not None + statuses = getattr(stored, "participant_statuses", []) + assert len(statuses) == 1, ( + "Reporter participant at RM.CLOSED must not gain extra statuses " + f"(it is already beyond ACCEPTED); got {len(statuses)} (#624)" + ) + assert statuses[0].rm_state == RM.CLOSED diff --git a/test/core/use_cases/received/test_case.py b/test/core/use_cases/received/case/test_update.py similarity index 63% rename from test/core/use_cases/received/test_case.py rename to test/core/use_cases/received/case/test_update.py index fc3ec2a25..5ac7ba801 100644 --- a/test/core/use_cases/received/test_case.py +++ b/test/core/use_cases/received/case/test_update.py @@ -10,38 +10,22 @@ # ("Third Party Software"). See LICENSE.md for more details. # Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the # U.S. Patent and Trademark Office by Carnegie Mellon University -"""Tests for case-related use-case classes.""" +"""Tests for case-related use-case update handlers. + +BT structure tests (tree shape and no-post-BT-broadcast contract) have been +extracted to ``test_update_bt.py`` to separate use-case behavior assertions +from tree-structure assertions. +""" import logging from typing import cast -import pytest - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer -from vultron.core.models.base import VultronObject -from vultron.core.models.case import VultronCase -from vultron.core.models.case_actor import VultronCaseActor from vultron.core.models.activity import VultronActivity -from vultron.core.models.events import MessageSemantics -from vultron.core.models.events.case import ( - DeferCaseReceivedEvent, - EngageCaseReceivedEvent, -) -from vultron.core.models.participant import VultronParticipant -from vultron.core.use_cases.received.case import ( - DeferCaseReceivedUseCase, - EngageCaseReceivedUseCase, +from vultron.core.models.case_actor import VultronCaseActor +from vultron.core.use_cases.received.case.update import ( UpdateCaseReceivedUseCase, ) -from vultron.core.behaviors.case.update_tree import ( - create_update_case_received_tree, -) -from vultron.core.behaviors.case.nodes.update import ( - ApplyCaseUpdateNode, - BroadcastCaseUpdateNode, - CaptureCaseUpdateBroadcastExclusionsNode, - CheckCaseUpdateOwnerNode, -) from vultron.wire.as2.rehydration import rehydrate as real_rehydrate from vultron.wire.as2.vocab.objects.case_participant import CaseParticipant from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent @@ -494,256 +478,3 @@ def test_update_case_broadcast_includes_all_participants( broadcast = cast(VultronActivity, broadcast) assert broadcast.to is not None assert set(broadcast.to) == {alice, bob} - - def test_update_case_bt_structure_includes_broadcast_node( - self, make_payload - ): - """UpdateCaseBT keeps ownership, embargo, update, and broadcast in-tree.""" - owner_id = "https://example.org/users/owner" - case_id = "https://example.org/cases/bt1" - updated_case = VulnerabilityCase( - id_=case_id, name="Updated", attributed_to=owner_id - ) - activity = update_case_activity(updated_case, actor=owner_id) - event = make_payload(activity) - - tree = create_update_case_received_tree( - case_id=case_id, - actor_id=owner_id, - request=event, - ) - - assert tree.name == "UpdateCaseBT" - assert [child.__class__ for child in tree.children] == [ - CheckCaseUpdateOwnerNode, - CaptureCaseUpdateBroadcastExclusionsNode, - ApplyCaseUpdateNode, - BroadcastCaseUpdateNode, - ] - - def test_update_case_bt_executes_without_post_bt_broadcast( - self, make_payload, monkeypatch - ): - """UpdateCaseBT handles the broadcast internally instead of after execute().""" - dl = SqliteDataLayer("sqlite:///:memory:") - owner_id = "https://example.org/users/owner" - participant_id = "https://example.org/users/alice" - case_id = "https://example.org/cases/bt2" - - case_actor = VultronCaseActor( - id_=f"{case_id}/actor", - name=f"CaseActor for {case_id}", - attributed_to=owner_id, - context=case_id, - ) - dl.create(case_actor) - - case = VulnerabilityCase( - id_=case_id, - name="Original", - attributed_to=owner_id, - ) - case.actor_participant_index[participant_id] = ( - "https://example.org/participants/p-bt2" - ) - dl.create(case) - - updated_case = VulnerabilityCase( - id_=case_id, - name="Updated", - attributed_to=owner_id, - ) - activity = update_case_activity(updated_case, actor=owner_id) - event = make_payload(activity) - - def _should_not_be_called(*args, **kwargs): - raise AssertionError("post-BT broadcast helper should not run") - - monkeypatch.setattr( - UpdateCaseReceivedUseCase, - "_broadcast_case_update", - _should_not_be_called, - ) - - UpdateCaseReceivedUseCase(dl, event).execute() - - outbox_items = dl.outbox_list_for_actor(case_actor.id_) - assert len(outbox_items) == 1 - - -class TestEngageDeferCaseBTFailureReason: - """Regression tests for BUG-471.6. - - When EngageCaseBT or DeferCaseBT fails (e.g., no participant record - exists for the given actor), the WARNING log must include a non-empty - failure reason — not a trailing colon with nothing after it. - """ - - @pytest.fixture - def dl(self): - return SqliteDataLayer("sqlite:///:memory:") - - @pytest.fixture - def actor_id(self): - return "https://example.org/actors/vendor" - - @pytest.fixture - def case_id(self): - return "urn:uuid:338a1bc3-0000-0000-0000-000000000001" - - def _engage_event( - self, actor_id: str, case_id: str - ) -> EngageCaseReceivedEvent: - return EngageCaseReceivedEvent( - activity_id="https://example.org/activities/engage-001", - actor_id=actor_id, - object_=VultronObject(id_=case_id), - semantic_type=MessageSemantics.ENGAGE_CASE, - ) - - def _defer_event( - self, actor_id: str, case_id: str - ) -> DeferCaseReceivedEvent: - return DeferCaseReceivedEvent( - activity_id="https://example.org/activities/defer-001", - actor_id=actor_id, - object_=VultronObject(id_=case_id), - semantic_type=MessageSemantics.DEFER_CASE, - ) - - def test_engage_case_failure_reason_is_nonempty( - self, dl, actor_id, case_id, caplog - ): - """EngageCaseBT WARNING includes a non-empty failure reason. - - When CheckParticipantExists fails (no participant record), - the warning must name the failing node, not end with a bare colon. - """ - event = self._engage_event(actor_id, case_id) - - with caplog.at_level(logging.WARNING): - EngageCaseReceivedUseCase(dl, event).execute() - - records = [ - r - for r in caplog.records - if "EngageCaseBT did not succeed" in r.message - ] - assert records, "Expected EngageCaseBT warning to be emitted" - reason = records[0].message.rsplit(":", 1)[-1].strip() - assert reason, ( - "EngageCaseBT warning must include a non-empty failure reason; " - f"got: {records[0].message!r}" - ) - - def test_defer_case_failure_reason_is_nonempty( - self, dl, actor_id, case_id, caplog - ): - """DeferCaseBT WARNING includes a non-empty failure reason. - - When CheckParticipantExists fails (no participant record), - the warning must name the failing node, not end with a bare colon. - """ - event = self._defer_event(actor_id, case_id) - - with caplog.at_level(logging.WARNING): - DeferCaseReceivedUseCase(dl, event).execute() - - records = [ - r - for r in caplog.records - if "DeferCaseBT did not succeed" in r.message - ] - assert records, "Expected DeferCaseBT warning to be emitted" - reason = records[0].message.rsplit(":", 1)[-1].strip() - assert reason, ( - "DeferCaseBT warning must include a non-empty failure reason; " - f"got: {records[0].message!r}" - ) - - -# --------------------------------------------------------------------------- -# #573: EngageCaseReceivedUseCase must store embedded participants -# --------------------------------------------------------------------------- - - -class TestEngageCaseStoresEmbeddedParticipants: - """EngageCaseReceivedUseCase must call _store_embedded_participants (#573). - - Regression tests: when Join(VulnerabilityCase) arrives with inline - participant objects, those objects must be persisted as independent - DataLayer records before the BT runs — matching the pattern already - established for Create (#564) and Announce (#566) paths. - """ - - _ACTOR_ID = "https://vendor.example.org/actors/vendor" - _CASE_ID = "https://example.org/cases/case-573-001" - _PARTICIPANT_ID = f"{_CASE_ID}/participants/vendor" - - @pytest.fixture - def dl(self): - return SqliteDataLayer("sqlite:///:memory:") - - @pytest.fixture - def case_with_inline_participant(self): - """VultronCase carrying a fully inline VultronParticipant.""" - participant = VultronParticipant( - id_=self._PARTICIPANT_ID, - attributed_to=self._ACTOR_ID, - context=self._CASE_ID, - ) - case = VultronCase(id_=self._CASE_ID) - case.case_participants = [participant] - return case - - @pytest.fixture - def engage_event_with_inline_case(self, case_with_inline_participant): - return EngageCaseReceivedEvent( - activity_id="https://example.org/activities/engage-573", - actor_id=self._ACTOR_ID, - object_=case_with_inline_participant, - semantic_type=MessageSemantics.ENGAGE_CASE, - ) - - def test_inline_participant_stored_even_when_bt_fails( - self, dl, engage_event_with_inline_case - ): - """Embedded CaseParticipant is persisted before EngageCaseBT runs. - - Even when the BT fails (no pre-registered participant in the DataLayer), - _store_embedded_participants must run first and persist the inline - participant object (#573 regression). - """ - EngageCaseReceivedUseCase(dl, engage_event_with_inline_case).execute() - - stored = dl.read(self._PARTICIPANT_ID) - assert stored is not None, ( - "CaseParticipant embedded in Join(VulnerabilityCase) must be " - "stored as an independent DataLayer record before the BT runs " - "(EngageCaseReceivedUseCase regression #573)" - ) - - def test_bare_string_participant_is_not_stored(self, dl): - """When case_participants contains bare strings, nothing is stored. - - _store_embedded_participants is idempotent on strings; no error and - no false record is created (#573 does not regress bare-string path). - """ - case_str_participants = VultronCase(id_=self._CASE_ID) - case_str_participants.case_participants = [ - self._PARTICIPANT_ID - ] # bare string - event = EngageCaseReceivedEvent( - activity_id="https://example.org/activities/engage-573-str", - actor_id=self._ACTOR_ID, - object_=case_str_participants, - semantic_type=MessageSemantics.ENGAGE_CASE, - ) - EngageCaseReceivedUseCase(dl, event).execute() - - stored = dl.read(self._PARTICIPANT_ID) - assert stored is None, ( - "_store_embedded_participants must skip bare string participant " - "refs — no VultronParticipant record should be created for a bare " - "string" - ) diff --git a/test/core/use_cases/received/case/test_update_bt.py b/test/core/use_cases/received/case/test_update_bt.py new file mode 100644 index 000000000..6a12c4755 --- /dev/null +++ b/test/core/use_cases/received/case/test_update_bt.py @@ -0,0 +1,109 @@ +# Copyright (c) 2025-2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University +"""BT structure and no-post-BT-broadcast tests for UpdateCaseBT.""" + +from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer +from vultron.core.behaviors.case.nodes.update import ( + ApplyCaseUpdateNode, + BroadcastCaseUpdateNode, + CaptureCaseUpdateBroadcastExclusionsNode, + CheckCaseUpdateOwnerNode, +) +from vultron.core.behaviors.case.update_tree import ( + create_update_case_received_tree, +) +from vultron.core.models.case_actor import VultronCaseActor +from vultron.core.use_cases.received.case.update import ( + UpdateCaseReceivedUseCase, +) +from vultron.wire.as2.factories import update_case_activity +from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase + + +class TestUpdateCaseBTStructure: + """BT tree-structure and no-post-BT-broadcast assertions for UpdateCaseBT.""" + + def test_update_case_bt_structure_includes_broadcast_node( + self, make_payload + ): + """UpdateCaseBT keeps ownership, embargo, update, and broadcast in-tree.""" + owner_id = "https://example.org/users/owner" + case_id = "https://example.org/cases/bt1" + updated_case = VulnerabilityCase( + id_=case_id, name="Updated", attributed_to=owner_id + ) + activity = update_case_activity(updated_case, actor=owner_id) + event = make_payload(activity) + + tree = create_update_case_received_tree( + case_id=case_id, + actor_id=owner_id, + request=event, + ) + + assert tree.name == "UpdateCaseBT" + assert [child.__class__ for child in tree.children] == [ + CheckCaseUpdateOwnerNode, + CaptureCaseUpdateBroadcastExclusionsNode, + ApplyCaseUpdateNode, + BroadcastCaseUpdateNode, + ] + + def test_update_case_bt_executes_without_post_bt_broadcast( + self, make_payload, monkeypatch + ): + """UpdateCaseBT handles the broadcast internally instead of after execute().""" + dl = SqliteDataLayer("sqlite:///:memory:") + owner_id = "https://example.org/users/owner" + participant_id = "https://example.org/users/alice" + case_id = "https://example.org/cases/bt2" + + case_actor = VultronCaseActor( + id_=f"{case_id}/actor", + name=f"CaseActor for {case_id}", + attributed_to=owner_id, + context=case_id, + ) + dl.create(case_actor) + + case = VulnerabilityCase( + id_=case_id, + name="Original", + attributed_to=owner_id, + ) + case.actor_participant_index[participant_id] = ( + "https://example.org/participants/p-bt2" + ) + dl.create(case) + + updated_case = VulnerabilityCase( + id_=case_id, + name="Updated", + attributed_to=owner_id, + ) + activity = update_case_activity(updated_case, actor=owner_id) + event = make_payload(activity) + + def _should_not_be_called(*args, **kwargs): + raise AssertionError("post-BT broadcast helper should not run") + + monkeypatch.setattr( + UpdateCaseReceivedUseCase, + "_broadcast_case_update", + _should_not_be_called, + ) + + UpdateCaseReceivedUseCase(dl, event).execute() + + outbox_items = dl.outbox_list_for_actor(case_actor.id_) + assert len(outbox_items) == 1 diff --git a/test/core/use_cases/received/test_actor.py b/test/core/use_cases/received/test_actor.py deleted file mode 100644 index 203af60be..000000000 --- a/test/core/use_cases/received/test_actor.py +++ /dev/null @@ -1,959 +0,0 @@ -# Copyright (c) 2025-2026 Carnegie Mellon University and Contributors. -# - see Contributors.md for a full list of Contributors -# - see ContributionInstructions.md for information on how you can Contribute to this project -# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is -# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed -# with this Software or contact permission@sei.cmu.edu for full terms. -# Created, in part, with funding and support from the United States Government -# (see Acknowledgments file). This program may include and/or can make use of -# certain third party source code, object code, documentation and other files -# ("Third Party Software"). See LICENSE.md for more details. -# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the -# U.S. Patent and Trademark Office by Carnegie Mellon University -"""Tests for actor-related use-case classes.""" - -import logging -from typing import Any, cast -from unittest.mock import MagicMock - -import py_trees -import pytest - -from vultron.wire.as2.vocab.base.objects.actors import as_Actor -from vultron.wire.as2.vocab.objects.vulnerability_case import ( - VulnerabilityCase, - VulnerabilityCaseStub, -) -from vultron.core.use_cases.received.actor import ( - AcceptCaseManagerRoleReceivedUseCase, - AcceptCaseOwnershipTransferReceivedUseCase, - AcceptInviteActorToCaseReceivedUseCase, - AcceptSuggestActorToCaseReceivedUseCase, - OfferCaseManagerRoleReceivedUseCase, - OfferCaseOwnershipTransferReceivedUseCase, - RejectCaseManagerRoleReceivedUseCase, - RejectCaseOwnershipTransferReceivedUseCase, - RejectInviteActorToCaseReceivedUseCase, - RejectSuggestActorToCaseReceivedUseCase, - SuggestActorToCaseReceivedUseCase, - InviteActorToCaseReceivedUseCase, -) -from vultron.wire.as2.factories import ( - accept_actor_recommendation_activity, - accept_case_manager_role_activity, - accept_case_ownership_transfer_activity, - offer_case_manager_role_activity, - offer_case_ownership_transfer_activity, - recommend_actor_activity, - reject_actor_recommendation_activity, - reject_case_manager_role_activity, - reject_case_ownership_transfer_activity, - rm_accept_invite_to_case_activity, - rm_invite_to_case_activity, - rm_reject_invite_to_case_activity, -) -from vultron.adapters.driven.trigger_activity_adapter import ( - TriggerActivityAdapter, -) - - -class TestInviteActorUseCases: - """Tests for invite_actor_to_case, accept_invite_actor_to_case, - and reject_invite_actor_to_case.""" - - def test_invite_actor_to_case_stores_invite( - self, monkeypatch, make_payload - ): - """InviteActorToCaseReceivedUseCase persists the Invite activity to the DataLayer.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - - invite = rm_invite_to_case_activity( - as_Actor(id_="https://example.org/users/coordinator"), - target="https://example.org/cases/case1", - actor="https://example.org/users/owner", - id_="https://example.org/cases/case1/invitations/1", - ) - - event = make_payload(invite) - - InviteActorToCaseReceivedUseCase(dl, event).execute() - - stored = dl.get(invite.type_.value, invite.id_) - assert stored is not None - - def test_invite_actor_to_case_idempotent(self, monkeypatch, make_payload): - """InviteActorToCaseReceivedUseCase skips storing a duplicate Invite.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - - invite = rm_invite_to_case_activity( - as_Actor(id_="https://example.org/users/coordinator"), - target="https://example.org/cases/case1", - actor="https://example.org/users/owner", - id_="https://example.org/cases/case1/invitations/2", - ) - - event = make_payload(invite) - - InviteActorToCaseReceivedUseCase(dl, event).execute() - InviteActorToCaseReceivedUseCase( - dl, event - ).execute() # second call is no-op - - stored = dl.get(invite.type_.value, invite.id_) - assert stored is not None - - def test_reject_invite_actor_to_case_ledgers_rejection(self, make_payload): - """RejectInviteActorToCaseReceivedUseCase logs without raising.""" - invite = rm_invite_to_case_activity( - as_Actor(id_="https://example.org/users/coordinator"), - target="https://example.org/cases/case1", - actor="https://example.org/users/owner", - id_="https://example.org/cases/case1/invitations/3", - ) - reject = rm_reject_invite_to_case_activity( - invite, - actor="https://example.org/users/coordinator", - ) - - event = make_payload(reject) - - result = RejectInviteActorToCaseReceivedUseCase( - MagicMock(), event - ).execute() - assert result is None - - def test_accept_invite_actor_to_case_adds_participant( - self, monkeypatch, make_payload - ): - """AcceptInviteActorToCaseReceivedUseCase creates a CaseParticipant and adds them to the case.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Organization - from vultron.wire.as2.vocab.objects.vulnerability_case import ( - VulnerabilityCase, - ) - - dl = SqliteDataLayer("sqlite:///:memory:") - invitee_id = "https://example.org/users/coordinator" - invitee = as_Organization(id_=invitee_id) - case = VulnerabilityCase( - id_="https://example.org/cases/caseIA1", - name="TEST-ACCEPT-INVITE", - ) - invite = rm_invite_to_case_activity( - invitee, - target=VulnerabilityCaseStub(id_=case.id_), - actor="https://example.org/users/owner", - id_="https://example.org/cases/caseIA1/invitations/1", - ) - dl.create(invitee) - dl.create(case) - dl.create(invite) - - accept = rm_accept_invite_to_case_activity( - invite, - actor=invitee_id, - ) - - event = make_payload(accept) - - AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() - - case = dl.read(case.id_) - assert case is not None - case = cast(VulnerabilityCase, case) - assert invitee_id in case.actor_participant_index - - def test_accept_invite_actor_to_case_records_active_embargo( - self, monkeypatch, make_payload - ): - """AcceptInviteActorToCaseReceivedUseCase records the active embargo ID on the new participant (CM-10-001, CM-10-003).""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.core.states.em import EM - from vultron.wire.as2.vocab.base.objects.actors import as_Organization - from vultron.wire.as2.vocab.objects.embargo_event import EmbargoEvent - from vultron.wire.as2.vocab.objects.vulnerability_case import ( - VulnerabilityCase, - ) - - dl = SqliteDataLayer("sqlite:///:memory:") - invitee_id = "https://example.org/users/coordinator" - invitee = as_Organization(id_=invitee_id) - embargo = EmbargoEvent( - id_="https://example.org/cases/caseIA2/embargo_events/e1", - content="Active embargo", - ) - case = VulnerabilityCase( - id_="https://example.org/cases/caseIA2", - name="TEST-ACCEPT-INVITE-EMBARGO", - ) - case.active_embargo = embargo.id_ - case.current_status.em_state = EM.ACTIVE - invite = rm_invite_to_case_activity( - invitee, - target=VulnerabilityCaseStub(id_=case.id_), - actor="https://example.org/users/owner", - id_="https://example.org/cases/caseIA2/invitations/1", - ) - dl.create(invitee) - dl.create(case) - dl.create(embargo) - dl.create(invite) - - accept = rm_accept_invite_to_case_activity( - invite, - actor=invitee_id, - ) - - event = make_payload(accept) - - AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() - - case = dl.read(case.id_) - assert case is not None - case = cast(VulnerabilityCase, case) - participant_id = case.actor_participant_index.get(invitee_id) - assert participant_id is not None - participant_obj = dl.get(id_=participant_id) - assert participant_obj is not None - participant_obj = cast(Any, participant_obj) - assert embargo.id_ in participant_obj.accepted_embargo_ids - - def test_accept_invite_participant_can_reach_rm_accepted( - self, make_payload - ): - """Accepted invite advances the participant to RM.ACCEPTED inline. - - PCR-08-010: Accept(Invite) IS the engage decision. The use case - records VALID→ACCEPTED directly on the participant without a separate - engage-case BT or emitting a proxy RmEngageCaseActivity. - """ - from typing import Any, cast - - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Organization - from vultron.wire.as2.vocab.objects.vulnerability_case import ( - VulnerabilityCase, - ) - from vultron.core.states.rm import RM - - dl = SqliteDataLayer("sqlite:///:memory:") - invitee_id = "https://example.org/users/coordinator_rm1" - invitee = as_Organization(id_=invitee_id) - owner_id = "https://example.org/users/owner" - case = VulnerabilityCase( - id_="https://example.org/cases/caseRM001", - name="TEST-RM-LIFECYCLE", - ) - invite = rm_invite_to_case_activity( - invitee, - target=VulnerabilityCaseStub(id_=case.id_), - actor=owner_id, - id_="https://example.org/cases/caseRM001/invitations/1", - ) - dl.create(invitee) - dl.create(case) - dl.create(invite) - - accept = rm_accept_invite_to_case_activity( - invite, - actor=invitee_id, - ) - event = make_payload(accept) - - # No TriggerActivityAdapter needed: RM.ACCEPTED is set inline. - AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() - - updated_case = cast(Any, dl.read(case.id_)) - participant_id = updated_case.actor_participant_index.get(invitee_id) - participant_obj = cast(Any, dl.get(id_=participant_id)) - latest_status = participant_obj.participant_statuses[-1] - assert latest_status.rm_state == RM.ACCEPTED - - def test_accept_invite_no_identity_spoofing(self, make_payload): - """PCR-07-008: AcceptInviteActorToCaseReceivedUseCase MUST NOT emit - RmEngageCaseActivity (Join) with actor=invitee_id from the Case Actor - context. The Accept(Invite) IS the engage decision; the participant - reaches RM.ACCEPTED via an inline state transition, not a spoofed BT - call. - """ - from typing import Any, cast - - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Organization - from vultron.wire.as2.vocab.objects.vulnerability_case import ( - VulnerabilityCase, - ) - from vultron.core.models.vultron_types import VultronParticipant - from vultron.core.states.rm import RM - from vultron.core.states.roles import CVDRole - - dl = SqliteDataLayer("sqlite:///:memory:") - invitee_id = "https://example.org/users/coordinator_rm2" - invitee = as_Organization(id_=invitee_id) - owner_id = "https://example.org/users/owner" - case_manager_participant_id = ( - "https://example.org/cases/caseRM002/participants/case-manager" - ) - case_manager_participant = VultronParticipant( - id_=case_manager_participant_id, - attributed_to=owner_id, - context="https://example.org/cases/caseRM002", - name="CaseManager", - case_roles=[CVDRole.CASE_MANAGER], - ) - case = VulnerabilityCase( - id_="https://example.org/cases/caseRM002", - name="TEST-RM-AUTO-ENGAGE", - case_participants=[case_manager_participant_id], - actor_participant_index={owner_id: case_manager_participant_id}, - ) - invite = rm_invite_to_case_activity( - invitee, - target=VulnerabilityCaseStub(id_=case.id_), - actor=owner_id, - id_="https://example.org/cases/caseRM002/invitations/1", - ) - dl.create(invitee) - dl.create(case_manager_participant) - dl.create(case) - dl.create(invite) - - accept = rm_accept_invite_to_case_activity( - invite, - actor=invitee_id, - ) - event = make_payload(accept) - - AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() - - # PCR-07-008: no RmEngageCaseActivity (Join) with actor=invitee_id - # should be queued in the invitee's outbox — the RM.ACCEPTED state is - # set inline; no BT proxy emit is permitted. - outbox_items = dl.clone_for_actor(invitee_id).outbox_list() - for item_id in outbox_items: - candidate = cast(Any, dl.read(item_id)) - if candidate is not None and str(candidate.type_) == "Join": - assert False, ( - f"PCR-07-008 violation: RmEngageCaseActivity (Join) with " - f"actor={invitee_id!r} found in outbox — identity spoofing" - ) - - # The participant should already be at RM.ACCEPTED (inline transition). - updated_case = cast(Any, dl.read(case.id_)) - participant_id = updated_case.actor_participant_index.get(invitee_id) - assert participant_id is not None - participant_obj = cast(Any, dl.get(id_=participant_id)) - assert participant_obj is not None - latest_status = participant_obj.participant_statuses[-1] - assert latest_status.rm_state == RM.ACCEPTED, ( - f"Expected RM.ACCEPTED after inline transition, " - f"got {latest_status.rm_state}" - ) - - def test_accept_invite_actor_to_case_records_case_event( - self, monkeypatch, make_payload - ): - """AcceptInviteActorToCaseReceivedUseCase appends a trusted-timestamp event to case.events (CM-02-009).""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Organization - from vultron.wire.as2.vocab.objects.vulnerability_case import ( - VulnerabilityCase, - ) - - dl = SqliteDataLayer("sqlite:///:memory:") - invitee_id = "https://example.org/users/coordinator" - invitee = as_Organization(id_=invitee_id) - case = VulnerabilityCase( - id_="https://example.org/cases/caseIA3", - name="TEST-ACCEPT-INVITE-EVENT", - ) - invite = rm_invite_to_case_activity( - invitee, - target=VulnerabilityCaseStub(id_=case.id_), - actor="https://example.org/users/owner", - id_="https://example.org/cases/caseIA3/invitations/1", - ) - dl.create(invitee) - dl.create(case) - dl.create(invite) - - accept = rm_accept_invite_to_case_activity( - invite, - actor=invitee_id, - ) - - event = make_payload(accept) - - assert len(case.events) == 0 - - AcceptInviteActorToCaseReceivedUseCase(dl, event).execute() - - case = dl.read(case.id_) - assert case is not None - case = cast(VulnerabilityCase, case) - assert len(case.events) >= 1 - event_types = [e.event_type for e in case.events] - assert "participant_joined" in event_types - - -class TestSuggestActorUseCases: - """Tests for suggest_actor_to_case, accept/reject suggest_actor use cases.""" - - def test_suggest_actor_to_case_persists_recommendation( - self, monkeypatch, make_payload - ): - """SuggestActorToCaseReceivedUseCase persists the RecommendActor offer.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Actor - - dl = SqliteDataLayer("sqlite:///:memory:") - - coordinator = as_Actor(id_="https://example.org/users/coordinator") - case = VulnerabilityCase( - id_="https://example.org/cases/case_sa1", - name="SA Case 1", - ) - activity = recommend_actor_activity( - coordinator, - target=case, - actor="https://example.org/users/finder", - to="https://example.org/users/vendor", - ) - - event = make_payload(activity) - - SuggestActorToCaseReceivedUseCase(dl, event).execute() - - stored = dl.get(activity.type_.value, activity.id_) - assert stored is not None - - def test_suggest_actor_to_case_idempotent(self, monkeypatch, make_payload): - """SuggestActorToCaseReceivedUseCase is idempotent — second call is a no-op.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Actor - - dl = SqliteDataLayer("sqlite:///:memory:") - - coordinator = as_Actor(id_="https://example.org/users/coordinator") - case = VulnerabilityCase( - id_="https://example.org/cases/case_sa2", - name="SA Case 2", - ) - activity = recommend_actor_activity( - coordinator, - target=case, - actor="https://example.org/users/finder", - ) - event = make_payload(activity) - - SuggestActorToCaseReceivedUseCase(dl, event).execute() - SuggestActorToCaseReceivedUseCase(dl, event).execute() - - stored = dl.get(activity.type_.value, activity.id_) - assert stored is not None - - def test_accept_suggest_actor_to_case_persists_acceptance( - self, monkeypatch, make_payload - ): - """AcceptSuggestActorToCaseReceivedUseCase persists the AcceptActorRecommendation.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Actor - - dl = SqliteDataLayer("sqlite:///:memory:") - - coordinator = as_Actor(id_="https://example.org/users/coordinator") - case = VulnerabilityCase( - id_="https://example.org/cases/case_sa3", - name="SA Case 3", - ) - recommendation = recommend_actor_activity( - coordinator, - target=case, - actor="https://example.org/users/finder", - ) - activity = accept_actor_recommendation_activity( - recommendation, - target=case, - actor="https://example.org/users/vendor", - ) - event = make_payload(activity) - - AcceptSuggestActorToCaseReceivedUseCase(dl, event).execute() - - stored = dl.get(activity.type_.value, activity.id_) - assert stored is not None - - def test_reject_suggest_actor_to_case_ledgers_rejection( - self, monkeypatch, caplog, make_payload - ): - """RejectSuggestActorToCaseReceivedUseCase logs rejection without state change.""" - from vultron.wire.as2.vocab.base.objects.actors import as_Actor - - coordinator = as_Actor(id_="https://example.org/users/coordinator") - case = VulnerabilityCase( - id_="https://example.org/cases/case_sa4", - name="SA Case 4", - ) - recommendation = recommend_actor_activity( - coordinator, - target=case, - actor="https://example.org/users/finder", - ) - activity = reject_actor_recommendation_activity( - recommendation, - target=case, - actor="https://example.org/users/vendor", - ) - event = make_payload(activity) - - with caplog.at_level(logging.INFO): - RejectSuggestActorToCaseReceivedUseCase( - MagicMock(), event - ).execute() - - assert any("rejected" in r.message.lower() for r in caplog.records) - - @pytest.fixture(autouse=True) - def clear_blackboard(self): - py_trees.blackboard.Blackboard.storage.clear() - yield - py_trees.blackboard.Blackboard.storage.clear() - - def _setup_dl_with_owner(self): - """Return a DataLayer seeded with a local Service actor and a case.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Service - - dl = SqliteDataLayer("sqlite:///:memory:") - local_actor_id = "https://example.org/actors/local-coordinator" - local_actor = as_Service(id_=local_actor_id) - case_id = "https://example.org/cases/suggest-test-case" - case = VulnerabilityCase( - id_=case_id, - name="SUGGEST-TEST", - attributed_to=local_actor_id, - ) - dl.create(local_actor) - dl.create(case) - return dl, local_actor_id, case_id - - def test_suggest_actor_emits_both_activities_when_owner( - self, make_payload - ): - """Owner emits Accept + Invite when receiving a recommendation.""" - dl, local_actor_id, case_id = self._setup_dl_with_owner() - recommender_id = "https://example.org/actors/finder" - invitee_id = "https://example.org/actors/vendor" - invitee = as_Actor(id_=invitee_id) - - recommendation = recommend_actor_activity( - invitee, - target=case_id, - actor=recommender_id, - to=[local_actor_id], - id_="https://example.org/activities/rec-001", - ) - event = make_payload(recommendation) - - SuggestActorToCaseReceivedUseCase( - dl, event, trigger_activity=TriggerActivityAdapter(dl) - ).execute() - - outbox = dl.outbox_list() - assert ( - len(outbox) == 2 - ), f"Expected 2 outbox entries (Accept + Invite), got {len(outbox)}" - - def test_suggest_actor_skips_when_not_case_owner(self, make_payload): - """Non-owner silently skips — no outbox entries emitted.""" - dl, local_actor_id, case_id = self._setup_dl_with_owner() - # Override case with a different owner - case = dl.read(case_id) - other_owner = "https://example.org/actors/other-owner" - case = cast(Any, case) - case = case.model_copy(update={"attributed_to": other_owner}) - dl.save(case) - - recommender_id = "https://example.org/actors/finder" - invitee_id = "https://example.org/actors/vendor" - invitee = as_Actor(id_=invitee_id) - - recommendation = recommend_actor_activity( - invitee, - target=case_id, - actor=recommender_id, - to=[local_actor_id], - id_="https://example.org/activities/rec-002", - ) - event = make_payload(recommendation) - - SuggestActorToCaseReceivedUseCase(dl, event).execute() - - outbox = dl.outbox_list() - assert len(outbox) == 0, ( - "Expected no outbox entries for non-owner, " f"got {len(outbox)}" - ) - - def test_suggest_actor_idempotent_when_invite_exists(self, make_payload): - """Second execute() adds no new outbox entries.""" - dl, local_actor_id, case_id = self._setup_dl_with_owner() - recommender_id = "https://example.org/actors/finder" - invitee_id = "https://example.org/actors/vendor" - invitee = as_Actor(id_=invitee_id) - - recommendation = recommend_actor_activity( - invitee, - target=case_id, - actor=recommender_id, - to=[local_actor_id], - id_="https://example.org/activities/rec-003", - ) - event = make_payload(recommendation) - - # First execution - SuggestActorToCaseReceivedUseCase( - dl, event, trigger_activity=TriggerActivityAdapter(dl) - ).execute() - outbox_after_first = len(dl.outbox_list()) - - # Second execution (should be a no-op) - py_trees.blackboard.Blackboard.storage.clear() - SuggestActorToCaseReceivedUseCase( - dl, event, trigger_activity=TriggerActivityAdapter(dl) - ).execute() - outbox_after_second = len(dl.outbox_list()) - - assert ( - outbox_after_first == 2 - ), f"Expected 2 entries after first run, got {outbox_after_first}" - assert outbox_after_second == outbox_after_first, ( - "Expected no new entries on second run (idempotency), " - f"got {outbox_after_second - outbox_after_first} extra" - ) - - -class TestOwnershipTransferUseCases: - """Tests for offer/accept/reject ownership transfer use cases.""" - - def test_offer_case_ownership_transfer_persists_offer( - self, monkeypatch, make_payload - ): - """OfferCaseOwnershipTransferReceivedUseCase persists the offer.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - - case = VulnerabilityCase( - id_="https://example.org/cases/case_ot1", - name="OT Case 1", - ) - activity = offer_case_ownership_transfer_activity( - case, - target="https://example.org/users/coordinator", - actor="https://example.org/users/vendor", - ) - event = make_payload(activity) - - OfferCaseOwnershipTransferReceivedUseCase(dl, event).execute() - - stored = dl.get(activity.type_.value, activity.id_) - assert stored is not None - - def test_accept_case_ownership_transfer_updates_attributed_to( - self, monkeypatch, make_payload - ): - """AcceptCaseOwnershipTransferReceivedUseCase updates case.attributed_to to new owner.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - case = VulnerabilityCase( - id_="https://example.org/cases/case_ot2", - name="OT Case 2", - attributed_to="https://example.org/users/vendor", - ) - dl.create(case) - - offer = offer_case_ownership_transfer_activity( - case, - target="https://example.org/users/coordinator", - actor="https://example.org/users/vendor", - id_="https://example.org/activities/offer_ot2", - ) - dl.create(offer) - - activity = accept_case_ownership_transfer_activity( - offer, - actor="https://example.org/users/coordinator", - ) - event = make_payload(activity) - - AcceptCaseOwnershipTransferReceivedUseCase(dl, event).execute() - - updated_record = dl.get(case.type_.value, case.id_) - assert updated_record is not None - data = cast(Any, updated_record).get("data_", updated_record) - assert ( - data.get("attributed_to") - == "https://example.org/users/coordinator" - ) - - def test_reject_case_ownership_transfer_logs_rejection( - self, monkeypatch, caplog, make_payload - ): - """RejectCaseOwnershipTransferReceivedUseCase logs rejection; ownership unchanged.""" - case = VulnerabilityCase( - id_="https://example.org/cases/case_ot3", - name="OT Case 3", - ) - offer = offer_case_ownership_transfer_activity( - case, - target="https://example.org/users/coordinator", - actor="https://example.org/users/vendor", - id_="https://example.org/activities/offer_ot3", - ) - activity = reject_case_ownership_transfer_activity( - offer, - actor="https://example.org/users/coordinator", - ) - event = make_payload(activity) - - with caplog.at_level(logging.INFO): - RejectCaseOwnershipTransferReceivedUseCase( - MagicMock(), event - ).execute() - - assert any("rejected" in r.message.lower() for r in caplog.records) - - -class TestCaseManagerRoleDelegationUseCases: - """Tests for offer/accept/reject CASE_MANAGER role delegation use cases. - - DEMOMA-08-002: CASE_MANAGER delegation is distinct from ownership transfer. - """ - - _VENDOR_URI = "https://example.org/actors/vendor" - _CASE_ACTOR_URI = "https://example.org/actors/case-actor" - _CASE_URI = "https://example.org/cases/urn:uuid:test-case-mgr" - _PARTICIPANT_URI = ( - "https://example.org/participants/urn:uuid:case-actor-participant" - ) - - def _make_offer(self): - from vultron.wire.as2.vocab.objects.case_participant import ( - CaseParticipant, - ) - from vultron.wire.as2.vocab.objects.vulnerability_case import ( - VulnerabilityCase, - ) - - case = VulnerabilityCase(id_=self._CASE_URI, name="CASE-MGR-TEST") - participant = CaseParticipant( - id_=self._PARTICIPANT_URI, - attributed_to=self._CASE_ACTOR_URI, - context=self._CASE_URI, - ) - return offer_case_manager_role_activity( - case, - target=participant, - actor=self._VENDOR_URI, - ) - - def test_offer_case_manager_role_persists_offer(self, make_payload): - """OfferCaseManagerRoleReceivedUseCase persists the offer activity.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - offer = self._make_offer() - event = make_payload(offer) - - OfferCaseManagerRoleReceivedUseCase(dl, event).execute() - - stored = dl.get(offer.type_.value, offer.id_) - assert stored is not None - - def test_offer_case_manager_role_idempotent(self, make_payload): - """Repeated execution of OfferCaseManagerRoleReceivedUseCase is a no-op.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - offer = self._make_offer() - event = make_payload(offer) - - OfferCaseManagerRoleReceivedUseCase(dl, event).execute() - OfferCaseManagerRoleReceivedUseCase(dl, event).execute() - - stored = dl.get(offer.type_.value, offer.id_) - assert stored is not None - - def test_accept_case_manager_role_persists_acceptance(self, make_payload): - """AcceptCaseManagerRoleReceivedUseCase persists the acceptance activity.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - offer = self._make_offer() - accept = accept_case_manager_role_activity( - offer, actor=self._CASE_ACTOR_URI - ) - event = make_payload(accept) - - AcceptCaseManagerRoleReceivedUseCase(dl, event).execute() - - stored = dl.get(accept.type_.value, accept.id_) - assert stored is not None - - def test_accept_case_manager_role_logs_acceptance( - self, caplog, make_payload - ): - """AcceptCaseManagerRoleReceivedUseCase logs acceptance without raising.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - offer = self._make_offer() - accept = accept_case_manager_role_activity( - offer, actor=self._CASE_ACTOR_URI - ) - event = make_payload(accept) - - with caplog.at_level(logging.INFO): - AcceptCaseManagerRoleReceivedUseCase(dl, event).execute() - - assert any("accepted" in r.message.lower() for r in caplog.records) - - def test_reject_case_manager_role_logs_warning(self, caplog, make_payload): - """RejectCaseManagerRoleReceivedUseCase logs a warning without raising.""" - offer = self._make_offer() - reject = reject_case_manager_role_activity( - offer, actor=self._CASE_ACTOR_URI - ) - event = make_payload(reject) - - with caplog.at_level(logging.WARNING): - RejectCaseManagerRoleReceivedUseCase(MagicMock(), event).execute() - - assert any("rejected" in r.message.lower() for r in caplog.records) - - def test_offer_case_manager_role_auto_accepts_when_trigger_given( - self, make_payload - ): - """OfferCaseManagerRoleReceivedUseCase auto-accepts when trigger_activity provided.""" - from unittest.mock import MagicMock, patch - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Organization - - dl = SqliteDataLayer("sqlite:///:memory:") - local_actor = as_Organization(id_=self._CASE_ACTOR_URI) - dl.create(local_actor) - - offer = self._make_offer() - event = make_payload(offer) - - trigger = MagicMock() - trigger.accept_case_manager_role.return_value = ( - "https://example.org/activities/accept-1" - ) - - with patch( - "vultron.core.use_cases.triggers._helpers.add_activity_to_outbox" - ): - OfferCaseManagerRoleReceivedUseCase( - dl, event, trigger_activity=trigger - ).execute() - - trigger.accept_case_manager_role.assert_called_once() - call_kwargs = trigger.accept_case_manager_role.call_args - assert call_kwargs.kwargs["offer_id"] == offer.id_ - assert call_kwargs.kwargs["vendor_id"] == self._VENDOR_URI - - def test_offer_case_manager_role_no_auto_accept_without_trigger( - self, make_payload - ): - """OfferCaseManagerRoleReceivedUseCase skips auto-accept when trigger_activity is None.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - offer = self._make_offer() - event = make_payload(offer) - - # No trigger_activity — should not raise - OfferCaseManagerRoleReceivedUseCase(dl, event).execute() - - stored = dl.get(offer.type_.value, offer.id_) - assert stored is not None - - def test_accept_case_manager_role_sends_bootstrap_when_reporter_found( - self, make_payload - ): - """AcceptCaseManagerRoleReceivedUseCase sends Create(Case) to Reporter on accept.""" - from unittest.mock import MagicMock, patch - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - from vultron.wire.as2.vocab.base.objects.actors import as_Organization - from vultron.core.models.vultron_types import VultronParticipant - from vultron.core.states.roles import CVDRole - from vultron.wire.as2.vocab.objects.vulnerability_case import ( - VulnerabilityCase, - ) - - dl = SqliteDataLayer("sqlite:///:memory:") - vendor = as_Organization(id_=self._VENDOR_URI) - reporter_id = "https://example.org/actors/reporter" - reporter_participant_id = f"{self._CASE_URI}/participants/reporter" - reporter_participant = VultronParticipant( - id_=reporter_participant_id, - attributed_to=reporter_id, - context=self._CASE_URI, - name="Reporter", - case_roles=[CVDRole.FINDER, CVDRole.REPORTER], - ) - case = VulnerabilityCase(id_=self._CASE_URI, name="BOOTSTRAP-TEST") - case.actor_participant_index[reporter_id] = reporter_participant_id - dl.create(vendor) - dl.create(reporter_participant) - dl.create(case) - - offer = self._make_offer() - accept = accept_case_manager_role_activity( - offer, actor=self._CASE_ACTOR_URI - ) - event = make_payload(accept) - - trigger = MagicMock() - trigger.create_case.return_value = ( - "https://example.org/activities/create-1", - {}, - ) - - with patch( - "vultron.core.use_cases.triggers._helpers.add_activity_to_outbox" - ): - AcceptCaseManagerRoleReceivedUseCase( - dl, event, trigger_activity=trigger - ).execute() - - trigger.create_case.assert_called_once() - call_kwargs = trigger.create_case.call_args - assert call_kwargs.kwargs.get("to") == [reporter_id] or ( - len(call_kwargs.args) >= 3 and reporter_id in call_kwargs.args - ) - - def test_accept_case_manager_role_no_bootstrap_without_trigger( - self, make_payload - ): - """AcceptCaseManagerRoleReceivedUseCase skips bootstrap when trigger_activity is None.""" - from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer - - dl = SqliteDataLayer("sqlite:///:memory:") - offer = self._make_offer() - accept = accept_case_manager_role_activity( - offer, actor=self._CASE_ACTOR_URI - ) - event = make_payload(accept) - - # No trigger_activity — should not raise - AcceptCaseManagerRoleReceivedUseCase(dl, event).execute() - - stored = dl.get(accept.type_.value, accept.id_) - assert stored is not None diff --git a/test/core/use_cases/received/test_case_bootstrap_trust.py b/test/core/use_cases/received/test_case_bootstrap_trust.py deleted file mode 100644 index e5c8163c1..000000000 --- a/test/core/use_cases/received/test_case_bootstrap_trust.py +++ /dev/null @@ -1,807 +0,0 @@ -# Copyright (c) 2026 Carnegie Mellon University and Contributors. -# - see Contributors.md for a full list of Contributors -# - see ContributionInstructions.md for information on how you can Contribute -# to this project -# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype -# is licensed under a MIT (SEI)-style license, please see LICENSE.md -# distributed with this Software or contact permission@sei.cmu.edu for full -# terms. -# Created, in part, with funding and support from the United States Government -# (see Acknowledgments file). This program may include and/or can make use of -# certain third party source code, object code, documentation and other files -# ("Third Party Software"). See LICENSE.md for more details. -# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the -# U.S. Patent and Trademark Office by Carnegie Mellon University -"""Tests for Case Bootstrap Trust (CBT-05). - -Covers: - CBT-05-001 Reporter accepts CaseActor Announce after bootstrap Create. - CBT-05-002 Bootstrap Create rejected when sender ≠ trusted_case_creator_id. - CBT-05-003 No-link path: Create without a matching ReportCaseLink is a - no-op (receiver is not the original reporter). - CBT-05-004 trusted_case_actor_id is extracted from the CASE_MANAGER participant - in the bootstrap snapshot and recorded in the ReportCaseLink. -""" - -from typing import cast - -import pytest - -from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer -from vultron.core.models.participant import VultronParticipant -from vultron.core.models.participant_status import ParticipantStatus -from vultron.wire.as2.vocab.objects.case_status import ( - ParticipantStatus as WireParticipantStatus, -) -from vultron.core.models.report import VultronReport -from vultron.core.models.report_case_link import VultronReportCaseLink -from vultron.core.states.cs import CS_vfd -from vultron.core.states.rm import RM -from vultron.core.use_cases.received.actor import ( - AnnounceVulnerabilityCaseReceivedUseCase, - _find_case_actor_id, -) -from vultron.core.use_cases.received.case import ( - CreateCaseReceivedUseCase, -) -from vultron.core.use_cases.received.status import ( - AddParticipantStatusToParticipantReceivedUseCase, -) -from vultron.wire.as2.factories import ( - add_status_to_participant_activity, - announce_vulnerability_case_activity, - create_case_activity, -) -from vultron.wire.as2.vocab.objects.case_participant import ( - CaseParticipant, -) -from vultron.core.states.roles import CVDRole -from vultron.wire.as2.vocab.objects.vulnerability_case import VulnerabilityCase - -# --------------------------------------------------------------------------- -# Shared constants -# --------------------------------------------------------------------------- - -_CREATOR_ID = "https://example.org/actors/creator" -_REPORTER_ID = "https://example.org/actors/reporter" -_IMPOSTER_ID = "https://example.org/actors/imposter" -_CASE_ACTOR_ID = "https://example.org/actors/case-actor" -_VENDOR_ID = "https://example.org/actors/vendor" -_CASE_ID = "https://example.org/cases/cbt-test-001" -_REPORT_ID = "https://example.org/reports/cbt-report-001" -_PARTICIPANT_ID = f"{_CASE_ID}/participants/case-actor" -_VENDOR_PARTICIPANT_ID = f"{_CASE_ID}/participants/vendor" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _case_with_case_actor_participant() -> tuple: - """Build a VulnerabilityCase whose participant list includes a CASE_MANAGER. - - Returns a tuple of (VulnerabilityCase, CaseActorParticipant). The - participant is embedded INLINE in the case snapshot (not just an ID), - matching what a real bootstrap ``Create(VulnerabilityCase)`` would carry. - """ - participant = CaseParticipant( - case_roles=[CVDRole.CASE_MANAGER], - id_=_PARTICIPANT_ID, - attributed_to=_CASE_ACTOR_ID, - context=_CASE_ID, - name="CaseActor", - ) - case = VulnerabilityCase( - id_=_CASE_ID, - name="CBT test case", - case_participants=[participant], - ) - return case, participant - - -def _build_link( - *, - trusted_case_creator_id: str | None = _CREATOR_ID, - case_id: str | None = None, - trusted_case_actor_id: str | None = None, -) -> VultronReportCaseLink: - return VultronReportCaseLink( - report_id=_REPORT_ID, - case_id=case_id, - trusted_case_creator_id=trusted_case_creator_id, - trusted_case_actor_id=trusted_case_actor_id, - ) - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture() -def dl(): - return SqliteDataLayer("sqlite:///:memory:") - - -@pytest.fixture() -def case_with_participant(): - """Return (VulnerabilityCase, VultronParticipant) with CASE_MANAGER role.""" - return _case_with_case_actor_participant() - - -@pytest.fixture() -def create_activity(case_with_participant): - case, _ = case_with_participant - return create_case_activity(case, actor=_CREATOR_ID) - - -@pytest.fixture() -def create_event(make_payload, create_activity): - return make_payload(create_activity) - - -# --------------------------------------------------------------------------- -# CBT-05-001: Bootstrap Create from trusted creator seeds the case replica -# --------------------------------------------------------------------------- - - -class TestBootstrapCreateAccepted: - """CBT-05-001 — reporter accepts bootstrap Create from trusted creator.""" - - def test_case_seeded_in_datalayer( - self, dl, create_event, case_with_participant - ): - """After a valid bootstrap, the case replica exists in the DataLayer.""" - link = _build_link() - dl.save(link) - - CreateCaseReceivedUseCase(dl, create_event).execute() - - stored = dl.read(_CASE_ID) - assert ( - stored is not None - ), "Case should be seeded after valid bootstrap" - - def test_report_case_link_updated_with_case_id( - self, dl, create_event, case_with_participant - ): - """Bootstrap updates ReportCaseLink.case_id (CBT-01-006).""" - link = _build_link() - dl.save(link) - - CreateCaseReceivedUseCase(dl, create_event).execute() - - updated = dl.read(link.id_) - assert isinstance(updated, VultronReportCaseLink) - assert updated.case_id == _CASE_ID - - def test_report_case_link_updated_with_trusted_case_actor_id( - self, dl, create_event, case_with_participant - ): - """Bootstrap extracts trusted_case_actor_id from CASE_MANAGER participant - (CBT-01-003, CBT-01-006).""" - link = _build_link() - dl.save(link) - - CreateCaseReceivedUseCase(dl, create_event).execute() - - updated = dl.read(link.id_) - assert isinstance(updated, VultronReportCaseLink) - assert updated.trusted_case_actor_id == _CASE_ACTOR_ID - - -# --------------------------------------------------------------------------- -# CBT-05-002: Bootstrap Create rejected when sender ≠ trusted_case_creator_id -# --------------------------------------------------------------------------- - - -class TestBootstrapCreateRejectedBadSender: - """CBT-05-002 — imposter sender is rejected; case is NOT seeded.""" - - @pytest.fixture() - def imposter_activity(self, case_with_participant): - case, _ = case_with_participant - return create_case_activity(case, actor=_IMPOSTER_ID) - - @pytest.fixture() - def imposter_event(self, make_payload, imposter_activity): - return make_payload(imposter_activity) - - def test_case_not_created(self, dl, imposter_event, case_with_participant): - """Case must NOT be seeded when sender ≠ trusted_case_creator_id.""" - link = _build_link(trusted_case_creator_id=_CREATOR_ID) - dl.save(link) - - CreateCaseReceivedUseCase(dl, imposter_event).execute() - - stored = dl.read(_CASE_ID) - assert ( - stored is None - ), "Case must not be seeded when sender is not trusted creator" - - def test_link_not_updated(self, dl, imposter_event, case_with_participant): - """ReportCaseLink must NOT be updated when bootstrap is rejected.""" - link = _build_link(trusted_case_creator_id=_CREATOR_ID) - dl.save(link) - - CreateCaseReceivedUseCase(dl, imposter_event).execute() - - updated = dl.read(link.id_) - assert isinstance(updated, VultronReportCaseLink) - assert updated.case_id is None - assert updated.trusted_case_actor_id is None - - -# --------------------------------------------------------------------------- -# CBT-05-003: No ReportCaseLink means receiver is not the reporter — no-op -# --------------------------------------------------------------------------- - - -class TestBootstrapCreateNoLink: - """CBT-05-003 — no matching ReportCaseLink → case is not a known reporter.""" - - def test_case_not_created_without_link( - self, dl, create_event, case_with_participant - ): - """Without a ReportCaseLink, CreateCaseReceivedUseCase is a no-op.""" - # Do NOT create a VultronReportCaseLink - - CreateCaseReceivedUseCase(dl, create_event).execute() - - stored = dl.read(_CASE_ID) - assert ( - stored is None - ), "Case should not be seeded when receiver has no matching ReportCaseLink" - - -# --------------------------------------------------------------------------- -# CBT-05-004: trusted_case_actor_id gates subsequent Announce acceptance -# --------------------------------------------------------------------------- - - -class TestAnnounceValidatedByTrustedCaseActorId: - """CBT-05-004 — only the bootstrapped CaseActor may push Announce updates.""" - - @pytest.fixture() - def case_obj(self): - return VulnerabilityCase(id_=_CASE_ID, name="CBT announce gate case") - - @pytest.fixture() - def announce_from_trusted(self, case_obj): - return announce_vulnerability_case_activity( - case_obj, actor=_CASE_ACTOR_ID - ) - - @pytest.fixture() - def announce_from_imposter(self, case_obj): - return announce_vulnerability_case_activity( - case_obj, actor=_IMPOSTER_ID - ) - - def test_trusted_actor_announce_accepted( - self, dl, make_payload, case_obj, announce_from_trusted - ): - """Announce from trusted_case_actor_id is accepted (CBT-05-004).""" - link = _build_link( - case_id=_CASE_ID, trusted_case_actor_id=_CASE_ACTOR_ID - ) - dl.save(link) - - event = make_payload(announce_from_trusted) - AnnounceVulnerabilityCaseReceivedUseCase(dl, event).execute() - - stored = dl.read(_CASE_ID) - assert ( - stored is not None - ), "Announce from trusted CaseActor must seed the case" - - def test_imposter_announce_rejected( - self, dl, make_payload, case_obj, announce_from_imposter - ): - """Announce from actor other than trusted_case_actor_id is rejected.""" - link = _build_link( - case_id=_CASE_ID, trusted_case_actor_id=_CASE_ACTOR_ID - ) - dl.save(link) - - event = make_payload(announce_from_imposter) - AnnounceVulnerabilityCaseReceivedUseCase(dl, event).execute() - - stored = dl.read(_CASE_ID) - assert stored is None, ( - "Announce from imposter must be rejected when trusted_case_actor_id " - "is set (CBT-05-004, PCR-03-001)" - ) - - def test_find_case_actor_id_prefers_link_over_service(self, dl, case_obj): - """_find_case_actor_id returns trusted_case_actor_id from link first.""" - link = _build_link( - case_id=_CASE_ID, trusted_case_actor_id=_CASE_ACTOR_ID - ) - dl.save(link) - - result = _find_case_actor_id(dl, _CASE_ID) - assert result == _CASE_ACTOR_ID - - -# --------------------------------------------------------------------------- -# CBT-05-005: Embedded participants are stored as separate DataLayer records -# --------------------------------------------------------------------------- - - -class TestBootstrapParticipantStorage: - """CBT-05-005 — bootstrap Create stores embedded participants in DataLayer. - - BT nodes ``CheckParticipantExists`` (#561) and ``AppendParticipantStatus`` - (#562) look up participants by UUID via ``datalayer.read(participant_id)``. - After a bootstrap ``Create(VulnerabilityCase)`` those participant records - MUST exist as independent DataLayer entries so the BT nodes can find them. - """ - - def test_embedded_participant_stored_after_bootstrap( - self, dl, create_event - ): - """Embedded CaseParticipant is stored as an independent DataLayer - record after a valid bootstrap (CBT-05-005, fixes #561 and #562). - """ - link = _build_link() - dl.save(link) - - CreateCaseReceivedUseCase(dl, create_event).execute() - - stored = dl.read(_PARTICIPANT_ID) - assert stored is not None, ( - "Embedded CaseActorParticipant must be stored as an independent " - "DataLayer record after bootstrap so BT nodes can look it up by ID" - ) - - def test_participant_stored_when_case_already_existed( - self, dl, create_event, case_with_participant - ): - """Participants are stored even when the case replica was already seeded - (e.g. by ``_store_nested_inbox_object`` before dispatch) — #561, #562. - """ - link = _build_link() - dl.save(link) - - # Pre-seed the case to trigger the idempotency guard in _handle_bootstrap - case, _ = case_with_participant - dl.create(case) - - CreateCaseReceivedUseCase(dl, create_event).execute() - - stored = dl.read(_PARTICIPANT_ID) - assert stored is not None, ( - "Participant must be stored even when the case was already seeded " - "before _handle_bootstrap ran" - ) - - def test_save_failure_propagates_from_store_embedded_participants( - self, dl, create_event, case_with_participant - ): - """A DataLayer failure in _store_embedded_participants propagates as an - exception rather than being silently swallowed (leaves replica - consistent — fail loudly instead of leaving participants missing). - """ - import unittest.mock as mock - - link = _build_link() - dl.save(link) - - # Patch dl.save to raise after the first successful call (link save) - original_save = dl.save - call_count = {"n": 0} - - def _patched_save(obj): - call_count["n"] += 1 - if call_count["n"] > 1: - raise RuntimeError("storage failure") - return original_save(obj) - - with mock.patch.object(dl, "save", side_effect=_patched_save): - with pytest.raises(RuntimeError, match="storage failure"): - CreateCaseReceivedUseCase(dl, create_event).execute() - - -# --------------------------------------------------------------------------- -# CBT-05-006: M4 AddParticipantStatusBT succeeds after bootstrap (#563) -# --------------------------------------------------------------------------- - - -class TestM4AddParticipantStatusAfterBootstrap: - """CBT-05-006 — AddParticipantStatusBT succeeds on finder's replica. - - Regression test for #563: M4 timeout in two-actor demo. - - Before the fix (PRs #561, #562): - - ``_store_embedded_participants`` did not persist each embedded participant - as an independent DataLayer record, so vendor's ``CaseParticipant`` could - not be found by its UUID after bootstrap. - - ``AppendParticipantStatusNode`` did ``dl.read(vendor_participant_id)`` - → ``None`` → ``FAILURE``, leaving finder's replica without the vendor's - ``vfd_state`` update. - - Finder's M4 poll returned 404 until timeout. - - After the fix: - - ``_store_embedded_participants`` stores all embedded participant objects - during bootstrap (CBT-05-005). - - ``AppendParticipantStatusNode`` finds the participant and appends the - status successfully. - - M4 completes without timeout. - """ - - @pytest.fixture() - def case_with_two_participants(self): - """VulnerabilityCase with a CASE_MANAGER participant and a vendor - participant, both embedded inline as in a real bootstrap snapshot.""" - case_actor_p = CaseParticipant( - case_roles=[CVDRole.CASE_MANAGER], - id_=_PARTICIPANT_ID, - attributed_to=_CASE_ACTOR_ID, - context=_CASE_ID, - ) - vendor_p = CaseParticipant( - id_=_VENDOR_PARTICIPANT_ID, - attributed_to=_VENDOR_ID, - context=_CASE_ID, - ) - case = VulnerabilityCase( - id_=_CASE_ID, - name="CBT-05-006 M4 regression case", - case_participants=[ - case_actor_p, - vendor_p, - ], - ) - case.actor_participant_index[_CASE_ACTOR_ID] = _PARTICIPANT_ID - case.actor_participant_index[_VENDOR_ID] = _VENDOR_PARTICIPANT_ID - return case, case_actor_p, vendor_p - - @pytest.fixture() - def bootstrap_m4_event(self, make_payload, case_with_two_participants): - case, _, _ = case_with_two_participants - activity = create_case_activity(case, actor=_CREATOR_ID) - return make_payload(activity) - - def test_add_participant_status_succeeds_after_bootstrap( - self, dl, make_payload, bootstrap_m4_event, case_with_two_participants - ): - """AddParticipantStatusBT appends VFd status on finder's replica. - - Full M4 path: bootstrap → verify participant stored → receive - Add(ParticipantStatus) from case-actor → assert VFd status on vendor - participant. Regression for #563. - """ - _vfd_status_id = f"{_VENDOR_PARTICIPANT_ID}/statuses/vfd-s1" - _, _, vendor_p = case_with_two_participants - - link = _build_link() - dl.save(link) - - # Step 1: bootstrap — _store_embedded_participants saves vendor's - # CaseParticipant as an independent DataLayer record (CBT-05-005). - CreateCaseReceivedUseCase(dl, bootstrap_m4_event).execute() - - # Step 2: confirm vendor participant is independently stored (core fix). - stored_p = dl.read(_VENDOR_PARTICIPANT_ID) - assert ( - stored_p is not None - ), "Vendor CaseParticipant must be stored during bootstrap (CBT-05-005)" - - # Step 3: case-actor broadcasts Add(ParticipantStatus, vendor_p) to - # finder. actor=_CASE_ACTOR_ID so VerifySenderIsParticipantNode passes - # (case.actor_participant_index contains _CASE_ACTOR_ID). - # The status is NOT pre-created — it arrives inline in the activity, so - # AppendParticipantStatusNode must resolve it from the fallback and - # persist it independently. - status = WireParticipantStatus( - id_=_vfd_status_id, - context=_CASE_ID, - vfd_state=CS_vfd.VFd, - ) - activity = add_status_to_participant_activity( - status, - target=vendor_p, - actor=_CASE_ACTOR_ID, - ) - event = make_payload(activity) - - AddParticipantStatusToParticipantReceivedUseCase(dl, event).execute() - - # Step 4: vendor participant now has the VFd status — M4 can observe it. - updated_p = dl.read(_VENDOR_PARTICIPANT_ID) - assert ( - updated_p is not None - ), "Vendor participant must still exist after AddParticipantStatus" - updated_p = cast(CaseParticipant, updated_p) - status_ids = [ - getattr(s, "id_", s) for s in updated_p.participant_statuses - ] - assert _vfd_status_id in status_ids, ( - "Vendor participant must have the VFd status after M4 broadcast " - "(regression for #563)" - ) - # The status object must also exist as an independent DataLayer record. - stored_status = dl.read(_vfd_status_id) - assert stored_status is not None, ( - "ParticipantStatus must be persisted as an independent DataLayer" - " record by AddParticipantStatusToParticipantReceivedUseCase" - ) - - -# --------------------------------------------------------------------------- -# CBT-05-006: Reporter participant seeded with RM.ACCEPTED on bootstrap (#589) -# --------------------------------------------------------------------------- - - -class TestBootstrapCreateReporterParticipant: - """Bootstrap Create must seed the reporter's participant at RM.ACCEPTED. - - When Create(VulnerabilityCase) arrives with participant IDs as bare - strings, _store_embedded_participants skips them. The reporter's own - participant record would then be absent from their DataLayer, causing - SvcAddParticipantStatusUseCase._resolve_current_participant_state to - fall back to RM.START — the root cause of #589. - - The fix: _handle_bootstrap infers from the reporter's submitted report - that they have already RM.ACCEPTED and creates the participant record - with that state if it is not already present. - """ - - _VENDOR_ID = "https://vendor.example.org/actors/vendor-589" - _FINDER_ID = "https://finder.example.org/actors/finder-589" - _CASE_ID = "https://example.org/cases/case-589" - _REPORT_ID = "https://example.org/reports/report-589" - _FINDER_PARTICIPANT_ID = f"{_CASE_ID}/participants/finder-589" - _VENDOR_PARTICIPANT_ID = f"{_CASE_ID}/participants/vendor-589" - - @pytest.fixture() - def dl(self): - return SqliteDataLayer("sqlite:///:memory:") - - @pytest.fixture() - def seeded_dl(self, dl): - """DataLayer with the Finder's pre-existing report and case link.""" - report = VultronReport( - id_=self._REPORT_ID, - attributed_to=self._FINDER_ID, - ) - dl.create(report) - - link = VultronReportCaseLink( - report_id=self._REPORT_ID, - trusted_case_creator_id=self._VENDOR_ID, - ) - dl.save(link) - return dl - - @pytest.fixture() - def case_with_string_participants(self): - """VulnerabilityCase whose participants are bare string IDs. - - This is the common wire representation when the sender serialises the - domain VultronCase (which stores participant IDs, not objects). - The fixture also includes a CASE_MANAGER participant inline so that - the bootstrap trust path extracts a trusted_case_actor_id. - """ - case_actor_participant = CaseParticipant( - case_roles=[CVDRole.CASE_MANAGER], - id_=self._VENDOR_PARTICIPANT_ID, - attributed_to=self._VENDOR_ID, - context=self._CASE_ID, - ) - case = VulnerabilityCase( - id_=self._CASE_ID, - name="Bug #589 regression case", - case_participants=[ - case_actor_participant, # inline so CBT-01-003 can extract it - self._FINDER_PARTICIPANT_ID, # bare string — typical case - ], - ) - case.actor_participant_index[self._VENDOR_ID] = ( - self._VENDOR_PARTICIPANT_ID - ) - case.actor_participant_index[self._FINDER_ID] = ( - self._FINDER_PARTICIPANT_ID - ) - return case - - @pytest.fixture() - def create_event(self, make_payload, case_with_string_participants): - activity = create_case_activity( - case_with_string_participants, actor=self._VENDOR_ID - ) - return make_payload(activity) - - def test_reporter_participant_created_after_bootstrap( - self, seeded_dl, create_event - ): - """Reporter participant must exist in DataLayer after bootstrap (#589). - - When the bootstrap Create(VulnerabilityCase) carries the reporter's - participant as a bare string ID, the DataLayer must still produce a - standalone participant record for the reporter so that subsequent - SvcAddParticipantStatusUseCase calls can read it. - """ - CreateCaseReceivedUseCase(seeded_dl, create_event).execute() - - stored = seeded_dl.read(self._FINDER_PARTICIPANT_ID) - assert stored is not None, ( - "Reporter participant must be created in the DataLayer after " - "bootstrap even when case_participants contains a bare string ID " - "(regression #589)" - ) - - def test_reporter_participant_has_rm_accepted_after_bootstrap( - self, seeded_dl, create_event - ): - """Reporter participant must start at RM.ACCEPTED after bootstrap. - - The reporter submitted a report — by definition they have accepted the - vulnerability from their own RM perspective. The seeded participant - must reflect this so that _resolve_current_participant_state returns - RM.ACCEPTED rather than RM.START (#589). - """ - CreateCaseReceivedUseCase(seeded_dl, create_event).execute() - - stored = seeded_dl.read(self._FINDER_PARTICIPANT_ID) - assert stored is not None - statuses = getattr(stored, "participant_statuses", []) - assert statuses, ( - "Reporter participant must have at least one ParticipantStatus " - "after bootstrap (#589)" - ) - latest = statuses[-1] - rm_state = getattr(latest, "rm_state", None) - assert rm_state == RM.ACCEPTED, ( - f"Reporter participant must have rm_state=RM.ACCEPTED after " - f"bootstrap; got {rm_state!r} (#589)" - ) - - -# --------------------------------------------------------------------------- -# CBT-05-007: Reporter participant upgraded from RM.START to RM.ACCEPTED (#624) -# --------------------------------------------------------------------------- - - -class TestBootstrapReporterUpgradesFromStart: - """Bootstrap Create upgrades an existing RM.START participant to RM.ACCEPTED. - - When ``_store_embedded_participants`` stores the wire-layer snapshot, it may - seed the reporter's participant with ``rm_state=RM.START`` (the wire default). - ``_ensure_reporter_participant`` must detect this and upgrade the participant - to ``RM.ACCEPTED``. See issue #624. - """ - - _VENDOR_ID = "https://vendor.example.org/actors/vendor-624" - _FINDER_ID = "https://finder.example.org/actors/finder-624" - _CASE_ID = "https://example.org/cases/case-624" - _REPORT_ID = "https://example.org/reports/report-624" - _FINDER_PARTICIPANT_ID = f"{_CASE_ID}/participants/finder-624" - _VENDOR_PARTICIPANT_ID = f"{_CASE_ID}/participants/vendor-624" - - @pytest.fixture() - def dl(self): - return SqliteDataLayer("sqlite:///:memory:") - - @pytest.fixture() - def base_dl(self, dl): - """DataLayer with report and link pre-seeded.""" - report = VultronReport( - id_=self._REPORT_ID, - attributed_to=self._FINDER_ID, - ) - dl.create(report) - - link = VultronReportCaseLink( - report_id=self._REPORT_ID, - trusted_case_creator_id=self._VENDOR_ID, - ) - dl.save(link) - return dl - - @pytest.fixture() - def case_with_string_participants(self): - case_actor_participant = CaseParticipant( - case_roles=[CVDRole.CASE_MANAGER], - id_=self._VENDOR_PARTICIPANT_ID, - attributed_to=self._VENDOR_ID, - context=self._CASE_ID, - ) - case = VulnerabilityCase( - id_=self._CASE_ID, - name="Bug #624 regression case", - case_participants=[ - case_actor_participant, - self._FINDER_PARTICIPANT_ID, # bare string - ], - ) - case.actor_participant_index[self._VENDOR_ID] = ( - self._VENDOR_PARTICIPANT_ID - ) - case.actor_participant_index[self._FINDER_ID] = ( - self._FINDER_PARTICIPANT_ID - ) - return case - - def _create_event(self, make_payload, case): - activity = create_case_activity(case, actor=self._VENDOR_ID) - return make_payload(activity) - - def _pre_seed_participant(self, dl, rm_state: RM) -> VultronParticipant: - """Store a finder participant at the given rm_state before bootstrap.""" - status = ParticipantStatus( - rm_state=rm_state, - context=self._CASE_ID, - attributed_to=self._FINDER_ID, - ) - participant = VultronParticipant( - id_=self._FINDER_PARTICIPANT_ID, - attributed_to=self._FINDER_ID, - context=self._CASE_ID, - participant_statuses=[status], - ) - dl.create(participant) - return participant - - def test_reporter_participant_upgraded_from_start_to_accepted( - self, base_dl, make_payload, case_with_string_participants - ): - """Reporter participant at RM.START must be upgraded to RM.ACCEPTED (#624). - - Pre-condition: reporter's participant is already in the DataLayer at - RM.START (seeded by _store_embedded_participants or a prior bootstrap). - Post-condition: after CreateCaseReceivedUseCase, the participant's latest - rm_state is RM.ACCEPTED. - """ - self._pre_seed_participant(base_dl, RM.START) - event = self._create_event(make_payload, case_with_string_participants) - - CreateCaseReceivedUseCase(base_dl, event).execute() - - stored = base_dl.read(self._FINDER_PARTICIPANT_ID) - assert stored is not None - statuses = getattr(stored, "participant_statuses", []) - assert statuses, "Reporter participant must have at least one status" - latest_rm = statuses[-1].rm_state - assert latest_rm == RM.ACCEPTED, ( - f"Reporter participant must be upgraded to RM.ACCEPTED from " - f"RM.START; got {latest_rm!r} (#624)" - ) - - def test_reporter_participant_noop_if_already_accepted( - self, base_dl, make_payload, case_with_string_participants - ): - """Reporter participant already at RM.ACCEPTED must not be modified (#624).""" - self._pre_seed_participant(base_dl, RM.ACCEPTED) - event = self._create_event(make_payload, case_with_string_participants) - - CreateCaseReceivedUseCase(base_dl, event).execute() - - stored = base_dl.read(self._FINDER_PARTICIPANT_ID) - assert stored is not None - statuses = getattr(stored, "participant_statuses", []) - assert len(statuses) == 1, ( - "Reporter participant already at RM.ACCEPTED must not gain extra " - f"statuses; got {len(statuses)} (#624)" - ) - assert statuses[0].rm_state == RM.ACCEPTED - - def test_reporter_participant_noop_if_already_closed( - self, base_dl, make_payload, case_with_string_participants - ): - """Reporter participant already at RM.CLOSED must not be downgraded (#624).""" - self._pre_seed_participant(base_dl, RM.CLOSED) - event = self._create_event(make_payload, case_with_string_participants) - - CreateCaseReceivedUseCase(base_dl, event).execute() - - stored = base_dl.read(self._FINDER_PARTICIPANT_ID) - assert stored is not None - statuses = getattr(stored, "participant_statuses", []) - assert len(statuses) == 1, ( - "Reporter participant at RM.CLOSED must not gain extra statuses " - f"(it is already beyond ACCEPTED); got {len(statuses)} (#624)" - ) - assert statuses[0].rm_state == RM.CLOSED diff --git a/vultron/core/behaviors/case/accept_invite_tree.py b/vultron/core/behaviors/case/accept_invite_tree.py new file mode 100644 index 000000000..86c31107a --- /dev/null +++ b/vultron/core/behaviors/case/accept_invite_tree.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Carnegie Mellon University and Contributors. +# - see Contributors.md for a full list of Contributors +# - see ContributionInstructions.md for information on how you can Contribute to this project +# Vultron Multiparty Coordinated Vulnerability Disclosure Protocol Prototype is +# licensed under a MIT (SEI)-style license, please see LICENSE.md distributed +# with this Software or contact permission@sei.cmu.edu for full terms. +# Created, in part, with funding and support from the United States Government +# (see Acknowledgments file). This program may include and/or can make use of +# certain third party source code, object code, documentation and other files +# ("Third Party Software"). See LICENSE.md for more details. +# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the +# U.S. Patent and Trademark Office by Carnegie Mellon University + +"""BT nodes and factory for AcceptInviteActorToCase received use-case. + +When the CaseActor receives ``Accept(Invite(actor, case))``, it runs this tree +as itself (the CaseActor) to record the invitee's participation in its own +DataLayer — without spoofing the invitee's identity (PCR-08-010). + +Tree structure:: + + AcceptInviteActorToCaseBT (Sequence, memory=False) + ├── CheckInviteeNotAlreadyParticipantNode — idempotency guard + ├── CreateInviteeParticipantAtAcceptedNode — build participant at RM.ACCEPTED + ├── MaybeSignEmbargoConsentNode — sign when embargo is EM.ACTIVE + ├── PersistInviteeParticipantNode — dl.create, attach, record events + └── EmitAnnounceCaseToInviteeNode — queue Announce(VulnerabilityCase) + +Specs: PCR-08-010 (identity constraint), CM-10-001/CM-10-003 (embargo +consent), MV-10-003/MV-10-005 (announce after consent resolved). +BT-06-001, BT-15-001. +""" + +import logging +from typing import cast + +import py_trees +from py_trees.common import Status + +from vultron.core.behaviors.helpers import DataLayerAction, DataLayerCondition +from vultron.core.models.protocols import is_case_model +from vultron.core.models.vultron_types import VultronParticipant +from vultron.core.ports.case_persistence import CaseOutboxPersistence +from vultron.core.states.em import EM +from vultron.core.states.participant_embargo_consent import ( + PEC, + PEC_Trigger, + apply_pec_trigger, +) +from vultron.core.states.rm import RM +from vultron.core.use_cases._helpers import _as_id + +logger = logging.getLogger(__name__) + + +class CheckInviteeNotAlreadyParticipantNode(DataLayerCondition): + """Idempotency guard: FAILURE when invitee is already a participant. + + Returns SUCCESS (allow proceeding) when the invitee is NOT yet + registered in ``case.actor_participant_index``. + Returns FAILURE (abort tree) when the invitee is already a participant. + """ + + def __init__( + self, case_id: str, invitee_id: str, name: str | None = None + ) -> None: + super().__init__(name=name or self.__class__.__name__) + self.case_id = case_id + self.invitee_id = invitee_id + + def setup(self, **kwargs) -> None: + super().setup(**kwargs) + self.blackboard.register_key( + key="invitee_case", + access=py_trees.common.Access.WRITE, + ) + + def update(self) -> Status: + if self.datalayer is None: + self.logger.error("%s: DataLayer not available", self.name) + return Status.FAILURE + + case = self.datalayer.read(self.case_id) + if not is_case_model(case): + self.logger.warning( + "%s: case '%s' not found", + self.name, + self.case_id, + ) + return Status.FAILURE + + existing_ids = [_as_id(p) for p in case.case_participants] + if ( + self.invitee_id in case.actor_participant_index + or self.invitee_id in existing_ids + ): + self.logger.info( + "%s: actor '%s' already participant in case '%s'" + " — skipping (idempotent)", + self.name, + self.invitee_id, + self.case_id, + ) + return Status.FAILURE + + # Cache the case object for downstream nodes + self.blackboard.invitee_case = case + return Status.SUCCESS + + +class CreateInviteeParticipantAtAcceptedNode(DataLayerAction): + """Build a ``VultronParticipant`` for the invitee at RM.ACCEPTED. + + Per PCR-08-010, ``Accept(Invite)`` IS the engage signal. The + participant's full RECEIVED→VALID→ACCEPTED arc is implicit; the + CaseActor records RM.ACCEPTED directly in its own DataLayer rather + than running an engage-case BT as the invitee. + + Writes ``new_invite_participant`` to the blackboard. + """ + + def __init__( + self, case_id: str, invitee_id: str, name: str | None = None + ) -> None: + super().__init__(name=name or self.__class__.__name__) + self.case_id = case_id + self.invitee_id = invitee_id + + def setup(self, **kwargs) -> None: + super().setup(**kwargs) + self.blackboard.register_key( + key="invitee_case", + access=py_trees.common.Access.READ, + ) + self.blackboard.register_key( + key="new_invite_participant", + access=py_trees.common.Access.WRITE, + ) + + def update(self) -> Status: + case = self.blackboard.get("invitee_case") + if not is_case_model(case): + self.logger.error( + "%s: invitee_case not found in blackboard", self.name + ) + return Status.FAILURE + + participant = VultronParticipant( + id_=f"{self.case_id}/participants/{self.invitee_id.split('/')[-1]}", + attributed_to=self.invitee_id, + context=self.case_id, + ) + # PCR-08-010: Accept(Invite) IS the engage signal; record all three + # RM transitions on behalf of the invitee in the CaseActor's DataLayer. + participant.append_rm_state( + RM.RECEIVED, actor=self.invitee_id, context=self.case_id + ) + participant.append_rm_state( + RM.VALID, actor=self.invitee_id, context=self.case_id + ) + participant.append_rm_state( + RM.ACCEPTED, actor=self.invitee_id, context=self.case_id + ) + self.blackboard.new_invite_participant = participant + self.logger.info( + "%s: created participant object for invitee '%s' at RM.ACCEPTED" + " (PCR-08-010)", + self.name, + self.invitee_id, + ) + return Status.SUCCESS + + +class MaybeSignEmbargoConsentNode(py_trees.composites.Selector): + """Auto-sign embargo consent when the case embargo is fully EM.ACTIVE. + + Selector logic: + - ``_TrySignEmbargoConsent`` (Sequence): sign if embargo is EM.ACTIVE. + - ``_AlwaysSucceed`` (leaf): fall-through so the parent Sequence can + continue when there is no active embargo or it is in REVISE state. + + Only auto-signs when ``em_state == EM.ACTIVE`` — REVISE means terms + are being renegotiated and the new participant should not be committed. + """ + + def __init__( + self, case_id: str, invitee_id: str, name: str | None = None + ) -> None: + super().__init__( + name=name or self.__class__.__name__, + memory=False, + children=[ + _TrySignEmbargoConsentSequence( + case_id=case_id, invitee_id=invitee_id + ), + _AlwaysSucceedNode(), + ], + ) + + +class _CheckEmbargoActiveStateNode(DataLayerAction): + """Return SUCCESS iff the case has an active embargo in EM.ACTIVE state.""" + + def __init__(self, case_id: str, name: str | None = None) -> None: + super().__init__(name=name or self.__class__.__name__) + self.case_id = case_id + + def setup(self, **kwargs) -> None: + super().setup(**kwargs) + self.blackboard.register_key( + key="invitee_case", + access=py_trees.common.Access.READ, + ) + self.blackboard.register_key( + key="active_embargo_id", + access=py_trees.common.Access.WRITE, + ) + + def update(self) -> Status: + case = self.blackboard.get("invitee_case") + if not is_case_model(case): + self.logger.error("%s: invitee_case not available", self.name) + # Initialize key so downstream nodes can safely read it. + self.blackboard.active_embargo_id = None + return Status.FAILURE + + active_embargo_id = _as_id(case.active_embargo) + em_state = case.current_status.em_state + if active_embargo_id and em_state == EM.ACTIVE: + self.blackboard.active_embargo_id = active_embargo_id + return Status.SUCCESS + # Always write the key so PersistInviteeParticipantNode can read it + # even when there is no active embargo (py_trees raises KeyError for + # unwritten READ-registered keys — see AGENTS.md pitfalls). + self.blackboard.active_embargo_id = None + return Status.FAILURE + + +class _SignEmbargoConsentLeafNode(DataLayerAction): + """Sign embargo consent on the participant and record the event.""" + + def __init__(self, invitee_id: str, name: str | None = None) -> None: + super().__init__(name=name or self.__class__.__name__) + self.invitee_id = invitee_id + + def setup(self, **kwargs) -> None: + super().setup(**kwargs) + self.blackboard.register_key( + key="new_invite_participant", + access=py_trees.common.Access.READ, + ) + self.blackboard.register_key( + key="active_embargo_id", + access=py_trees.common.Access.READ, + ) + + def update(self) -> Status: + participant = self.blackboard.get("new_invite_participant") + active_embargo_id = self.blackboard.get("active_embargo_id") + if not isinstance(participant, VultronParticipant) or not isinstance( + active_embargo_id, str + ): + self.logger.error( + "%s: participant or active_embargo_id missing", self.name + ) + return Status.FAILURE + + participant.accepted_embargo_ids.append(active_embargo_id) + participant.embargo_consent_state = apply_pec_trigger( + PEC.NO_EMBARGO, PEC_Trigger.ACCEPT + ) + self.logger.info( + "%s: signed embargo consent for invitee '%s' (EM.ACTIVE," + " CM-10-001)", + self.name, + self.invitee_id, + ) + return Status.SUCCESS + + +class _TrySignEmbargoConsentSequence(py_trees.composites.Sequence): + def __init__( + self, case_id: str, invitee_id: str, name: str | None = None + ) -> None: + super().__init__( + name=name or "_TrySignEmbargoConsent", + memory=False, + children=[ + _CheckEmbargoActiveStateNode(case_id=case_id), + _SignEmbargoConsentLeafNode(invitee_id=invitee_id), + ], + ) + + +class _AlwaysSucceedNode(py_trees.behaviour.Behaviour): + """Fallback leaf that always returns SUCCESS. + + Used in Selector subtrees as a no-op alternative to optional steps. + """ + + def __init__(self, name: str = "_AlwaysSucceed") -> None: + super().__init__(name=name) + + def update(self) -> Status: + return Status.SUCCESS + + +class PersistInviteeParticipantNode(DataLayerAction): + """Persist the participant, attach to case, record events, save case.""" + + def __init__( + self, case_id: str, invitee_id: str, name: str | None = None + ) -> None: + super().__init__(name=name or self.__class__.__name__) + self.case_id = case_id + self.invitee_id = invitee_id + + def setup(self, **kwargs) -> None: + super().setup(**kwargs) + self.blackboard.register_key( + key="new_invite_participant", + access=py_trees.common.Access.READ, + ) + self.blackboard.register_key( + key="invitee_case", + access=py_trees.common.Access.READ, + ) + self.blackboard.register_key( + key="active_embargo_id", + access=py_trees.common.Access.READ, + ) + + def update(self) -> Status: + if self.datalayer is None: + self.logger.error("%s: DataLayer not available", self.name) + return Status.FAILURE + + participant = self.blackboard.get("new_invite_participant") + case = self.blackboard.get("invitee_case") + if not isinstance( + participant, VultronParticipant + ) or not is_case_model(case): + self.logger.error( + "%s: new_invite_participant or invitee_case missing", + self.name, + ) + return Status.FAILURE + + self.datalayer.create(participant) + case.add_participant(participant) + case.record_event(self.invitee_id, "participant_joined") + + active_embargo_id = self.blackboard.get("active_embargo_id") + if isinstance(active_embargo_id, str): + case.record_event(active_embargo_id, "embargo_accepted") + + self.datalayer.save(case) + self.logger.info( + "%s: participant '%s' persisted and attached to case '%s'" + " (RM.ACCEPTED, PCR-08-010)", + self.name, + participant.id_, + self.case_id, + ) + return Status.SUCCESS + + +class EmitAnnounceCaseToInviteeNode(DataLayerAction): + """Queue Announce(VulnerabilityCase) to the invitee from the CaseActor. + + Per MV-10-003/MV-10-005, the CaseActor sends the full case object after + embargo consent has been resolved (auto-signed above when EM.ACTIVE). + FAILURE is returned if the trigger-activity factory is unavailable — the + caller should log a warning but is not required to abort. + """ + + def __init__( + self, case_id: str, invitee_id: str, name: str | None = None + ) -> None: + super().__init__(name=name or self.__class__.__name__) + self.case_id = case_id + self.invitee_id = invitee_id + + def update(self) -> Status: + if self.datalayer is None or self.actor_id is None: + self.logger.error( + "%s: DataLayer or actor_id not available", self.name + ) + return Status.FAILURE + + factory = self.trigger_activity_factory + if factory is None: + self.logger.warning( + "%s: trigger_activity_factory not available;" + " cannot emit AnnounceVulnerabilityCase for case '%s'" + " (MV-10-003)", + self.name, + self.case_id, + ) + return Status.FAILURE + + try: + activity_id = factory.announce_vulnerability_case( + case_id=self.case_id, + actor=self.actor_id, + context_id=self.case_id, + to=[self.invitee_id], + ) + cast(CaseOutboxPersistence, self.datalayer).record_outbox_item( + self.actor_id, activity_id + ) + self.logger.info( + "%s: queued AnnounceVulnerabilityCase '%s' to '%s'" + " for case '%s' (MV-10-003)", + self.name, + activity_id, + self.invitee_id, + self.case_id, + ) + return Status.SUCCESS + except Exception as exc: + self.logger.error( + "%s: failed to emit AnnounceVulnerabilityCase for case '%s'" + " to '%s': %s", + self.name, + self.case_id, + self.invitee_id, + exc, + ) + return Status.FAILURE + + +def create_accept_invite_actor_to_case_tree( + case_id: str, + invitee_id: str, +) -> py_trees.composites.Sequence: + """Return the BT for handling an inbound ``Accept(Invite(actor, case))``. + + The CaseActor runs this tree **as itself** (not as the invitee) to record + the invitee's participation in its own DataLayer (PCR-08-010). + + The returned Sequence:: + + AcceptInviteActorToCaseBT (memory=False) + ├── CheckInviteeNotAlreadyParticipantNode — idempotency guard + ├── CreateInviteeParticipantAtAcceptedNode — build participant at ACCEPTED + ├── MaybeSignEmbargoConsentNode — sign when EM.ACTIVE + ├── PersistInviteeParticipantNode — persist, attach, record events + └── EmitAnnounceCaseToInviteeNode — queue Announce to invitee + + Args: + case_id: ID of the VulnerabilityCase the invitee accepted. + invitee_id: Actor ID of the actor who accepted the invitation. + + Returns: + Configured ``Sequence`` ready for execution via + :class:`~vultron.core.behaviors.bridge.BTBridge`. + """ + return py_trees.composites.Sequence( + name="AcceptInviteActorToCaseBT", + memory=False, + children=[ + CheckInviteeNotAlreadyParticipantNode( + case_id=case_id, invitee_id=invitee_id + ), + CreateInviteeParticipantAtAcceptedNode( + case_id=case_id, invitee_id=invitee_id + ), + MaybeSignEmbargoConsentNode( + case_id=case_id, invitee_id=invitee_id + ), + PersistInviteeParticipantNode( + case_id=case_id, invitee_id=invitee_id + ), + EmitAnnounceCaseToInviteeNode( + case_id=case_id, invitee_id=invitee_id + ), + ], + ) + + +__all__ = [ + "CheckInviteeNotAlreadyParticipantNode", + "CreateInviteeParticipantAtAcceptedNode", + "MaybeSignEmbargoConsentNode", + "PersistInviteeParticipantNode", + "EmitAnnounceCaseToInviteeNode", + "create_accept_invite_actor_to_case_tree", +] diff --git a/vultron/core/behaviors/case/nodes/participant/__init__.py b/vultron/core/behaviors/case/nodes/participant/__init__.py index 2a9b52c15..729ca356e 100644 --- a/vultron/core/behaviors/case/nodes/participant/__init__.py +++ b/vultron/core/behaviors/case/nodes/participant/__init__.py @@ -51,6 +51,7 @@ CaseHasActiveEmbargoNode, CaseHasNoActiveEmbargoNode, CreateParticipantNode, + EnsureReporterParticipantAtAcceptedNode, QueueAddParticipantNotificationNode, RecordParticipantAddedEventNode, ResolveParticipantAcceptedStatusNode, @@ -90,6 +91,7 @@ # composite subtrees — lazy via __getattr__ "CreateCaseParticipantNode", "CreateParticipantStatusNode", + "EnsureReporterParticipantAtAcceptedNode", ] # TYPE_CHECKING stubs so mypy resolves composite names to their actual types. diff --git a/vultron/core/behaviors/case/nodes/participant/participant_add.py b/vultron/core/behaviors/case/nodes/participant/participant_add.py index b523a5a55..6a6f25ebb 100644 --- a/vultron/core/behaviors/case/nodes/participant/participant_add.py +++ b/vultron/core/behaviors/case/nodes/participant/participant_add.py @@ -34,7 +34,8 @@ ) from vultron.core.behaviors.helpers import DataLayerAction from vultron.core.models.participant_status import ParticipantStatus -from vultron.core.models.protocols import is_case_model +from vultron.core.models.protocols import CaseModel, is_case_model +from vultron.core.models.report_case_link import VultronReportCaseLink from vultron.core.models.vultron_types import VultronParticipant from vultron.core.states.participant_embargo_consent import PEC from vultron.core.states.roles import CVDRole @@ -388,3 +389,49 @@ def update(self) -> Status: ): return Status.FAILURE return Status.SUCCESS + + +class EnsureReporterParticipantAtAcceptedNode(DataLayerAction): + """BT leaf node that seeds or upgrades the reporter participant to RM.ACCEPTED. + + Called from ``CreateCaseReceivedUseCase._handle_bootstrap`` via BTBridge + after a ``Create(VulnerabilityCase)`` bootstrap. When participants arrive + as bare string IDs, ``_store_embedded_participants`` cannot create records + for them. This node ensures the reporter's participant record exists at + ``RM.ACCEPTED`` — inferred from the fact that they submitted a report (#589, + #624). + + Args: + link: The ``VultronReportCaseLink`` associating the report to the case. + case_obj: The bootstrapped ``VulnerabilityCase`` domain object. + case_id: ID of the case (for log context). + """ + + def __init__( + self, + link: VultronReportCaseLink, + case_obj: CaseModel, + case_id: str, + name: str | None = None, + ) -> None: + super().__init__(name=name or self.__class__.__name__) + self.link = link + self.case_obj = case_obj + self.case_id = case_id + + def update(self) -> Status: + if self.datalayer is None: + self.logger.error("%s: DataLayer not available", self.name) + return Status.FAILURE + + from vultron.core.use_cases.received.case._helpers import ( + _ensure_reporter_participant, + ) + + _ensure_reporter_participant( + self.datalayer, + self.link, + self.case_obj, + self.case_id, + ) + return Status.SUCCESS diff --git a/vultron/core/use_cases/received/actor.py b/vultron/core/use_cases/received/actor.py deleted file mode 100644 index bf6907354..000000000 --- a/vultron/core/use_cases/received/actor.py +++ /dev/null @@ -1,778 +0,0 @@ -"""Use cases for case actor/participant invitation and suggestion activities.""" - -import logging -from typing import TYPE_CHECKING - -from vultron.core.models.events.actor import ( - AcceptCaseManagerRoleReceivedEvent, - AcceptCaseOwnershipTransferReceivedEvent, - AcceptInviteActorToCaseReceivedEvent, - AcceptSuggestActorToCaseReceivedEvent, - AnnounceVulnerabilityCaseReceivedEvent, - InviteActorToCaseReceivedEvent, - OfferCaseManagerRoleReceivedEvent, - OfferCaseOwnershipTransferReceivedEvent, - RejectCaseManagerRoleReceivedEvent, - RejectCaseOwnershipTransferReceivedEvent, - RejectInviteActorToCaseReceivedEvent, - RejectSuggestActorToCaseReceivedEvent, - SuggestActorToCaseReceivedEvent, -) -from vultron.core.models.report_case_link import VultronReportCaseLink -from vultron.core.models.vultron_types import VultronParticipant -from vultron.core.models.protocols import is_case_model -from vultron.core.ports.case_persistence import ( - CasePersistence, - CaseOutboxPersistence, -) -from vultron.core.states.em import EM -from vultron.core.states.participant_embargo_consent import ( - PEC, - PEC_Trigger, - apply_pec_trigger, -) -from vultron.core.states.rm import RM -from vultron.core.states.roles import CVDRole -from vultron.core.use_cases.received.case import _store_embedded_participants -from vultron.core.use_cases._helpers import ( - _as_id, - _find_case_actor_id, - _idempotent_create, -) - -if TYPE_CHECKING: - from vultron.core.ports.trigger_activity import TriggerActivityPort - -logger = logging.getLogger(__name__) - - -def _link_report_case_links(dl: CasePersistence, case) -> None: - """Attach any matching ``ReportCaseLink`` records to the announced case.""" - for report_ref in case.vulnerability_reports: - report_id = _as_id(report_ref) - if report_id is None: - continue - - link = dl.read(VultronReportCaseLink.build_id(report_id)) - if not isinstance(link, VultronReportCaseLink): - continue - if link.case_id == case.id_: - continue - - dl.save(link.model_copy(update={"case_id": case.id_})) - logger.info( - "AnnounceVulnerabilityCase: linked report '%s' to case '%s'", - report_id, - case.id_, - ) - - -class SuggestActorToCaseReceivedUseCase: - def __init__( - self, - dl: CasePersistence, - request: SuggestActorToCaseReceivedEvent, - trigger_activity: "TriggerActivityPort | None" = None, - ) -> None: - self._dl = dl - self._request: SuggestActorToCaseReceivedEvent = request - self._trigger_activity = trigger_activity - - def execute(self) -> None: - request = self._request - activity_id = request.activity_id - recommender_id = request.actor_id - invitee_id = request.object_id - case_id = request.target_id - - if not invitee_id or not case_id: - logger.warning( - "SuggestActorToCaseReceived: missing invitee_id or case_id" - " in event '%s' — skipping", - activity_id, - ) - return - - # Persist the incoming recommendation for record-keeping. - _idempotent_create( - self._dl, - request.activity_type, - activity_id, - request.activity, - "RecommendActor", - activity_id, - ) - - from vultron.core.behaviors.bridge import BTBridge - from vultron.core.behaviors.case.suggest_actor_tree import ( - create_suggest_actor_tree, - ) - from vultron.core.use_cases.received.sync import _find_local_actor_id - - local_actor_id = _find_local_actor_id(self._dl) - if local_actor_id is None: - logger.warning( - "SuggestActorToCaseReceived: no local actor found in DataLayer" - " — skipping event '%s'", - activity_id, - ) - return - - tree = create_suggest_actor_tree( - recommendation_id=activity_id, - recommender_id=recommender_id, - invitee_id=invitee_id, - case_id=case_id, - ) - bridge = BTBridge( - datalayer=self._dl, trigger_activity=self._trigger_activity - ) - bridge.execute_with_setup(tree, actor_id=local_actor_id) - - -class AcceptSuggestActorToCaseReceivedUseCase: - def __init__( - self, - dl: CasePersistence, - request: AcceptSuggestActorToCaseReceivedEvent, - ) -> None: - self._dl = dl - self._request: AcceptSuggestActorToCaseReceivedEvent = request - - def execute(self) -> None: - request = self._request - _idempotent_create( - self._dl, - request.activity_type, - request.activity_id, - request.activity, - "AcceptSuggestActorToCase", - request.activity_id, - ) - - -class RejectSuggestActorToCaseReceivedUseCase: - def __init__( - self, - dl: CasePersistence, - request: RejectSuggestActorToCaseReceivedEvent, - ) -> None: - self._dl = dl - self._request: RejectSuggestActorToCaseReceivedEvent = request - - def execute(self) -> None: - request = self._request - logger.info( - "Actor '%s' rejected recommendation to add actor '%s' to case", - request.actor_id, - request.suggested_actor_id, - ) - - -class OfferCaseManagerRoleReceivedUseCase: - """Handle an incoming CASE_MANAGER role delegation offer. - - Idempotently stores the incoming Offer activity, then auto-accepts it - on behalf of the local actor (the Case Actor entity that received the - Offer). The Accept is queued to the Case Actor's outbox so the offering - Vendor receives confirmation. - - See DEMOMA-08-002, DEMOMA-08-003; Issue #469. - """ - - def __init__( - self, - dl: CaseOutboxPersistence, - request: OfferCaseManagerRoleReceivedEvent, - trigger_activity: "TriggerActivityPort | None" = None, - ) -> None: - self._dl = dl - self._request: OfferCaseManagerRoleReceivedEvent = request - self._trigger_activity = trigger_activity - - def execute(self) -> None: - from vultron.core.use_cases.received.sync import _find_local_actor_id - from vultron.core.use_cases.triggers._helpers import ( - add_activity_to_outbox, - ) - - request = self._request - _idempotent_create( - self._dl, - request.activity_type, - request.activity_id, - request.activity, - "OfferCaseManagerRole", - request.activity_id, - ) - logger.info( - "OfferCaseManagerRoleReceived: actor '%s' offered CASE_MANAGER" - " role delegation for activity '%s'", - request.actor_id, - request.activity_id, - ) - - if self._trigger_activity is None: - logger.warning( - "OfferCaseManagerRoleReceived: trigger_activity not available" - " — skipping auto-accept for offer '%s'", - request.activity_id, - ) - return - - case_id = _as_id(request.activity.object_) - participant_id = _as_id(request.activity.target) - offer_id = request.activity_id - vendor_id = request.actor_id - - if not case_id or not participant_id: - logger.warning( - "OfferCaseManagerRoleReceived: missing case_id or" - " participant_id in offer '%s' — skipping auto-accept", - offer_id, - ) - return - - local_actor_id = request.receiving_actor_id or _find_local_actor_id( - self._dl - ) - if local_actor_id is None: - logger.warning( - "OfferCaseManagerRoleReceived: no local actor found" - " — skipping auto-accept for offer '%s'", - offer_id, - ) - return - - try: - accept_id = self._trigger_activity.accept_case_manager_role( - offer_id=offer_id, - case_id=case_id, - participant_id=participant_id, - vendor_id=vendor_id, - actor=local_actor_id, - to=[vendor_id], - ) - add_activity_to_outbox(local_actor_id, accept_id, self._dl) - logger.info( - "OfferCaseManagerRoleReceived: auto-accepted offer '%s'" - " as actor '%s'; queued Accept '%s' to outbox", - offer_id, - local_actor_id, - accept_id, - ) - except Exception as exc: - logger.error( - "OfferCaseManagerRoleReceived: error auto-accepting offer" - " '%s': %s", - offer_id, - exc, - ) - - -class AcceptCaseManagerRoleReceivedUseCase: - """Handle an incoming acceptance of the CASE_MANAGER role delegation offer. - - The offering actor (Vendor) receives this Accept from the Case Actor. - After persisting the activity the Vendor bootstraps trust with the - Reporter by sending ``Create(VulnerabilityCase)`` to the Reporter's inbox. - - See notes/case-bootstrap-trust.md CBT-01; Issue #469. - """ - - def __init__( - self, - dl: CaseOutboxPersistence, - request: AcceptCaseManagerRoleReceivedEvent, - trigger_activity: "TriggerActivityPort | None" = None, - ) -> None: - self._dl = dl - self._request: AcceptCaseManagerRoleReceivedEvent = request - self._trigger_activity = trigger_activity - - def execute(self) -> None: - from vultron.core.use_cases.received.sync import _find_local_actor_id - from vultron.core.use_cases.triggers._helpers import ( - add_activity_to_outbox, - ) - - request = self._request - _idempotent_create( - self._dl, - request.activity_type, - request.activity_id, - request.activity, - "AcceptCaseManagerRole", - request.activity_id, - ) - logger.info( - "AcceptCaseManagerRoleReceived: actor '%s' accepted CASE_MANAGER" - " role delegation (inner case: '%s')", - request.actor_id, - request.inner_object_id, - ) - - if self._trigger_activity is None: - logger.warning( - "AcceptCaseManagerRoleReceived: trigger_activity not available" - " — skipping trust bootstrap for case '%s'", - request.inner_object_id, - ) - return - - case_id = request.case_id - if not case_id: - logger.warning( - "AcceptCaseManagerRoleReceived: missing case_id on request" - " '%s' — skipping trust bootstrap", - request.activity_id, - ) - return - - case = self._dl.read(case_id) - if not is_case_model(case): - logger.warning( - "AcceptCaseManagerRoleReceived: case '%s' not found" - " — skipping trust bootstrap", - case_id, - ) - return - - local_actor_id = request.receiving_actor_id or _find_local_actor_id( - self._dl - ) - if local_actor_id is None: - logger.warning( - "AcceptCaseManagerRoleReceived: no local actor found" - " — skipping trust bootstrap for case '%s'", - case_id, - ) - return - - # Find the Reporter participant to address the Create(Case) to. - reporter_id: str | None = None - for p_id in case.actor_participant_index.values(): - p = self._dl.read(p_id) - roles = getattr(p, "case_roles", []) - if CVDRole.REPORTER in roles or CVDRole.FINDER in roles: - reporter_id = _as_id(getattr(p, "attributed_to", None)) - break - - if not reporter_id: - logger.warning( - "AcceptCaseManagerRoleReceived: no Reporter participant found" - " in case '%s' — skipping trust bootstrap", - case_id, - ) - return - - try: - create_id, _ = self._trigger_activity.create_case( - case_id=case_id, - actor=local_actor_id, - to=[reporter_id], - ) - add_activity_to_outbox(local_actor_id, create_id, self._dl) - logger.info( - "AcceptCaseManagerRoleReceived: sent Create(VulnerabilityCase)" - " '%s' to Reporter '%s' for case '%s'", - create_id, - reporter_id, - case_id, - ) - except Exception as exc: - logger.error( - "AcceptCaseManagerRoleReceived: error sending trust bootstrap" - " for case '%s': %s", - case_id, - exc, - ) - - -class RejectCaseManagerRoleReceivedUseCase: - """Process a Reject(Offer(VulnerabilityCase)) for CASE_MANAGER delegation. - - The offering actor (Vendor) receives this rejection from the Case Actor. - Logs a warning so the operator can investigate. - """ - - def __init__( - self, - dl: CasePersistence, - request: RejectCaseManagerRoleReceivedEvent, - ) -> None: - self._dl = dl - self._request: RejectCaseManagerRoleReceivedEvent = request - - def execute(self) -> None: - request = self._request - logger.warning( - "RejectCaseManagerRoleReceived: actor '%s' rejected CASE_MANAGER" - " role delegation offer '%s'", - request.actor_id, - request.object_id, - ) - - -class OfferCaseOwnershipTransferReceivedUseCase: - def __init__( - self, - dl: CasePersistence, - request: OfferCaseOwnershipTransferReceivedEvent, - ) -> None: - self._dl = dl - self._request: OfferCaseOwnershipTransferReceivedEvent = request - - def execute(self) -> None: - request = self._request - _idempotent_create( - self._dl, - request.activity_type, - request.activity_id, - request.activity, - "OfferCaseOwnershipTransfer", - request.activity_id, - ) - - -class AcceptCaseOwnershipTransferReceivedUseCase: - def __init__( - self, - dl: CasePersistence, - request: AcceptCaseOwnershipTransferReceivedEvent, - ) -> None: - self._dl = dl - self._request: AcceptCaseOwnershipTransferReceivedEvent = request - - def execute(self) -> None: - request = self._request - case_id = request.case_id - new_owner_id = request.actor_id - if case_id is None: - logger.warning( - "accept_case_ownership_transfer: missing case_id on request" - ) - return - case = self._dl.read(case_id) - - if not is_case_model(case): - logger.warning( - "accept_case_ownership_transfer: case '%s' not found", - case_id, - ) - return - - current_owner_id = _as_id(case.attributed_to) - if current_owner_id == new_owner_id: - logger.info( - "Case '%s' already owned by '%s' — skipping (idempotent)", - case_id, - new_owner_id, - ) - return - - case.attributed_to = new_owner_id # type: ignore[assignment] - self._dl.save(case) - logger.info( - "Transferred ownership of case '%s' from '%s' to '%s'", - case_id, - current_owner_id, - new_owner_id, - ) - - -class RejectCaseOwnershipTransferReceivedUseCase: - def __init__( - self, - dl: CasePersistence, - request: RejectCaseOwnershipTransferReceivedEvent, - ) -> None: - self._dl = dl - self._request: RejectCaseOwnershipTransferReceivedEvent = request - - def execute(self) -> None: - request = self._request - logger.info( - "Actor '%s' rejected ownership transfer offer '%s' — ownership unchanged", - request.actor_id, - request.offer_id, - ) - - -class InviteActorToCaseReceivedUseCase: - def __init__( - self, dl: CasePersistence, request: InviteActorToCaseReceivedEvent - ) -> None: - self._dl = dl - self._request: InviteActorToCaseReceivedEvent = request - - def execute(self) -> None: - request = self._request - _idempotent_create( - self._dl, - request.activity_type, - request.activity_id, - request.activity, - "InviteActorToCase", - request.activity_id, - ) - # MV-10-004: do NOT create a case from the stub target. The stub - # carries only {id, type} for identification; the full case details - # arrive later in an AnnounceVulnerabilityCase activity (MV-10-003). - case_stub_id = request.target_id - if case_stub_id: - logger.info( - "InviteActorToCase: received invite with case stub '%s'." - " Awaiting AnnounceVulnerabilityCase before creating case.", - case_stub_id, - ) - - -class AcceptInviteActorToCaseReceivedUseCase: - def __init__( - self, - dl: CaseOutboxPersistence, - request: AcceptInviteActorToCaseReceivedEvent, - trigger_activity: "TriggerActivityPort | None" = None, - ) -> None: - self._dl = dl - self._request: AcceptInviteActorToCaseReceivedEvent = request - self._trigger_activity = trigger_activity - - def execute(self) -> None: - request = self._request - case_id = request.case_id - invitee_id = request.invitee_id - if case_id is None or invitee_id is None: - logger.warning( - "accept_invite_actor_to_case: missing case_id or invitee_id" - ) - return - case = self._dl.read(case_id) - - if not is_case_model(case): - logger.warning( - "accept_invite_actor_to_case: case '%s' not found", case_id - ) - return - - existing_ids = [_as_id(p) for p in case.case_participants] - if ( - invitee_id in case.actor_participant_index - or invitee_id in existing_ids - ): - logger.info( - "Actor '%s' already participant in case '%s' — skipping (idempotent)", - invitee_id, - case_id, - ) - return - - active_embargo_id = _as_id(case.active_embargo) - em_state = case.current_status.em_state - - participant = VultronParticipant( - id_=f"{case_id}/participants/{invitee_id.split('/')[-1]}", - attributed_to=invitee_id, - context=case_id, - ) - # Accept(Invite) IS the engage signal (PCR-08-010): pre-seed all three - # RM states inline so the participant reaches ACCEPTED immediately - # without a separate engage-case trigger or identity-spoofing BT call. - participant.append_rm_state( - RM.RECEIVED, actor=invitee_id, context=case_id - ) - participant.append_rm_state( - RM.VALID, actor=invitee_id, context=case_id - ) - participant.append_rm_state( - RM.ACCEPTED, actor=invitee_id, context=case_id - ) - # Only auto-sign embargo consent when the embargo is fully ACTIVE. - # In REVISE state the terms are under negotiation; auto-signing would - # commit the new participant to unresolved terms. - if active_embargo_id and em_state == EM.ACTIVE: - participant.accepted_embargo_ids.append(active_embargo_id) - participant.embargo_consent_state = apply_pec_trigger( - PEC.NO_EMBARGO, PEC_Trigger.ACCEPT - ) - self._dl.create(participant) - - case.add_participant(participant) - case.record_event(invitee_id, "participant_joined") - if active_embargo_id and em_state == EM.ACTIVE: - case.record_event(active_embargo_id, "embargo_accepted") - self._dl.save(case) - - # MV-10-003/MV-10-005: emit full case details to the invitee now that - # embargo consent has been resolved (auto-signed above when ACTIVE). - # The Case Actor sends Announce(VulnerabilityCase) so the invitee can - # seed their local DataLayer. - self._emit_announce_case(case_id, invitee_id, case) - - logger.info( - "Added participant '%s' to case '%s' via accepted invite" - " (RM.ACCEPTED inline, PCR-08-010)", - invitee_id, - case_id, - ) - - def _emit_announce_case(self, case_id: str, invitee_id: str, case) -> None: - """Emit Announce(VulnerabilityCase) to the invitee from the CaseActor. - - Per MV-10-003, the case owner sends the full case object after the - invitee's embargo consent has been verified. - """ - from vultron.core.use_cases.triggers._helpers import ( - add_activity_to_outbox, - ) - - if self._trigger_activity is None: - logger.warning( - "AcceptInviteActorToCase: no TriggerActivityPort;" - " cannot emit AnnounceVulnerabilityCase for case '%s'", - case_id, - ) - return - - case_actor_id = _find_case_actor_id(self._dl, case_id) - if case_actor_id is None: - logger.warning( - "AcceptInviteActorToCase: no CaseActor found;" - " cannot emit AnnounceVulnerabilityCase for case '%s'", - case_id, - ) - return - - try: - activity_id = self._trigger_activity.announce_vulnerability_case( - case_id=case_id, - actor=case_actor_id, - context_id=case_id, - to=[invitee_id], - ) - add_activity_to_outbox(case_actor_id, activity_id, self._dl) - logger.info( - "Emitted AnnounceVulnerabilityCase '%s' to '%s' for case '%s'", - activity_id, - invitee_id, - case_id, - ) - except Exception as exc: - logger.error( - "Failed to emit AnnounceVulnerabilityCase for case '%s'" - " to '%s': %s", - case_id, - invitee_id, - exc, - ) - - -class RejectInviteActorToCaseReceivedUseCase: - def __init__( - self, - dl: CasePersistence, - request: RejectInviteActorToCaseReceivedEvent, - ) -> None: - self._dl = dl - self._request: RejectInviteActorToCaseReceivedEvent = request - - def execute(self) -> None: - request = self._request - logger.info( - "Actor '%s' rejected invitation '%s'", - request.actor_id, - request.invite_id, - ) - - -class AnnounceVulnerabilityCaseReceivedUseCase: - """Seed the local DataLayer with a full VulnerabilityCase from the case owner. - - Per MV-10-003, the invitee creates the case if it does not already exist. - Per MV-10-004, if the case already exists locally, the announcement is - accepted without overwriting the existing record (idempotent). - """ - - def __init__( - self, - dl: CasePersistence, - request: AnnounceVulnerabilityCaseReceivedEvent, - ) -> None: - self._dl = dl - self._request = request - - def execute(self) -> None: - request = self._request - activity = request.activity - if activity is None: - logger.warning( - "AnnounceVulnerabilityCase: no activity on event '%s' — skipping", - request.activity_id, - ) - return - - # The case object is the object_ field of the announce activity. - case_obj = getattr(activity, "object_", None) - if case_obj is None: - logger.warning( - "AnnounceVulnerabilityCase: no case object in activity '%s'" - " — skipping", - request.activity_id, - ) - return - - if not is_case_model(case_obj): - logger.warning( - "AnnounceVulnerabilityCase: object in activity '%s' is not a" - " VulnerabilityCase (%s) — skipping", - request.activity_id, - type(case_obj).__name__, - ) - return - - case_id = _as_id(case_obj) - if case_id is None: - logger.warning( - "AnnounceVulnerabilityCase: case object has no id in" - " activity '%s' — skipping", - request.activity_id, - ) - return - - case_actor_id = _find_case_actor_id(self._dl, case_id) - if case_actor_id is not None and case_actor_id != request.actor_id: - logger.warning( - "AnnounceVulnerabilityCase: actor '%s' is not the CaseActor" - " for case '%s' — update rejected (PCR-03-001)", - request.actor_id, - case_id, - ) - return - - existing = self._dl.read(case_id) - if existing is not None: - logger.info( - "AnnounceVulnerabilityCase: case '%s' already exists locally" - " — skipping (idempotent, MV-10-004)", - case_id, - ) - _store_embedded_participants(case_obj, self._dl, case_id) - _link_report_case_links(self._dl, case_obj) - return - - try: - self._dl.save(case_obj) - _store_embedded_participants(case_obj, self._dl, case_id) - _link_report_case_links(self._dl, case_obj) - logger.info( - "AnnounceVulnerabilityCase: seeded case '%s' from actor '%s'", - case_id, - request.actor_id, - ) - except Exception as exc: - logger.error( - "AnnounceVulnerabilityCase: failed to create case '%s': %s", - case_id, - exc, - ) diff --git a/vultron/core/use_cases/received/actor/__init__.py b/vultron/core/use_cases/received/actor/__init__.py new file mode 100644 index 000000000..f4f3ce047 --- /dev/null +++ b/vultron/core/use_cases/received/actor/__init__.py @@ -0,0 +1,26 @@ +"""Use cases for case actor/participant invitation and suggestion activities.""" + +from vultron.core.use_cases._helpers import _find_case_actor_id +from vultron.core.use_cases.received.actor.announce import ( + AnnounceVulnerabilityCaseReceivedUseCase, +) +from vultron.core.use_cases.received.actor.case_manager_role import ( + AcceptCaseManagerRoleReceivedUseCase, + OfferCaseManagerRoleReceivedUseCase, + RejectCaseManagerRoleReceivedUseCase, +) +from vultron.core.use_cases.received.actor.invite import ( + AcceptInviteActorToCaseReceivedUseCase, + InviteActorToCaseReceivedUseCase, + RejectInviteActorToCaseReceivedUseCase, +) +from vultron.core.use_cases.received.actor.ownership import ( + AcceptCaseOwnershipTransferReceivedUseCase, + OfferCaseOwnershipTransferReceivedUseCase, + RejectCaseOwnershipTransferReceivedUseCase, +) +from vultron.core.use_cases.received.actor.suggest import ( + AcceptSuggestActorToCaseReceivedUseCase, + RejectSuggestActorToCaseReceivedUseCase, + SuggestActorToCaseReceivedUseCase, +) diff --git a/vultron/core/use_cases/received/actor/announce.py b/vultron/core/use_cases/received/actor/announce.py new file mode 100644 index 000000000..7761c134f --- /dev/null +++ b/vultron/core/use_cases/received/actor/announce.py @@ -0,0 +1,127 @@ +"""Use cases for case actor/participant invitation and suggestion activities.""" + +import logging + +from vultron.core.models.events.actor import ( + AnnounceVulnerabilityCaseReceivedEvent, +) +from vultron.core.models.protocols import is_case_model +from vultron.core.models.report_case_link import VultronReportCaseLink +from vultron.core.ports.case_persistence import CasePersistence +from vultron.core.use_cases._helpers import _as_id, _find_case_actor_id +from vultron.core.use_cases.received.case import _store_embedded_participants + +logger = logging.getLogger(__name__) + + +def _link_report_case_links(dl: CasePersistence, case) -> None: + """Attach any matching ``ReportCaseLink`` records to the announced case.""" + for report_ref in case.vulnerability_reports: + report_id = _as_id(report_ref) + if report_id is None: + continue + + link = dl.read(VultronReportCaseLink.build_id(report_id)) + if not isinstance(link, VultronReportCaseLink): + continue + if link.case_id == case.id_: + continue + + dl.save(link.model_copy(update={"case_id": case.id_})) + logger.info( + "AnnounceVulnerabilityCase: linked report '%s' to case '%s'", + report_id, + case.id_, + ) + + +class AnnounceVulnerabilityCaseReceivedUseCase: + """Seed the local DataLayer with a full VulnerabilityCase from the case owner. + + Per MV-10-003, the invitee creates the case if it does not already exist. + Per MV-10-004, if the case already exists locally, the announcement is + accepted without overwriting the existing record (idempotent). + """ + + def __init__( + self, + dl: CasePersistence, + request: AnnounceVulnerabilityCaseReceivedEvent, + ) -> None: + self._dl = dl + self._request = request + + def execute(self) -> None: + request = self._request + activity = request.activity + if activity is None: + logger.warning( + "AnnounceVulnerabilityCase: no activity on event '%s' — skipping", + request.activity_id, + ) + return + + # The case object is the object_ field of the announce activity. + case_obj = getattr(activity, "object_", None) + if case_obj is None: + logger.warning( + "AnnounceVulnerabilityCase: no case object in activity '%s'" + " — skipping", + request.activity_id, + ) + return + + if not is_case_model(case_obj): + logger.warning( + "AnnounceVulnerabilityCase: object in activity '%s' is not a" + " VulnerabilityCase (%s) — skipping", + request.activity_id, + type(case_obj).__name__, + ) + return + + case_id = _as_id(case_obj) + if case_id is None: + logger.warning( + "AnnounceVulnerabilityCase: case object has no id in" + " activity '%s' — skipping", + request.activity_id, + ) + return + + case_actor_id = _find_case_actor_id(self._dl, case_id) + if case_actor_id is not None and case_actor_id != request.actor_id: + logger.warning( + "AnnounceVulnerabilityCase: actor '%s' is not the CaseActor" + " for case '%s' — update rejected (PCR-03-001)", + request.actor_id, + case_id, + ) + return + + existing = self._dl.read(case_id) + if existing is not None: + logger.info( + "AnnounceVulnerabilityCase: case '%s' already exists locally" + " — skipping (idempotent, MV-10-004)", + case_id, + ) + _store_embedded_participants(case_obj, self._dl, case_id) + _link_report_case_links(self._dl, case_obj) + return + + try: + self._dl.save(case_obj) + _store_embedded_participants(case_obj, self._dl, case_id) + _link_report_case_links(self._dl, case_obj) + logger.info( + "AnnounceVulnerabilityCase: seeded case '%s' from actor '%s'", + case_id, + request.actor_id, + ) + except Exception as exc: + logger.error( + "AnnounceVulnerabilityCase: failed to create case '%s': %s", + case_id, + exc, + ) diff --git a/vultron/core/use_cases/received/actor/case_manager_role.py b/vultron/core/use_cases/received/actor/case_manager_role.py new file mode 100644 index 000000000..1cecf897d --- /dev/null +++ b/vultron/core/use_cases/received/actor/case_manager_role.py @@ -0,0 +1,270 @@ +"""Use cases for case actor/participant invitation and suggestion activities.""" + +import logging +from typing import TYPE_CHECKING + +from vultron.core.models.events.actor import ( + AcceptCaseManagerRoleReceivedEvent, + OfferCaseManagerRoleReceivedEvent, + RejectCaseManagerRoleReceivedEvent, +) +from vultron.core.models.protocols import is_case_model +from vultron.core.ports.case_persistence import ( + CaseOutboxPersistence, + CasePersistence, +) +from vultron.core.states.roles import CVDRole +from vultron.core.use_cases._helpers import ( + _as_id, + _idempotent_create, +) + +if TYPE_CHECKING: + from vultron.core.ports.trigger_activity import TriggerActivityPort + +logger = logging.getLogger(__name__) + + +class OfferCaseManagerRoleReceivedUseCase: + """Handle an incoming CASE_MANAGER role delegation offer. + + Idempotently stores the incoming Offer activity, then auto-accepts it + on behalf of the local actor (the Case Actor entity that received the + Offer). The Accept is queued to the Case Actor's outbox so the offering + Vendor receives confirmation. + + See DEMOMA-08-002, DEMOMA-08-003; Issue #469. + """ + + def __init__( + self, + dl: CaseOutboxPersistence, + request: OfferCaseManagerRoleReceivedEvent, + trigger_activity: "TriggerActivityPort | None" = None, + ) -> None: + self._dl = dl + self._request: OfferCaseManagerRoleReceivedEvent = request + self._trigger_activity = trigger_activity + + def execute(self) -> None: + from vultron.core.use_cases.received.sync import _find_local_actor_id + from vultron.core.use_cases.triggers._helpers import ( + add_activity_to_outbox, + ) + + request = self._request + _idempotent_create( + self._dl, + request.activity_type, + request.activity_id, + request.activity, + "OfferCaseManagerRole", + request.activity_id, + ) + logger.info( + "OfferCaseManagerRoleReceived: actor '%s' offered CASE_MANAGER" + " role delegation for activity '%s'", + request.actor_id, + request.activity_id, + ) + + if self._trigger_activity is None: + logger.warning( + "OfferCaseManagerRoleReceived: trigger_activity not available" + " — skipping auto-accept for offer '%s'", + request.activity_id, + ) + return + + case_id = _as_id(request.activity.object_) + participant_id = _as_id(request.activity.target) + offer_id = request.activity_id + vendor_id = request.actor_id + + if not case_id or not participant_id: + logger.warning( + "OfferCaseManagerRoleReceived: missing case_id or" + " participant_id in offer '%s' — skipping auto-accept", + offer_id, + ) + return + + local_actor_id = request.receiving_actor_id or _find_local_actor_id( + self._dl + ) + if local_actor_id is None: + logger.warning( + "OfferCaseManagerRoleReceived: no local actor found" + " — skipping auto-accept for offer '%s'", + offer_id, + ) + return + + try: + accept_id = self._trigger_activity.accept_case_manager_role( + offer_id=offer_id, + case_id=case_id, + participant_id=participant_id, + vendor_id=vendor_id, + actor=local_actor_id, + to=[vendor_id], + ) + add_activity_to_outbox(local_actor_id, accept_id, self._dl) + logger.info( + "OfferCaseManagerRoleReceived: auto-accepted offer '%s'" + " as actor '%s'; queued Accept '%s' to outbox", + offer_id, + local_actor_id, + accept_id, + ) + except Exception as exc: + logger.error( + "OfferCaseManagerRoleReceived: error auto-accepting offer" + " '%s': %s", + offer_id, + exc, + ) + + +class AcceptCaseManagerRoleReceivedUseCase: + """Handle an incoming acceptance of the CASE_MANAGER role delegation offer. + + The offering actor (Vendor) receives this Accept from the Case Actor. + After persisting the activity the Vendor bootstraps trust with the + Reporter by sending ``Create(VulnerabilityCase)`` to the Reporter's inbox. + + See notes/case-bootstrap-trust.md CBT-01; Issue #469. + """ + + def __init__( + self, + dl: CaseOutboxPersistence, + request: AcceptCaseManagerRoleReceivedEvent, + trigger_activity: "TriggerActivityPort | None" = None, + ) -> None: + self._dl = dl + self._request: AcceptCaseManagerRoleReceivedEvent = request + self._trigger_activity = trigger_activity + + def execute(self) -> None: + from vultron.core.use_cases.received.sync import _find_local_actor_id + from vultron.core.use_cases.triggers._helpers import ( + add_activity_to_outbox, + ) + + request = self._request + _idempotent_create( + self._dl, + request.activity_type, + request.activity_id, + request.activity, + "AcceptCaseManagerRole", + request.activity_id, + ) + logger.info( + "AcceptCaseManagerRoleReceived: actor '%s' accepted CASE_MANAGER" + " role delegation (inner case: '%s')", + request.actor_id, + request.inner_object_id, + ) + + if self._trigger_activity is None: + logger.warning( + "AcceptCaseManagerRoleReceived: trigger_activity not available" + " — skipping trust bootstrap for case '%s'", + request.inner_object_id, + ) + return + + case_id = request.case_id + if not case_id: + logger.warning( + "AcceptCaseManagerRoleReceived: missing case_id on request" + " '%s' — skipping trust bootstrap", + request.activity_id, + ) + return + + case = self._dl.read(case_id) + if not is_case_model(case): + logger.warning( + "AcceptCaseManagerRoleReceived: case '%s' not found" + " — skipping trust bootstrap", + case_id, + ) + return + + local_actor_id = request.receiving_actor_id or _find_local_actor_id( + self._dl + ) + if local_actor_id is None: + logger.warning( + "AcceptCaseManagerRoleReceived: no local actor found" + " — skipping trust bootstrap for case '%s'", + case_id, + ) + return + + # Find the Reporter participant to address the Create(Case) to. + reporter_id: str | None = None + for p_id in case.actor_participant_index.values(): + p = self._dl.read(p_id) + roles = getattr(p, "case_roles", []) + if CVDRole.REPORTER in roles or CVDRole.FINDER in roles: + reporter_id = _as_id(getattr(p, "attributed_to", None)) + break + + if not reporter_id: + logger.warning( + "AcceptCaseManagerRoleReceived: no Reporter participant found" + " in case '%s' — skipping trust bootstrap", + case_id, + ) + return + + try: + create_id, _ = self._trigger_activity.create_case( + case_id=case_id, + actor=local_actor_id, + to=[reporter_id], + ) + add_activity_to_outbox(local_actor_id, create_id, self._dl) + logger.info( + "AcceptCaseManagerRoleReceived: sent Create(VulnerabilityCase)" + " '%s' to Reporter '%s' for case '%s'", + create_id, + reporter_id, + case_id, + ) + except Exception as exc: + logger.error( + "AcceptCaseManagerRoleReceived: error sending trust bootstrap" + " for case '%s': %s", + case_id, + exc, + ) + + +class RejectCaseManagerRoleReceivedUseCase: + """Process a Reject(Offer(VulnerabilityCase)) for CASE_MANAGER delegation. + + The offering actor (Vendor) receives this rejection from the Case Actor. + Logs a warning so the operator can investigate. + """ + + def __init__( + self, + dl: CasePersistence, + request: RejectCaseManagerRoleReceivedEvent, + ) -> None: + self._dl = dl + self._request: RejectCaseManagerRoleReceivedEvent = request + + def execute(self) -> None: + request = self._request + logger.warning( + "RejectCaseManagerRoleReceived: actor '%s' rejected CASE_MANAGER" + " role delegation offer '%s'", + request.actor_id, + request.object_id, + ) diff --git a/vultron/core/use_cases/received/actor/invite.py b/vultron/core/use_cases/received/actor/invite.py new file mode 100644 index 000000000..e04891ee4 --- /dev/null +++ b/vultron/core/use_cases/received/actor/invite.py @@ -0,0 +1,145 @@ +"""Use cases for case actor/participant invitation and suggestion activities.""" + +import logging +from typing import TYPE_CHECKING + +from py_trees.common import Status + +from vultron.core.behaviors.bridge import BTBridge +from vultron.core.behaviors.case.accept_invite_tree import ( + create_accept_invite_actor_to_case_tree, +) +from vultron.core.models.events.actor import ( + AcceptInviteActorToCaseReceivedEvent, + InviteActorToCaseReceivedEvent, + RejectInviteActorToCaseReceivedEvent, +) +from vultron.core.ports.case_persistence import ( + CaseOutboxPersistence, + CasePersistence, +) +from vultron.core.use_cases._helpers import ( + _find_case_actor_id, + _idempotent_create, +) + +if TYPE_CHECKING: + from vultron.core.ports.trigger_activity import TriggerActivityPort + +logger = logging.getLogger(__name__) + + +class InviteActorToCaseReceivedUseCase: + def __init__( + self, dl: CasePersistence, request: InviteActorToCaseReceivedEvent + ) -> None: + self._dl = dl + self._request: InviteActorToCaseReceivedEvent = request + + def execute(self) -> None: + request = self._request + _idempotent_create( + self._dl, + request.activity_type, + request.activity_id, + request.activity, + "InviteActorToCase", + request.activity_id, + ) + # MV-10-004: do NOT create a case from the stub target. The stub + # carries only {id, type} for identification; the full case details + # arrive later in an AnnounceVulnerabilityCase activity (MV-10-003). + case_stub_id = request.target_id + if case_stub_id: + logger.info( + "InviteActorToCase: received invite with case stub '%s'." + " Awaiting AnnounceVulnerabilityCase before creating case.", + case_stub_id, + ) + + +class AcceptInviteActorToCaseReceivedUseCase: + """CaseActor processes ``Accept(Invite(actor, case))`` from the invitee. + + Delegates all protocol-significant work to + ``AcceptInviteActorToCaseBT`` via BTBridge. The BT runs as the + CaseActor (not the invitee), recording the invitee's participation in + the CaseActor's own DataLayer without identity spoofing (PCR-08-010). + + BT-06-001, BT-15-001: all RM transitions, participant creation, case + events, and outbox work live in leaf nodes of the BT. + """ + + def __init__( + self, + dl: CaseOutboxPersistence, + request: AcceptInviteActorToCaseReceivedEvent, + trigger_activity: "TriggerActivityPort | None" = None, + ) -> None: + self._dl = dl + self._request: AcceptInviteActorToCaseReceivedEvent = request + self._trigger_activity = trigger_activity + + def execute(self) -> None: + request = self._request + case_id = request.case_id + invitee_id = request.invitee_id + if case_id is None or invitee_id is None: + logger.warning( + "accept_invite_actor_to_case: missing case_id or invitee_id" + ) + return + + # Resolve the CaseActor ID to use as the BT actor. + # receiving_actor_id is set by the inbox adapter to the CaseActor's ID. + # Fall back to _find_case_actor_id when dispatched outside the inbox + # path (e.g. CLI, tests that do not set receiving_actor_id). + actor_id = request.receiving_actor_id or _find_case_actor_id( + self._dl, case_id + ) + if actor_id is None: + logger.warning( + "accept_invite_actor_to_case: no CaseActor ID for case '%s'" + " — BT will run without a named actor context", + case_id, + ) + actor_id = "unknown" + + result = BTBridge( + datalayer=self._dl, + trigger_activity=self._trigger_activity, + ).execute_with_setup( + tree=create_accept_invite_actor_to_case_tree( + case_id=case_id, + invitee_id=invitee_id, + ), + actor_id=actor_id, + activity=request, + ) + + if result.status == Status.FAILURE: + logger.debug( + "accept_invite_actor_to_case: BT returned FAILURE for" + " invitee '%s' case '%s': %s", + invitee_id, + case_id, + result.feedback_message, + ) + + +class RejectInviteActorToCaseReceivedUseCase: + def __init__( + self, + dl: CasePersistence, + request: RejectInviteActorToCaseReceivedEvent, + ) -> None: + self._dl = dl + self._request: RejectInviteActorToCaseReceivedEvent = request + + def execute(self) -> None: + request = self._request + logger.info( + "Actor '%s' rejected invitation '%s'", + request.actor_id, + request.invite_id, + ) diff --git a/vultron/core/use_cases/received/actor/ownership.py b/vultron/core/use_cases/received/actor/ownership.py new file mode 100644 index 000000000..3dadc9b98 --- /dev/null +++ b/vultron/core/use_cases/received/actor/ownership.py @@ -0,0 +1,99 @@ +"""Use cases for case actor/participant invitation and suggestion activities.""" + +import logging + +from vultron.core.models.events.actor import ( + AcceptCaseOwnershipTransferReceivedEvent, + OfferCaseOwnershipTransferReceivedEvent, + RejectCaseOwnershipTransferReceivedEvent, +) +from vultron.core.models.protocols import is_case_model +from vultron.core.ports.case_persistence import CasePersistence +from vultron.core.use_cases._helpers import _as_id, _idempotent_create + +logger = logging.getLogger(__name__) + + +class OfferCaseOwnershipTransferReceivedUseCase: + def __init__( + self, + dl: CasePersistence, + request: OfferCaseOwnershipTransferReceivedEvent, + ) -> None: + self._dl = dl + self._request: OfferCaseOwnershipTransferReceivedEvent = request + + def execute(self) -> None: + request = self._request + _idempotent_create( + self._dl, + request.activity_type, + request.activity_id, + request.activity, + "OfferCaseOwnershipTransfer", + request.activity_id, + ) + + +class AcceptCaseOwnershipTransferReceivedUseCase: + def __init__( + self, + dl: CasePersistence, + request: AcceptCaseOwnershipTransferReceivedEvent, + ) -> None: + self._dl = dl + self._request: AcceptCaseOwnershipTransferReceivedEvent = request + + def execute(self) -> None: + request = self._request + case_id = request.case_id + new_owner_id = request.actor_id + if case_id is None: + logger.warning( + "accept_case_ownership_transfer: missing case_id on request" + ) + return + case = self._dl.read(case_id) + + if not is_case_model(case): + logger.warning( + "accept_case_ownership_transfer: case '%s' not found", + case_id, + ) + return + + current_owner_id = _as_id(case.attributed_to) + if current_owner_id == new_owner_id: + logger.info( + "Case '%s' already owned by '%s' — skipping (idempotent)", + case_id, + new_owner_id, + ) + return + + case.attributed_to = new_owner_id # type: ignore[assignment] + self._dl.save(case) + logger.info( + "Transferred ownership of case '%s' from '%s' to '%s'", + case_id, + current_owner_id, + new_owner_id, + ) + + +class RejectCaseOwnershipTransferReceivedUseCase: + def __init__( + self, + dl: CasePersistence, + request: RejectCaseOwnershipTransferReceivedEvent, + ) -> None: + self._dl = dl + self._request: RejectCaseOwnershipTransferReceivedEvent = request + + def execute(self) -> None: + request = self._request + logger.info( + "Actor '%s' rejected ownership transfer offer '%s' — ownership unchanged", + request.actor_id, + request.offer_id, + ) diff --git a/vultron/core/use_cases/received/actor/suggest.py b/vultron/core/use_cases/received/actor/suggest.py new file mode 100644 index 000000000..499a6a109 --- /dev/null +++ b/vultron/core/use_cases/received/actor/suggest.py @@ -0,0 +1,119 @@ +"""Use cases for case actor/participant invitation and suggestion activities.""" + +import logging +from typing import TYPE_CHECKING + +from vultron.core.models.events.actor import ( + AcceptSuggestActorToCaseReceivedEvent, + RejectSuggestActorToCaseReceivedEvent, + SuggestActorToCaseReceivedEvent, +) +from vultron.core.ports.case_persistence import CasePersistence +from vultron.core.use_cases._helpers import _idempotent_create + +if TYPE_CHECKING: + from vultron.core.ports.trigger_activity import TriggerActivityPort + +logger = logging.getLogger(__name__) + + +class SuggestActorToCaseReceivedUseCase: + def __init__( + self, + dl: CasePersistence, + request: SuggestActorToCaseReceivedEvent, + trigger_activity: "TriggerActivityPort | None" = None, + ) -> None: + self._dl = dl + self._request: SuggestActorToCaseReceivedEvent = request + self._trigger_activity = trigger_activity + + def execute(self) -> None: + request = self._request + activity_id = request.activity_id + recommender_id = request.actor_id + invitee_id = request.object_id + case_id = request.target_id + + if not invitee_id or not case_id: + logger.warning( + "SuggestActorToCaseReceived: missing invitee_id or case_id" + " in event '%s' — skipping", + activity_id, + ) + return + + # Persist the incoming recommendation for record-keeping. + _idempotent_create( + self._dl, + request.activity_type, + activity_id, + request.activity, + "RecommendActor", + activity_id, + ) + + from vultron.core.behaviors.bridge import BTBridge + from vultron.core.behaviors.case.suggest_actor_tree import ( + create_suggest_actor_tree, + ) + from vultron.core.use_cases.received.sync import _find_local_actor_id + + local_actor_id = _find_local_actor_id(self._dl) + if local_actor_id is None: + logger.warning( + "SuggestActorToCaseReceived: no local actor found in DataLayer" + " — skipping event '%s'", + activity_id, + ) + return + + tree = create_suggest_actor_tree( + recommendation_id=activity_id, + recommender_id=recommender_id, + invitee_id=invitee_id, + case_id=case_id, + ) + bridge = BTBridge( + datalayer=self._dl, trigger_activity=self._trigger_activity + ) + bridge.execute_with_setup(tree, actor_id=local_actor_id) + + +class AcceptSuggestActorToCaseReceivedUseCase: + def __init__( + self, + dl: CasePersistence, + request: AcceptSuggestActorToCaseReceivedEvent, + ) -> None: + self._dl = dl + self._request: AcceptSuggestActorToCaseReceivedEvent = request + + def execute(self) -> None: + request = self._request + _idempotent_create( + self._dl, + request.activity_type, + request.activity_id, + request.activity, + "AcceptSuggestActorToCase", + request.activity_id, + ) + + +class RejectSuggestActorToCaseReceivedUseCase: + def __init__( + self, + dl: CasePersistence, + request: RejectSuggestActorToCaseReceivedEvent, + ) -> None: + self._dl = dl + self._request: RejectSuggestActorToCaseReceivedEvent = request + + def execute(self) -> None: + request = self._request + logger.info( + "Actor '%s' rejected recommendation to add actor '%s' to case", + request.actor_id, + request.suggested_actor_id, + ) diff --git a/vultron/core/use_cases/received/case.py b/vultron/core/use_cases/received/case.py deleted file mode 100644 index e75206b9f..000000000 --- a/vultron/core/use_cases/received/case.py +++ /dev/null @@ -1,809 +0,0 @@ -"""Use cases for vulnerability case activities.""" - -import logging -from typing import TYPE_CHECKING, Any, cast - -from py_trees.common import Status - -from vultron.core.models.events.case import ( - AddReportToCaseReceivedEvent, - CloseCaseReceivedEvent, - CreateCaseReceivedEvent, - DeferCaseReceivedEvent, - EngageCaseReceivedEvent, - UpdateCaseReceivedEvent, -) -from vultron.core.models.participant import VultronParticipant -from vultron.core.models.participant_status import ParticipantStatus -from vultron.core.models.report_case_link import VultronReportCaseLink -from vultron.core.models.vultron_types import VultronActivity -from vultron.core.ports.case_persistence import ( - CasePersistence, - CaseOutboxPersistence, -) -from vultron.core.models.protocols import ( - CaseModel, - is_case_model, -) -from vultron.core.states.rm import RM, is_rm_at_least -from vultron.core.states.roles import CVDRole -from vultron.core.use_cases._helpers import _as_id, update_participant_rm_state -from vultron.core.behaviors.case.update_support import ( - broadcast_case_update, - find_excluded_actor_ids, -) - -if TYPE_CHECKING: - from vultron.core.ports.trigger_activity import TriggerActivityPort - -logger = logging.getLogger(__name__) - - -def _find_case_actor_id_from_participants( - case_obj: CaseModel, dl: CasePersistence -) -> str | None: - """Find the CaseActor ID from the CASE_MANAGER participant in the case. - - Uses duck-typing on ``case_roles`` to avoid importing wire-layer types. - Returns the ``attributed_to`` URI of the first participant holding - ``CVDRole.CASE_MANAGER`` (CBT-01-003). - - Handles both inline objects and ID-only references stored in - ``case_participants``. - """ - for participant_ref in case_obj.case_participants: - # Try inline object first (participant embedded in snapshot) - if not isinstance(participant_ref, str): - roles = getattr(participant_ref, "case_roles", []) - if CVDRole.CASE_MANAGER in roles: - attributed = getattr(participant_ref, "attributed_to", None) - if attributed: - return str(attributed) - continue - - # ID-only reference — look up from DataLayer - participant = dl.read(participant_ref) - if participant is None: - continue - roles = getattr(participant, "case_roles", []) - if CVDRole.CASE_MANAGER in roles: - attributed = getattr(participant, "attributed_to", None) - if attributed: - return str(attributed) - return None - - -def _find_report_case_link( - creator_id: str, dl: CasePersistence -) -> VultronReportCaseLink | None: - """Return a pending ReportCaseLink expecting a bootstrap from *creator_id*. - - Scans all ``ReportCaseLink`` records and returns the first that has - ``trusted_case_creator_id == creator_id`` and ``case_id is None`` - (i.e. awaiting bootstrap). Using the sender identity rather than the - case's vulnerability_reports list makes the lookup independent of whether - the case snapshot embeds the report. - """ - for obj in dl.list_objects("ReportCaseLink"): - if isinstance(obj, VultronReportCaseLink): - if ( - obj.trusted_case_creator_id == creator_id - and obj.case_id is None - ): - return obj - return None - - -def _check_participant_embargo_acceptance( - case: CaseModel, dl: CasePersistence -) -> set[str]: - """Check which participants have not accepted the active embargo. - - Returns a set of actor IDs whose case updates should be withheld per - CM-10-004 (participants that have not accepted the active embargo). - """ - return find_excluded_actor_ids(case, dl) - - -def _store_embedded_participants( - case_obj: CaseModel, dl: CasePersistence, case_id: str -) -> None: - """Persist embedded participant objects from a case snapshot. - - When a bootstrapped or announced ``VulnerabilityCase`` carries fully - materialised participant objects (not just ID strings), each is stored - as an independent DataLayer record. This ensures BT nodes such as - ``CheckParticipantExists`` (#561) and ``AppendParticipantStatusNode`` - (#562, #566) can retrieve them by their UUID. - - Called from: - - ``CreateCaseReceivedUseCase._handle_bootstrap`` (Create path, CBT-05-005) - - ``AnnounceVulnerabilityCaseReceivedUseCase.execute`` (Announce path, #566) - - Idempotent: ``dl.save()`` upserts so repeated calls are safe. - - Args: - case_obj: The bootstrapped or announced case domain object. - dl: DataLayer to persist participants into. - case_id: ID of the case (for log context). - """ - participants = getattr(case_obj, "case_participants", []) or [] - for participant_ref in participants: - if isinstance(participant_ref, str): - continue - pid = getattr(participant_ref, "id_", None) - if pid is None: - continue - dl.save(participant_ref) - logger.info( - "store_embedded_participants: stored participant '%s'" - " for case '%s' (CBT-05-005, #566)", - pid, - case_id, - ) - - -def _ensure_reporter_participant( - dl: CasePersistence, - link: VultronReportCaseLink, - case_obj: CaseModel, - case_id: str, -) -> None: - """Ensure the reporter's participant record is at RM.ACCEPTED (#589, #624). - - When ``Create(VulnerabilityCase)`` carries participant IDs as bare - strings, ``_store_embedded_participants`` skips them. Without an - explicit participant record in the DataLayer, - ``SvcAddParticipantStatusUseCase._resolve_current_participant_state`` - falls back to ``RM.START``, causing the Vendor's Case Actor to reject the - subsequent ``Add(ParticipantStatus)`` as a backwards transition. - - The reporter submitted the original report, which implies they have - already ``RM.ACCEPTED`` from their own perspective. The reporter is - identified as ``attributed_to`` of the ``Offer(Report)`` activity - (``report.attributed_to``). Their ``START→RECEIVED→VALID→ACCEPTED`` arc - is hidden from the protocol — their first observable action already - implies ``RM.ACCEPTED`` (#624). - - This function: - - * Creates the participant record at ``RM.ACCEPTED`` if it is absent. - * Upgrades an existing participant from any state below ``RM.ACCEPTED`` - (e.g. ``RM.START`` seeded by the wire-layer default) to ``RM.ACCEPTED``. - * No-ops if the participant is already at or beyond ``RM.ACCEPTED``. - - This invariant applies **only** to the reporter/finder. All other - participants enter through a visible protocol interaction and their RM - lifecycle proceeds normally from ``RM.RECEIVED``. - - Args: - dl: The reporter's local DataLayer. - link: The ``VultronReportCaseLink`` associating the report to this - case bootstrap. - case_obj: The bootstrapped ``VulnerabilityCase`` snapshot. - case_id: ID of the case (for log context and status context). - """ - report = dl.read(link.report_id) - if report is None: - logger.warning( - "ensure_reporter_participant: report '%s' not found " - "— cannot seed reporter participant (#589)", - link.report_id, - ) - return - - reporter_actor_id = _as_id(getattr(report, "attributed_to", None)) - if not reporter_actor_id: - logger.warning( - "ensure_reporter_participant: report '%s' has no attributed_to " - "— cannot seed reporter participant (#589)", - link.report_id, - ) - return - - index = getattr(case_obj, "actor_participant_index", {}) or {} - participant_id = index.get(reporter_actor_id) - if not participant_id: - logger.warning( - "ensure_reporter_participant: reporter '%s' not in " - "actor_participant_index for case '%s' — skipping (#589)", - reporter_actor_id, - case_id, - ) - return - - existing = dl.read(participant_id) - if existing is not None: - statuses = getattr(existing, "participant_statuses", []) or [] - latest_rm = statuses[-1].rm_state if statuses else RM.START - if is_rm_at_least(latest_rm, RM.ACCEPTED): - logger.debug( - "ensure_reporter_participant: participant '%s' already " - "≥ RM.ACCEPTED — skipping (#589, #624)", - participant_id, - ) - return - _upgrade_participant_to_accepted( - dl, existing, participant_id, case_id, reporter_actor_id, latest_rm - ) - return - - status = ParticipantStatus( - rm_state=RM.ACCEPTED, - context=case_id, - attributed_to=reporter_actor_id, - ) - participant = VultronParticipant( - id_=participant_id, - attributed_to=reporter_actor_id, - context=case_id, - participant_statuses=[status], - ) - try: - dl.create(participant) - logger.info( - "ensure_reporter_participant: created participant '%s' for " - "reporter '%s' at RM.ACCEPTED (#589)", - participant_id, - reporter_actor_id, - ) - except ValueError: - logger.debug( - "ensure_reporter_participant: participant '%s' was concurrently " - "created — idempotent (#589)", - participant_id, - ) - - -def _upgrade_participant_to_accepted( - dl: CasePersistence, - existing: Any, - participant_id: str, - case_id: str, - reporter_actor_id: str, - latest_rm: "RM", -) -> None: - """Upgrade an existing participant record from below RM.ACCEPTED to RM.ACCEPTED. - - Saves the new status as an independent DataLayer record, then reads it back - via the vocabulary registry so the serialised type matches what the - participant container expects. This avoids wire/domain type mismatches when - appending to ``CaseParticipant.participant_statuses``. - """ - upgrade_status = ParticipantStatus( - rm_state=RM.ACCEPTED, - context=case_id, - attributed_to=reporter_actor_id, - ) - try: - dl.create(upgrade_status) - except ValueError: - dl.save(upgrade_status) - wire_status = dl.read(upgrade_status.id_) - participant_statuses = getattr(existing, "participant_statuses", None) - if participant_statuses is not None: - participant_statuses.append( - wire_status if wire_status is not None else upgrade_status - ) - dl.save(existing) - logger.info( - "ensure_reporter_participant: upgraded participant '%s' from " - "%s to RM.ACCEPTED (#589, #624)", - participant_id, - latest_rm, - ) - - -class CreateCaseReceivedUseCase: - """Process a bootstrap ``Create(VulnerabilityCase)`` from a remote actor. - - Receiving this message means *someone else* created the case and is - notifying us. We do NOT create our own case infrastructure here. - - Bootstrap trust path (CBT-01-005 / CBT-01-006): - 1. Locate the ``VultronReportCaseLink`` for any report listed in the case. - 2. Validate that the sender matches ``link.trusted_case_creator_id``. - 3. Extract the ``CaseActor`` ID from the ``CASE_MANAGER`` participant. - 4. Seed a local replica of the case via the case-replica BT. - 5. Update the link with ``case_id`` and ``trusted_case_actor_id``. - """ - - def __init__( - self, dl: CasePersistence, request: CreateCaseReceivedEvent - ) -> None: - self._dl = dl - self._request: CreateCaseReceivedEvent = request - - def execute(self) -> None: - request = self._request - actor_id = request.actor_id - case_id = request.case_id - - if request.case is None: - logger.warning( - "create_case_received: no case domain object in event for " - "case '%s'", - case_id, - ) - return - - if case_id is None: - logger.warning( - "create_case_received: case_id missing in event — skipping" - ) - return - - case_obj_raw = request.case - if not is_case_model(case_obj_raw): - logger.warning( - "create_case_received: case object for case '%s' does not " - "satisfy CaseModel protocol — skipping", - case_id, - ) - return - case_obj: CaseModel = case_obj_raw - link = _find_report_case_link(actor_id, self._dl) - - if link is not None: - # Bootstrap trust path — CBT-01-005 / CBT-01-006 - self._handle_bootstrap(actor_id, case_id, case_obj, link) - else: - logger.info( - "create_case_received: no ReportCaseLink for case '%s' — " - "no bootstrap trust to record (not a known reporter)", - case_id, - ) - - def _handle_bootstrap( - self, - actor_id: str, - case_id: str, - case_obj: CaseModel, - link: VultronReportCaseLink, - ) -> None: - """Validate trust and seed the case replica.""" - # CBT-01-005: sender must match the actor we sent the report to - if link.trusted_case_creator_id is not None: - if actor_id != link.trusted_case_creator_id: - logger.warning( - "create_case_received: bootstrap rejected for case '%s' — " - "sender does not match trusted case creator " - "(CBT-01-005)", - case_id, - ) - return - else: - logger.warning( - "create_case_received: no trusted_case_creator_id in link " - "for case '%s'; accepting bootstrap unchecked", - case_id, - ) - - # CBT-01-003: extract CaseActor from CASE_MANAGER participant - case_actor_id = _find_case_actor_id_from_participants( - case_obj, self._dl - ) - if case_actor_id is None: - logger.warning( - "create_case_received: no CASE_MANAGER participant found in " - "bootstrap snapshot for case '%s'; Announce validation will " - "be bypassed", - case_id, - ) - - logger.info( - "create_case_received: bootstrap accepted for case '%s' from " - "'%s'; seeding replica (CBT-01-006)", - case_id, - actor_id, - ) - - # Seed the local case replica - from vultron.core.models.protocols import is_case_model - - # Idempotency guard (CBT-01-006, ID-04-004) - existing = self._dl.read(case_id) - if is_case_model(existing): - logger.info( - "create_case_received: case '%s' already exists as replica " - "— skipping re-seed", - case_id, - ) - else: - try: - self._dl.create(case_obj) - logger.info( - "create_case_received: replica case '%s' persisted", - case_id, - ) - except ValueError: - logger.info( - "create_case_received: case '%s' persisted concurrently " - "— idempotent", - case_id, - ) - - # CBT-01-006: persist trust anchors in the link - link.case_id = case_id - link.trusted_case_actor_id = case_actor_id - self._dl.save(link) - logger.info( - "create_case_received: ReportCaseLink updated with case_id='%s' " - "and trusted_case_actor_id='%s' (CBT-01-006)", - case_id, - case_actor_id, - ) - - # CBT-05-005: store any embedded participant objects so that BT nodes - # (``CheckParticipantExists``, ``AppendParticipantStatusNode``) can - # find them by UUID via ``datalayer.read(participant_id)``. - # This must happen regardless of the idempotency guard above because - # the inbox router may have already seeded the case before dispatch. - _store_embedded_participants(case_obj, self._dl, case_id) - - # #589: when participants arrive as bare string IDs (the common case), - # _store_embedded_participants cannot create records for them. Ensure - # the reporter's own participant is seeded at RM.ACCEPTED — inferred - # from the fact that they submitted a report. - _ensure_reporter_participant(self._dl, link, case_obj, case_id) - - -class UpdateCaseReceivedUseCase: - def __init__( - self, dl: CaseOutboxPersistence, request: UpdateCaseReceivedEvent - ) -> None: - self._dl = dl - self._request: UpdateCaseReceivedEvent = request - - def execute(self) -> None: - request = self._request - actor_id = request.actor_id - case_id = request.case_id - if case_id is None: - logger.warning("update_case: missing case_id on request") - return - - from py_trees.common import Status - - from vultron.core.behaviors.bridge import BTBridge - from vultron.core.behaviors.case.update_tree import ( - create_update_case_received_tree, - ) - - tree = create_update_case_received_tree( - case_id=case_id, - actor_id=actor_id, - request=request, - ) - bridge = BTBridge(datalayer=self._dl) - result = bridge.execute_with_setup( - tree=tree, - actor_id=actor_id, - activity=request, - ) - if result.status != Status.SUCCESS: - logger.warning( - "UpdateCaseBT did not succeed for actor '%s' / case '%s': %s", - actor_id, - case_id, - BTBridge.get_failure_reason(tree), - ) - - def _broadcast_case_update( - self, - case_id: str, - case: CaseModel, - excluded_actor_ids: set[str] | None = None, - ) -> None: - """Broadcast an Announce activity for the updated case to participants. - - Implements CM-06-001/CM-06-002: after a case update, the CaseActor MUST - send an ActivityStreams Announce to each active case participant's inbox. - Per CM-10-004, participants who have not accepted the active embargo are - excluded from the broadcast. - """ - broadcast_case_update(self._dl, case_id, case, excluded_actor_ids) - - -class EngageCaseReceivedUseCase: - def __init__( - self, - dl: CasePersistence, - request: EngageCaseReceivedEvent, - trigger_activity: "TriggerActivityPort | None" = None, - ) -> None: - self._dl = dl - self._request: EngageCaseReceivedEvent = request - self._trigger_activity = trigger_activity - - def execute(self) -> None: - request = self._request - from vultron.core.behaviors.bridge import BTBridge - from vultron.core.behaviors.report.prioritize_tree import ( - create_engage_case_tree, - ) - - actor_id = request.actor_id - case_id = request.case_id - if case_id is None: - logger.warning("engage_case: missing case_id on request") - return - - # Persist any inline participant objects carried in the case snapshot - # so BT nodes (CheckParticipantExists, AppendParticipantStatusNode) can - # locate them by UUID. Mirrors the Create (#564) and Announce (#566) - # paths (CBT-05-005, fixes #573). - case_obj = request.case - if is_case_model(case_obj): - _store_embedded_participants(case_obj, self._dl, case_id) - - logger.info( - "Actor '%s' engages case '%s' (RM → ACCEPTED)", - actor_id, - case_id, - ) - - bridge = BTBridge( - datalayer=self._dl, trigger_activity=self._trigger_activity - ) - tree = create_engage_case_tree(case_id=case_id, actor_id=actor_id) - result = bridge.execute_with_setup( - tree=tree, actor_id=actor_id, activity=request - ) - - if result.status != Status.SUCCESS: - logger.warning( - "EngageCaseBT did not succeed for actor '%s' / case '%s': %s", - actor_id, - case_id, - BTBridge.get_failure_reason(tree), - ) - - -class DeferCaseReceivedUseCase: - def __init__( - self, - dl: CasePersistence, - request: DeferCaseReceivedEvent, - trigger_activity: "TriggerActivityPort | None" = None, - ) -> None: - self._dl = dl - self._request: DeferCaseReceivedEvent = request - self._trigger_activity = trigger_activity - - def execute(self) -> None: - request = self._request - from vultron.core.behaviors.bridge import BTBridge - from vultron.core.behaviors.report.prioritize_tree import ( - create_defer_case_tree, - ) - - actor_id = request.actor_id - case_id = request.case_id - if case_id is None: - logger.warning("defer_case: missing case_id on request") - return - - logger.info( - "Actor '%s' defers case '%s' (RM → DEFERRED)", - actor_id, - case_id, - ) - - bridge = BTBridge( - datalayer=self._dl, trigger_activity=self._trigger_activity - ) - tree = create_defer_case_tree(case_id=case_id, actor_id=actor_id) - result = bridge.execute_with_setup( - tree=tree, actor_id=actor_id, activity=request - ) - - if result.status != Status.SUCCESS: - logger.warning( - "DeferCaseBT did not succeed for actor '%s' / case '%s': %s", - actor_id, - case_id, - BTBridge.get_failure_reason(tree), - ) - - -class AddReportToCaseReceivedUseCase: - def __init__( - self, dl: CasePersistence, request: AddReportToCaseReceivedEvent - ) -> None: - self._dl = dl - self._request: AddReportToCaseReceivedEvent = request - - def execute(self) -> None: - request = self._request - report_id = request.report_id - case_id = request.case_id - if report_id is None or case_id is None: - logger.warning("add_report_to_case: missing report_id or case_id") - return - case = self._dl.read(case_id) - - if not is_case_model(case): - logger.warning("add_report_to_case: case '%s' not found", case_id) - return - - existing_report_ids = [_as_id(r) for r in case.vulnerability_reports] - if report_id in existing_report_ids: - logger.info( - "Report '%s' already in case '%s' — skipping (idempotent)", - report_id, - case_id, - ) - return - - case.vulnerability_reports.append(report_id) - self._dl.save(case) - logger.info("Added report '%s' to case '%s'", report_id, case_id) - - -class CloseCaseReceivedUseCase: - def __init__( - self, dl: CaseOutboxPersistence, request: CloseCaseReceivedEvent - ) -> None: - self._dl = dl - self._request: CloseCaseReceivedEvent = request - - def execute(self) -> None: - request = self._request - actor_id = request.actor_id - case_id = request.case_id - - logger.info("Actor '%s' is closing case '%s'", actor_id, case_id) - - close_activity = VultronActivity( - type_="Leave", - actor=actor_id, - object_=case_id, - ) - try: - self._dl.create(close_activity) - logger.info("Created Leave activity %s", close_activity.id_) - except ValueError: - logger.info( - "Leave activity for case '%s' already exists — skipping (idempotent)", - case_id, - ) - return - - actor_obj = self._dl.read(actor_id) - if actor_obj is not None and hasattr(actor_obj, "outbox"): - cast(Any, actor_obj).outbox.items.append(close_activity.id_) - self._dl.save(actor_obj) - logger.info( - "Added Leave activity %s to actor %s outbox", - close_activity.id_, - actor_id, - ) - # Queue for delivery via outbox_handler regardless of outbox field - self._dl.record_outbox_item(actor_id, close_activity.id_) - - -class InvalidateCaseUseCase: - """Transition the actor's RM state to INVALID within the given case. - - Called by ``InvalidateReportReceivedUseCase`` after dereferencing - report_id to case_id (CM-12-005). - """ - - def __init__( - self, dl: CasePersistence, case_id: str, actor_id: str - ) -> None: - self._dl = dl - self._case_id = case_id - self._actor_id = actor_id - - def execute(self) -> None: - success = update_participant_rm_state( - self._case_id, self._actor_id, RM.INVALID, self._dl - ) - if success: - logger.info( - "RM → INVALID for actor '%s' in case '%s'", - self._actor_id, - self._case_id, - ) - else: - logger.warning( - "Failed to set RM.INVALID for actor '%s' in case '%s'", - self._actor_id, - self._case_id, - ) - - -class CloseCaseUseCase: - """Transition the actor's RM state to CLOSED within the given case. - - Called by ``CloseReportReceivedUseCase`` after dereferencing - report_id to case_id (CM-12-005). - """ - - def __init__( - self, dl: CasePersistence, case_id: str, actor_id: str - ) -> None: - self._dl = dl - self._case_id = case_id - self._actor_id = actor_id - - def execute(self) -> None: - success = update_participant_rm_state( - self._case_id, self._actor_id, RM.CLOSED, self._dl - ) - if success: - logger.info( - "RM → CLOSED for actor '%s' in case '%s'", - self._actor_id, - self._case_id, - ) - else: - logger.warning( - "Failed to set RM.CLOSED for actor '%s' in case '%s'", - self._actor_id, - self._case_id, - ) - - -class ValidateCaseUseCase: - """Run the validate-report behavior tree for the given case. - - Called by ``ValidateReportReceivedUseCase`` after dereferencing - report_id to case_id (CM-12-005). - - Advances RM to VALID only. The engage/defer decision (RM → ACCEPTED - or RM → DEFERRED) is a distinct, explicit protocol step driven by a - separate ``engage-case`` or ``defer-case`` trigger. - """ - - def __init__( - self, - dl: CasePersistence, - actor_id: str, - report_id: str, - offer_id: str, - ) -> None: - self._dl = dl - self._actor_id = actor_id - self._report_id = report_id - self._offer_id = offer_id - - def execute(self) -> None: - from vultron.core.behaviors.bridge import BTBridge - from vultron.core.behaviors.report.validate_tree import ( - create_validate_report_tree, - ) - - logger.info( - "Actor '%s' validates VulnerabilityReport '%s' via BT", - self._actor_id, - self._report_id, - ) - - bridge = BTBridge(datalayer=self._dl) - tree = create_validate_report_tree( - report_id=self._report_id, - offer_id=self._offer_id, - ) - result = bridge.execute_with_setup(tree, actor_id=self._actor_id) - - if result.status == Status.SUCCESS: - logger.info( - "✓ BT validation succeeded for report: %s", self._report_id - ) - elif result.status == Status.FAILURE: - logger.error( - "✗ BT validation failed for report: %s — %s", - self._report_id, - result.feedback_message, - ) - for err in result.errors or []: - logger.error(" - %s", err) - else: - logger.warning( - "⚠ BT validation incomplete for report: %s (status=%s)", - self._report_id, - result.status, - ) diff --git a/vultron/core/use_cases/received/case/__init__.py b/vultron/core/use_cases/received/case/__init__.py new file mode 100644 index 000000000..836def69f --- /dev/null +++ b/vultron/core/use_cases/received/case/__init__.py @@ -0,0 +1,45 @@ +from vultron.core.use_cases.received.case._helpers import ( + _find_case_actor_id_from_participants, + _find_report_case_link, + _check_participant_embargo_acceptance, + _store_embedded_participants, + _ensure_reporter_participant, + _upgrade_participant_to_accepted, +) +from vultron.core.use_cases.received.case.create import ( + CreateCaseReceivedUseCase, +) +from vultron.core.use_cases.received.case.update import ( + UpdateCaseReceivedUseCase, +) +from vultron.core.use_cases.received.case.engage_defer import ( + EngageCaseReceivedUseCase, + DeferCaseReceivedUseCase, +) +from vultron.core.use_cases.received.case.lifecycle import ( + AddReportToCaseReceivedUseCase, + CloseCaseReceivedUseCase, +) +from vultron.core.use_cases.received.case.validate import ( + InvalidateCaseUseCase, + CloseCaseUseCase, + ValidateCaseUseCase, +) + +__all__ = [ + "_find_case_actor_id_from_participants", + "_find_report_case_link", + "_check_participant_embargo_acceptance", + "_store_embedded_participants", + "_ensure_reporter_participant", + "_upgrade_participant_to_accepted", + "CreateCaseReceivedUseCase", + "UpdateCaseReceivedUseCase", + "EngageCaseReceivedUseCase", + "DeferCaseReceivedUseCase", + "AddReportToCaseReceivedUseCase", + "CloseCaseReceivedUseCase", + "InvalidateCaseUseCase", + "CloseCaseUseCase", + "ValidateCaseUseCase", +] diff --git a/vultron/core/use_cases/received/case/_helpers.py b/vultron/core/use_cases/received/case/_helpers.py new file mode 100644 index 000000000..2f32de946 --- /dev/null +++ b/vultron/core/use_cases/received/case/_helpers.py @@ -0,0 +1,273 @@ +"""Use cases for vulnerability case activities.""" + +import logging +from typing import Any + +from vultron.core.behaviors.case.update_support import ( + find_excluded_actor_ids, +) +from vultron.core.models.participant import VultronParticipant +from vultron.core.models.participant_status import ParticipantStatus +from vultron.core.models.protocols import CaseModel +from vultron.core.models.report_case_link import VultronReportCaseLink +from vultron.core.ports.case_persistence import CasePersistence +from vultron.core.states.rm import RM, is_rm_at_least +from vultron.core.states.roles import CVDRole +from vultron.core.use_cases._helpers import _as_id + +logger = logging.getLogger(__name__) + + +def _find_case_actor_id_from_participants( + case_obj: CaseModel, dl: CasePersistence +) -> str | None: + """Find the CaseActor ID from the CASE_MANAGER participant in the case. + + Uses duck-typing on ``case_roles`` to avoid importing wire-layer types. + Returns the ``attributed_to`` URI of the first participant holding + ``CVDRole.CASE_MANAGER`` (CBT-01-003). + + Handles both inline objects and ID-only references stored in + ``case_participants``. + """ + for participant_ref in case_obj.case_participants: + # Try inline object first (participant embedded in snapshot) + if not isinstance(participant_ref, str): + roles = getattr(participant_ref, "case_roles", []) + if CVDRole.CASE_MANAGER in roles: + attributed = getattr(participant_ref, "attributed_to", None) + if attributed: + return str(attributed) + continue + + # ID-only reference — look up from DataLayer + participant = dl.read(participant_ref) + if participant is None: + continue + roles = getattr(participant, "case_roles", []) + if CVDRole.CASE_MANAGER in roles: + attributed = getattr(participant, "attributed_to", None) + if attributed: + return str(attributed) + return None + + +def _find_report_case_link( + creator_id: str, dl: CasePersistence +) -> VultronReportCaseLink | None: + """Return a pending ReportCaseLink expecting a bootstrap from *creator_id*. + + Scans all ``ReportCaseLink`` records and returns the first that has + ``trusted_case_creator_id == creator_id`` and ``case_id is None`` + (i.e. awaiting bootstrap). Using the sender identity rather than the + case's vulnerability_reports list makes the lookup independent of whether + the case snapshot embeds the report. + """ + for obj in dl.list_objects("ReportCaseLink"): + if isinstance(obj, VultronReportCaseLink): + if ( + obj.trusted_case_creator_id == creator_id + and obj.case_id is None + ): + return obj + return None + + +def _check_participant_embargo_acceptance( + case: CaseModel, dl: CasePersistence +) -> set[str]: + """Check which participants have not accepted the active embargo. + + Returns a set of actor IDs whose case updates should be withheld per + CM-10-004 (participants that have not accepted the active embargo). + """ + return find_excluded_actor_ids(case, dl) + + +def _store_embedded_participants( + case_obj: CaseModel, dl: CasePersistence, case_id: str +) -> None: + """Persist embedded participant objects from a case snapshot. + + When a bootstrapped or announced ``VulnerabilityCase`` carries fully + materialised participant objects (not just ID strings), each is stored + as an independent DataLayer record. This ensures BT nodes such as + ``CheckParticipantExists`` (#561) and ``AppendParticipantStatusNode`` + (#562, #566) can retrieve them by their UUID. + + Called from: + - ``CreateCaseReceivedUseCase._handle_bootstrap`` (Create path, CBT-05-005) + - ``AnnounceVulnerabilityCaseReceivedUseCase.execute`` (Announce path, #566) + + Idempotent: ``dl.save()`` upserts so repeated calls are safe. + + Args: + case_obj: The bootstrapped or announced case domain object. + dl: DataLayer to persist participants into. + case_id: ID of the case (for log context). + """ + participants = getattr(case_obj, "case_participants", []) or [] + for participant_ref in participants: + if isinstance(participant_ref, str): + continue + pid = getattr(participant_ref, "id_", None) + if pid is None: + continue + dl.save(participant_ref) + logger.info( + "store_embedded_participants: stored participant '%s'" + " for case '%s' (CBT-05-005, #566)", + pid, + case_id, + ) + + +def _ensure_reporter_participant( + dl: CasePersistence, + link: VultronReportCaseLink, + case_obj: CaseModel, + case_id: str, +) -> None: + """Ensure the reporter's participant record is at RM.ACCEPTED (#589, #624). + + When ``Create(VulnerabilityCase)`` carries participant IDs as bare + strings, ``_store_embedded_participants`` skips them. Without an + explicit participant record in the DataLayer, + ``SvcAddParticipantStatusUseCase._resolve_current_participant_state`` + falls back to ``RM.START``, causing the Vendor's Case Actor to reject the + subsequent ``Add(ParticipantStatus)`` as a backwards transition. + + The reporter submitted the original report, which implies they have + already ``RM.ACCEPTED`` from their own perspective. The reporter is + identified as ``attributed_to`` of the ``Offer(Report)`` activity + (``report.attributed_to``). Their ``START→RECEIVED→VALID→ACCEPTED`` arc + is hidden from the protocol — their first observable action already + implies ``RM.ACCEPTED`` (#624). + + This function: + + * Creates the participant record at ``RM.ACCEPTED`` if it is absent. + * Upgrades an existing participant from any state below ``RM.ACCEPTED`` + (e.g. ``RM.START`` seeded by the wire-layer default) to ``RM.ACCEPTED``. + * No-ops if the participant is already at or beyond ``RM.ACCEPTED``. + + This invariant applies **only** to the reporter/finder. All other + participants enter through a visible protocol interaction and their RM + lifecycle proceeds normally from ``RM.RECEIVED``. + + Args: + dl: The reporter's local DataLayer. + link: The ``VultronReportCaseLink`` associating the report to this + case bootstrap. + case_obj: The bootstrapped ``VulnerabilityCase`` snapshot. + case_id: ID of the case (for log context and status context). + """ + report = dl.read(link.report_id) + if report is None: + logger.warning( + "ensure_reporter_participant: report '%s' not found " + "— cannot seed reporter participant (#589)", + link.report_id, + ) + return + + reporter_actor_id = _as_id(getattr(report, "attributed_to", None)) + if not reporter_actor_id: + logger.warning( + "ensure_reporter_participant: report '%s' has no attributed_to " + "— cannot seed reporter participant (#589)", + link.report_id, + ) + return + + index = getattr(case_obj, "actor_participant_index", {}) or {} + participant_id = index.get(reporter_actor_id) + if not participant_id: + logger.warning( + "ensure_reporter_participant: reporter '%s' not in " + "actor_participant_index for case '%s' — skipping (#589)", + reporter_actor_id, + case_id, + ) + return + + existing = dl.read(participant_id) + if existing is not None: + statuses = getattr(existing, "participant_statuses", []) or [] + latest_rm = statuses[-1].rm_state if statuses else RM.START + if is_rm_at_least(latest_rm, RM.ACCEPTED): + logger.debug( + "ensure_reporter_participant: participant '%s' already " + "≥ RM.ACCEPTED — skipping (#589, #624)", + participant_id, + ) + return + _upgrade_participant_to_accepted( + dl, existing, participant_id, case_id, reporter_actor_id, latest_rm + ) + return + + status = ParticipantStatus( + rm_state=RM.ACCEPTED, + context=case_id, + attributed_to=reporter_actor_id, + ) + participant = VultronParticipant( + id_=participant_id, + attributed_to=reporter_actor_id, + context=case_id, + participant_statuses=[status], + ) + try: + dl.create(participant) + logger.info( + "ensure_reporter_participant: created participant '%s' for " + "reporter '%s' at RM.ACCEPTED (#589)", + participant_id, + reporter_actor_id, + ) + except ValueError: + logger.debug( + "ensure_reporter_participant: participant '%s' was concurrently " + "created — idempotent (#589)", + participant_id, + ) + + +def _upgrade_participant_to_accepted( + dl: CasePersistence, + existing: Any, + participant_id: str, + case_id: str, + reporter_actor_id: str, + latest_rm: "RM", +) -> None: + """Upgrade an existing participant record from below RM.ACCEPTED to RM.ACCEPTED. + + Saves the new status as an independent DataLayer record, then reads it back + via the vocabulary registry so the serialised type matches what the + participant container expects. This avoids wire/domain type mismatches when + appending to ``CaseParticipant.participant_statuses``. + """ + upgrade_status = ParticipantStatus( + rm_state=RM.ACCEPTED, + context=case_id, + attributed_to=reporter_actor_id, + ) + try: + dl.create(upgrade_status) + except ValueError: + dl.save(upgrade_status) + wire_status = dl.read(upgrade_status.id_) + participant_statuses = getattr(existing, "participant_statuses", None) + if participant_statuses is not None: + participant_statuses.append( + wire_status if wire_status is not None else upgrade_status + ) + dl.save(existing) + logger.info( + "ensure_reporter_participant: upgraded participant '%s' from " + "%s to RM.ACCEPTED (#589, #624)", + participant_id, + latest_rm, + ) diff --git a/vultron/core/use_cases/received/case/create.py b/vultron/core/use_cases/received/case/create.py new file mode 100644 index 000000000..e59590cfc --- /dev/null +++ b/vultron/core/use_cases/received/case/create.py @@ -0,0 +1,182 @@ +"""Use cases for vulnerability case activities.""" + +import logging + +from vultron.core.behaviors.bridge import BTBridge +from vultron.core.behaviors.case.nodes.participant import ( + EnsureReporterParticipantAtAcceptedNode, +) +from vultron.core.models.events.case import CreateCaseReceivedEvent +from vultron.core.models.protocols import CaseModel, is_case_model +from vultron.core.models.report_case_link import VultronReportCaseLink +from vultron.core.ports.case_persistence import CasePersistence + +from ._helpers import ( + _find_case_actor_id_from_participants, + _find_report_case_link, + _store_embedded_participants, +) + +logger = logging.getLogger(__name__) + + +class CreateCaseReceivedUseCase: + """Process a bootstrap ``Create(VulnerabilityCase)`` from a remote actor. + + Receiving this message means *someone else* created the case and is + notifying us. We do NOT create our own case infrastructure here. + + Bootstrap trust path (CBT-01-005 / CBT-01-006): + 1. Locate the ``VultronReportCaseLink`` for any report listed in the case. + 2. Validate that the sender matches ``link.trusted_case_creator_id``. + 3. Extract the ``CaseActor`` ID from the ``CASE_MANAGER`` participant. + 4. Seed a local replica of the case via the case-replica BT. + 5. Update the link with ``case_id`` and ``trusted_case_actor_id``. + """ + + def __init__( + self, dl: CasePersistence, request: CreateCaseReceivedEvent + ) -> None: + self._dl = dl + self._request: CreateCaseReceivedEvent = request + + def execute(self) -> None: + request = self._request + actor_id = request.actor_id + case_id = request.case_id + + if request.case is None: + logger.warning( + "create_case_received: no case domain object in event for " + "case '%s'", + case_id, + ) + return + + if case_id is None: + logger.warning( + "create_case_received: case_id missing in event — skipping" + ) + return + + case_obj_raw = request.case + if not is_case_model(case_obj_raw): + logger.warning( + "create_case_received: case object for case '%s' does not " + "satisfy CaseModel protocol — skipping", + case_id, + ) + return + case_obj: CaseModel = case_obj_raw + link = _find_report_case_link(actor_id, self._dl) + + if link is not None: + # Bootstrap trust path — CBT-01-005 / CBT-01-006 + self._handle_bootstrap(actor_id, case_id, case_obj, link) + else: + logger.info( + "create_case_received: no ReportCaseLink for case '%s' — " + "no bootstrap trust to record (not a known reporter)", + case_id, + ) + + def _handle_bootstrap( + self, + actor_id: str, + case_id: str, + case_obj: CaseModel, + link: VultronReportCaseLink, + ) -> None: + """Validate trust and seed the case replica.""" + # CBT-01-005: sender must match the actor we sent the report to + if link.trusted_case_creator_id is not None: + if actor_id != link.trusted_case_creator_id: + logger.warning( + "create_case_received: bootstrap rejected for case '%s' — " + "sender does not match trusted case creator " + "(CBT-01-005)", + case_id, + ) + return + else: + logger.warning( + "create_case_received: no trusted_case_creator_id in link " + "for case '%s'; accepting bootstrap unchecked", + case_id, + ) + + # CBT-01-003: extract CaseActor from CASE_MANAGER participant + case_actor_id = _find_case_actor_id_from_participants( + case_obj, self._dl + ) + if case_actor_id is None: + logger.warning( + "create_case_received: no CASE_MANAGER participant found in " + "bootstrap snapshot for case '%s'; Announce validation will " + "be bypassed", + case_id, + ) + + logger.info( + "create_case_received: bootstrap accepted for case '%s' from " + "'%s'; seeding replica (CBT-01-006)", + case_id, + actor_id, + ) + + # Seed the local case replica + from vultron.core.models.protocols import is_case_model + + # Idempotency guard (CBT-01-006, ID-04-004) + existing = self._dl.read(case_id) + if is_case_model(existing): + logger.info( + "create_case_received: case '%s' already exists as replica " + "— skipping re-seed", + case_id, + ) + else: + try: + self._dl.create(case_obj) + logger.info( + "create_case_received: replica case '%s' persisted", + case_id, + ) + except ValueError: + logger.info( + "create_case_received: case '%s' persisted concurrently " + "— idempotent", + case_id, + ) + + # CBT-01-006: persist trust anchors in the link + link.case_id = case_id + link.trusted_case_actor_id = case_actor_id + self._dl.save(link) + logger.info( + "create_case_received: ReportCaseLink updated with case_id='%s' " + "and trusted_case_actor_id='%s' (CBT-01-006)", + case_id, + case_actor_id, + ) + + # CBT-05-005: store any embedded participant objects so that BT nodes + # (``CheckParticipantExists``, ``AppendParticipantStatusNode``) can + # find them by UUID via ``datalayer.read(participant_id)``. + # This must happen regardless of the idempotency guard above because + # the inbox router may have already seeded the case before dispatch. + _store_embedded_participants(case_obj, self._dl, case_id) + + # #589: when participants arrive as bare string IDs (the common case), + # _store_embedded_participants cannot create records for them. Ensure + # the reporter's own participant is seeded at RM.ACCEPTED — inferred + # from the fact that they submitted a report. This is a protocol- + # significant RM state transition, so it runs via BTBridge (BT-15-001). + BTBridge(datalayer=self._dl).execute_with_setup( + tree=EnsureReporterParticipantAtAcceptedNode( + link=link, + case_obj=case_obj, + case_id=case_id, + ), + actor_id=actor_id, + ) diff --git a/vultron/core/use_cases/received/case/engage_defer.py b/vultron/core/use_cases/received/case/engage_defer.py new file mode 100644 index 000000000..a7b6ce642 --- /dev/null +++ b/vultron/core/use_cases/received/case/engage_defer.py @@ -0,0 +1,122 @@ +"""Use cases for vulnerability case activities.""" + +import logging +from typing import TYPE_CHECKING + +from py_trees.common import Status + +from vultron.core.models.events.case import ( + DeferCaseReceivedEvent, + EngageCaseReceivedEvent, +) +from vultron.core.models.protocols import is_case_model +from vultron.core.ports.case_persistence import CasePersistence + +from ._helpers import _store_embedded_participants + +if TYPE_CHECKING: + from vultron.core.ports.trigger_activity import TriggerActivityPort + +logger = logging.getLogger(__name__) + + +class EngageCaseReceivedUseCase: + def __init__( + self, + dl: CasePersistence, + request: EngageCaseReceivedEvent, + trigger_activity: "TriggerActivityPort | None" = None, + ) -> None: + self._dl = dl + self._request: EngageCaseReceivedEvent = request + self._trigger_activity = trigger_activity + + def execute(self) -> None: + request = self._request + from vultron.core.behaviors.bridge import BTBridge + from vultron.core.behaviors.report.prioritize_tree import ( + create_engage_case_tree, + ) + + actor_id = request.actor_id + case_id = request.case_id + if case_id is None: + logger.warning("engage_case: missing case_id on request") + return + + # Persist any inline participant objects carried in the case snapshot + # so BT nodes (CheckParticipantExists, AppendParticipantStatusNode) can + # locate them by UUID. Mirrors the Create (#564) and Announce (#566) + # paths (CBT-05-005, fixes #573). + case_obj = request.case + if is_case_model(case_obj): + _store_embedded_participants(case_obj, self._dl, case_id) + + logger.info( + "Actor '%s' engages case '%s' (RM → ACCEPTED)", + actor_id, + case_id, + ) + + bridge = BTBridge( + datalayer=self._dl, trigger_activity=self._trigger_activity + ) + tree = create_engage_case_tree(case_id=case_id, actor_id=actor_id) + result = bridge.execute_with_setup( + tree=tree, actor_id=actor_id, activity=request + ) + + if result.status != Status.SUCCESS: + logger.warning( + "EngageCaseBT did not succeed for actor '%s' / case '%s': %s", + actor_id, + case_id, + BTBridge.get_failure_reason(tree), + ) + + +class DeferCaseReceivedUseCase: + def __init__( + self, + dl: CasePersistence, + request: DeferCaseReceivedEvent, + trigger_activity: "TriggerActivityPort | None" = None, + ) -> None: + self._dl = dl + self._request: DeferCaseReceivedEvent = request + self._trigger_activity = trigger_activity + + def execute(self) -> None: + request = self._request + from vultron.core.behaviors.bridge import BTBridge + from vultron.core.behaviors.report.prioritize_tree import ( + create_defer_case_tree, + ) + + actor_id = request.actor_id + case_id = request.case_id + if case_id is None: + logger.warning("defer_case: missing case_id on request") + return + + logger.info( + "Actor '%s' defers case '%s' (RM → DEFERRED)", + actor_id, + case_id, + ) + + bridge = BTBridge( + datalayer=self._dl, trigger_activity=self._trigger_activity + ) + tree = create_defer_case_tree(case_id=case_id, actor_id=actor_id) + result = bridge.execute_with_setup( + tree=tree, actor_id=actor_id, activity=request + ) + + if result.status != Status.SUCCESS: + logger.warning( + "DeferCaseBT did not succeed for actor '%s' / case '%s': %s", + actor_id, + case_id, + BTBridge.get_failure_reason(tree), + ) diff --git a/vultron/core/use_cases/received/case/lifecycle.py b/vultron/core/use_cases/received/case/lifecycle.py new file mode 100644 index 000000000..fb2bbd4c7 --- /dev/null +++ b/vultron/core/use_cases/received/case/lifecycle.py @@ -0,0 +1,94 @@ +"""Use cases for vulnerability case activities.""" + +import logging +from typing import Any, cast + +from vultron.core.models.events.case import ( + AddReportToCaseReceivedEvent, + CloseCaseReceivedEvent, +) +from vultron.core.models.protocols import is_case_model +from vultron.core.models.vultron_types import VultronActivity +from vultron.core.ports.case_persistence import ( + CaseOutboxPersistence, + CasePersistence, +) +from vultron.core.use_cases._helpers import _as_id + +logger = logging.getLogger(__name__) + + +class AddReportToCaseReceivedUseCase: + def __init__( + self, dl: CasePersistence, request: AddReportToCaseReceivedEvent + ) -> None: + self._dl = dl + self._request: AddReportToCaseReceivedEvent = request + + def execute(self) -> None: + request = self._request + report_id = request.report_id + case_id = request.case_id + if report_id is None or case_id is None: + logger.warning("add_report_to_case: missing report_id or case_id") + return + case = self._dl.read(case_id) + + if not is_case_model(case): + logger.warning("add_report_to_case: case '%s' not found", case_id) + return + + existing_report_ids = [_as_id(r) for r in case.vulnerability_reports] + if report_id in existing_report_ids: + logger.info( + "Report '%s' already in case '%s' — skipping (idempotent)", + report_id, + case_id, + ) + return + + case.vulnerability_reports.append(report_id) + self._dl.save(case) + logger.info("Added report '%s' to case '%s'", report_id, case_id) + + +class CloseCaseReceivedUseCase: + def __init__( + self, dl: CaseOutboxPersistence, request: CloseCaseReceivedEvent + ) -> None: + self._dl = dl + self._request: CloseCaseReceivedEvent = request + + def execute(self) -> None: + request = self._request + actor_id = request.actor_id + case_id = request.case_id + + logger.info("Actor '%s' is closing case '%s'", actor_id, case_id) + + close_activity = VultronActivity( + type_="Leave", + actor=actor_id, + object_=case_id, + ) + try: + self._dl.create(close_activity) + logger.info("Created Leave activity %s", close_activity.id_) + except ValueError: + logger.info( + "Leave activity for case '%s' already exists — skipping (idempotent)", + case_id, + ) + return + + actor_obj = self._dl.read(actor_id) + if actor_obj is not None and hasattr(actor_obj, "outbox"): + cast(Any, actor_obj).outbox.items.append(close_activity.id_) + self._dl.save(actor_obj) + logger.info( + "Added Leave activity %s to actor %s outbox", + close_activity.id_, + actor_id, + ) + # Queue for delivery via outbox_handler regardless of outbox field + self._dl.record_outbox_item(actor_id, close_activity.id_) diff --git a/vultron/core/use_cases/received/case/update.py b/vultron/core/use_cases/received/case/update.py new file mode 100644 index 000000000..4087118c4 --- /dev/null +++ b/vultron/core/use_cases/received/case/update.py @@ -0,0 +1,67 @@ +"""Use cases for vulnerability case activities.""" + +import logging + +from vultron.core.behaviors.case.update_support import broadcast_case_update +from vultron.core.models.events.case import UpdateCaseReceivedEvent +from vultron.core.models.protocols import CaseModel +from vultron.core.ports.case_persistence import CaseOutboxPersistence + +logger = logging.getLogger(__name__) + + +class UpdateCaseReceivedUseCase: + def __init__( + self, dl: CaseOutboxPersistence, request: UpdateCaseReceivedEvent + ) -> None: + self._dl = dl + self._request: UpdateCaseReceivedEvent = request + + def execute(self) -> None: + request = self._request + actor_id = request.actor_id + case_id = request.case_id + if case_id is None: + logger.warning("update_case: missing case_id on request") + return + + from py_trees.common import Status + + from vultron.core.behaviors.bridge import BTBridge + from vultron.core.behaviors.case.update_tree import ( + create_update_case_received_tree, + ) + + tree = create_update_case_received_tree( + case_id=case_id, + actor_id=actor_id, + request=request, + ) + bridge = BTBridge(datalayer=self._dl) + result = bridge.execute_with_setup( + tree=tree, + actor_id=actor_id, + activity=request, + ) + if result.status != Status.SUCCESS: + logger.warning( + "UpdateCaseBT did not succeed for actor '%s' / case '%s': %s", + actor_id, + case_id, + BTBridge.get_failure_reason(tree), + ) + + def _broadcast_case_update( + self, + case_id: str, + case: CaseModel, + excluded_actor_ids: set[str] | None = None, + ) -> None: + """Broadcast an Announce activity for the updated case to participants. + + Implements CM-06-001/CM-06-002: after a case update, the CaseActor MUST + send an ActivityStreams Announce to each active case participant's inbox. + Per CM-10-004, participants who have not accepted the active embargo are + excluded from the broadcast. + """ + broadcast_case_update(self._dl, case_id, case, excluded_actor_ids) diff --git a/vultron/core/use_cases/received/case/validate.py b/vultron/core/use_cases/received/case/validate.py new file mode 100644 index 000000000..9b8b2c4a1 --- /dev/null +++ b/vultron/core/use_cases/received/case/validate.py @@ -0,0 +1,137 @@ +"""Use cases for vulnerability case activities.""" + +import logging + +from py_trees.common import Status + +from vultron.core.ports.case_persistence import CasePersistence +from vultron.core.states.rm import RM +from vultron.core.use_cases._helpers import update_participant_rm_state + +logger = logging.getLogger(__name__) + + +class InvalidateCaseUseCase: + """Transition the actor's RM state to INVALID within the given case. + + Called by ``InvalidateReportReceivedUseCase`` after dereferencing + report_id to case_id (CM-12-005). + """ + + def __init__( + self, dl: CasePersistence, case_id: str, actor_id: str + ) -> None: + self._dl = dl + self._case_id = case_id + self._actor_id = actor_id + + def execute(self) -> None: + success = update_participant_rm_state( + self._case_id, self._actor_id, RM.INVALID, self._dl + ) + if success: + logger.info( + "RM → INVALID for actor '%s' in case '%s'", + self._actor_id, + self._case_id, + ) + else: + logger.warning( + "Failed to set RM.INVALID for actor '%s' in case '%s'", + self._actor_id, + self._case_id, + ) + + +class CloseCaseUseCase: + """Transition the actor's RM state to CLOSED within the given case. + + Called by ``CloseReportReceivedUseCase`` after dereferencing + report_id to case_id (CM-12-005). + """ + + def __init__( + self, dl: CasePersistence, case_id: str, actor_id: str + ) -> None: + self._dl = dl + self._case_id = case_id + self._actor_id = actor_id + + def execute(self) -> None: + success = update_participant_rm_state( + self._case_id, self._actor_id, RM.CLOSED, self._dl + ) + if success: + logger.info( + "RM → CLOSED for actor '%s' in case '%s'", + self._actor_id, + self._case_id, + ) + else: + logger.warning( + "Failed to set RM.CLOSED for actor '%s' in case '%s'", + self._actor_id, + self._case_id, + ) + + +class ValidateCaseUseCase: + """Run the validate-report behavior tree for the given case. + + Called by ``ValidateReportReceivedUseCase`` after dereferencing + report_id to case_id (CM-12-005). + + Advances RM to VALID only. The engage/defer decision (RM → ACCEPTED + or RM → DEFERRED) is a distinct, explicit protocol step driven by a + separate ``engage-case`` or ``defer-case`` trigger. + """ + + def __init__( + self, + dl: CasePersistence, + actor_id: str, + report_id: str, + offer_id: str, + ) -> None: + self._dl = dl + self._actor_id = actor_id + self._report_id = report_id + self._offer_id = offer_id + + def execute(self) -> None: + from vultron.core.behaviors.bridge import BTBridge + from vultron.core.behaviors.report.validate_tree import ( + create_validate_report_tree, + ) + + logger.info( + "Actor '%s' validates VulnerabilityReport '%s' via BT", + self._actor_id, + self._report_id, + ) + + bridge = BTBridge(datalayer=self._dl) + tree = create_validate_report_tree( + report_id=self._report_id, + offer_id=self._offer_id, + ) + result = bridge.execute_with_setup(tree, actor_id=self._actor_id) + + if result.status == Status.SUCCESS: + logger.info( + "✓ BT validation succeeded for report: %s", self._report_id + ) + elif result.status == Status.FAILURE: + logger.error( + "✗ BT validation failed for report: %s — %s", + self._report_id, + result.feedback_message, + ) + for err in result.errors or []: + logger.error(" - %s", err) + else: + logger.warning( + "⚠ BT validation incomplete for report: %s (status=%s)", + self._report_id, + result.status, + )