Skip to content

Commit 190fb9b

Browse files
Merge pull request #1140 from CERTCC/task/1087-propose-case-actor-node
feat: implement ProposeCaseToActorNode and wire into case-creation trees (#1087)
2 parents ef862ea + dcab66c commit 190fb9b

15 files changed

Lines changed: 1116 additions & 9 deletions

File tree

test/core/behaviors/case/nodes/test_case_setup.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,3 +433,217 @@ def test_fails_when_case_not_in_datalayer(
433433
case_actor_participant_id=f"{actor_id}/case-actor/participant",
434434
)
435435
assert result.status == py_trees.common.Status.FAILURE
436+
437+
438+
# ---------------------------------------------------------------------------
439+
# ProposeCaseToActorNode tests
440+
# ---------------------------------------------------------------------------
441+
442+
443+
class TestProposeCaseToActorNode:
444+
"""ProposeCaseToActorNode sends Create(as_CaseProposal) to the case-actor."""
445+
446+
from vultron.core.behaviors.case.nodes.actor import (
447+
ProposeCaseToActorNode,
448+
)
449+
450+
CASE_ACTOR_ID = "https://example.org/actors/case-actor-service"
451+
452+
def test_succeeds_and_queues_proposal_to_outbox(
453+
self,
454+
bt_scenario: BTTestScenario,
455+
actor: VultronCaseActor,
456+
actor_id: str,
457+
report: VultronReport,
458+
case_obj: VultronCase,
459+
) -> None:
460+
"""Happy path: node returns SUCCESS and enqueues a Create activity."""
461+
from vultron.core.behaviors.case.nodes.actor import (
462+
ProposeCaseToActorNode,
463+
)
464+
465+
outbox_before = list(
466+
bt_scenario.dl.outbox_list_for_actor(actor_id) or []
467+
)
468+
result = bt_scenario.run(
469+
ProposeCaseToActorNode(),
470+
actor_id=actor_id,
471+
case_id=case_obj.id_,
472+
case_actor_id=self.CASE_ACTOR_ID,
473+
)
474+
bt_scenario.assert_success(result)
475+
476+
outbox_after = list(
477+
bt_scenario.dl.outbox_list_for_actor(actor_id) or []
478+
)
479+
assert len(outbox_after) > len(
480+
outbox_before
481+
), "ProposeCaseToActorNode must enqueue an activity to the outbox"
482+
483+
def test_persists_create_activity_in_datalayer(
484+
self,
485+
bt_scenario: BTTestScenario,
486+
actor: VultronCaseActor,
487+
actor_id: str,
488+
report: VultronReport,
489+
case_obj: VultronCase,
490+
) -> None:
491+
"""Create(as_CaseProposal) activity is persisted to the DataLayer."""
492+
from vultron.core.behaviors.case.nodes.actor import (
493+
ProposeCaseToActorNode,
494+
)
495+
496+
create_activities_before = bt_scenario.dl.list_objects("Create")
497+
bt_scenario.run(
498+
ProposeCaseToActorNode(),
499+
actor_id=actor_id,
500+
case_id=case_obj.id_,
501+
case_actor_id=self.CASE_ACTOR_ID,
502+
)
503+
504+
create_activities_after = bt_scenario.dl.list_objects("Create")
505+
assert len(create_activities_after) > len(
506+
create_activities_before
507+
), "At least one new Create activity should be in the DataLayer"
508+
509+
def test_fails_without_case_id(
510+
self,
511+
bt_scenario: BTTestScenario,
512+
actor: VultronCaseActor,
513+
actor_id: str,
514+
) -> None:
515+
"""Node returns FAILURE when case_id is missing from the blackboard."""
516+
from vultron.core.behaviors.case.nodes.actor import (
517+
ProposeCaseToActorNode,
518+
)
519+
520+
result = bt_scenario.run(
521+
ProposeCaseToActorNode(),
522+
actor_id=actor_id,
523+
case_actor_id=self.CASE_ACTOR_ID,
524+
# case_id intentionally omitted
525+
)
526+
bt_scenario.assert_failure(result)
527+
528+
def test_fails_without_case_actor_id(
529+
self,
530+
bt_scenario: BTTestScenario,
531+
actor: VultronCaseActor,
532+
actor_id: str,
533+
case_obj: VultronCase,
534+
) -> None:
535+
"""Node returns FAILURE when case_actor_id is missing from the blackboard."""
536+
from vultron.core.behaviors.case.nodes.actor import (
537+
ProposeCaseToActorNode,
538+
)
539+
540+
result = bt_scenario.run(
541+
ProposeCaseToActorNode(),
542+
actor_id=actor_id,
543+
case_id=case_obj.id_,
544+
# case_actor_id intentionally omitted
545+
)
546+
bt_scenario.assert_failure(result)
547+
548+
def test_fails_when_case_has_no_reports(
549+
self,
550+
bt_scenario: BTTestScenario,
551+
actor: VultronCaseActor,
552+
actor_id: str,
553+
) -> None:
554+
"""Node returns FAILURE when the case has no linked VulnerabilityReport."""
555+
from vultron.core.behaviors.case.nodes.actor import (
556+
ProposeCaseToActorNode,
557+
)
558+
559+
empty_case = VultronCase(
560+
id_="https://example.org/cases/empty-case",
561+
name="Empty Case",
562+
vulnerability_reports=[],
563+
)
564+
bt_scenario.dl.create(empty_case)
565+
566+
result = bt_scenario.run(
567+
ProposeCaseToActorNode(),
568+
actor_id=actor_id,
569+
case_id=empty_case.id_,
570+
case_actor_id=self.CASE_ACTOR_ID,
571+
)
572+
bt_scenario.assert_failure(result)
573+
574+
def test_fails_when_report_not_in_datalayer(
575+
self,
576+
bt_scenario: BTTestScenario,
577+
actor: VultronCaseActor,
578+
actor_id: str,
579+
) -> None:
580+
"""Node returns FAILURE when the linked report is absent from DataLayer."""
581+
from vultron.core.behaviors.case.nodes.actor import (
582+
ProposeCaseToActorNode,
583+
)
584+
585+
dangling_case = VultronCase(
586+
id_="https://example.org/cases/dangling",
587+
name="Dangling Case",
588+
vulnerability_reports=["https://example.org/reports/ghost"],
589+
)
590+
bt_scenario.dl.create(dangling_case)
591+
592+
result = bt_scenario.run(
593+
ProposeCaseToActorNode(),
594+
actor_id=actor_id,
595+
case_id=dangling_case.id_,
596+
case_actor_id=self.CASE_ACTOR_ID,
597+
)
598+
bt_scenario.assert_failure(result)
599+
600+
def test_fails_when_case_not_in_datalayer(
601+
self,
602+
bt_scenario: BTTestScenario,
603+
actor: VultronCaseActor,
604+
actor_id: str,
605+
) -> None:
606+
"""Node returns FAILURE when the case record is absent from DataLayer."""
607+
from vultron.core.behaviors.case.nodes.actor import (
608+
ProposeCaseToActorNode,
609+
)
610+
611+
result = bt_scenario.run(
612+
ProposeCaseToActorNode(),
613+
actor_id=actor_id,
614+
case_id="https://example.org/cases/nonexistent",
615+
case_actor_id=self.CASE_ACTOR_ID,
616+
)
617+
bt_scenario.assert_failure(result)
618+
619+
def test_fails_when_no_trigger_activity_factory(
620+
self,
621+
actor: VultronCaseActor,
622+
actor_id: str,
623+
report: VultronReport,
624+
case_obj: VultronCase,
625+
) -> None:
626+
"""Node returns FAILURE when trigger_activity_factory is absent."""
627+
import py_trees
628+
629+
from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer
630+
from vultron.core.behaviors.bridge import BTBridge
631+
from vultron.core.behaviors.case.nodes.actor import (
632+
ProposeCaseToActorNode,
633+
)
634+
635+
# Build a bridge with NO trigger_activity_factory injected.
636+
dl = SqliteDataLayer("sqlite:///:memory:")
637+
dl.create(actor)
638+
dl.create(report)
639+
dl.create(case_obj)
640+
bridge_no_factory = BTBridge(datalayer=dl)
641+
642+
py_trees.blackboard.Blackboard.storage.clear()
643+
result = bridge_no_factory.execute_with_setup(
644+
tree=ProposeCaseToActorNode(),
645+
actor_id=actor_id,
646+
case_id=case_obj.id_,
647+
case_actor_id=self.CASE_ACTOR_ID,
648+
)
649+
assert result.status == py_trees.common.Status.FAILURE

test/core/behaviors/case/test_create_tree.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
from py_trees.common import Status
2828

2929
from vultron.adapters.driven.datalayer_sqlite import SqliteDataLayer
30+
from vultron.adapters.driven.trigger_activity_adapter import (
31+
TriggerActivityAdapter,
32+
)
3033
from vultron.core.models.activity import VultronActivity
3134
from vultron.core.models.vultron_types import (
3235
VultronCase,
@@ -86,7 +89,12 @@ def create_case_activity(case_obj, actor_id):
8689

8790
@pytest.fixture
8891
def bridge(datalayer):
89-
return BTBridge(datalayer=datalayer)
92+
# ProposeCaseToActorNode requires trigger_activity_factory; inject it here
93+
# so all tree-execution tests exercise the full node sequence.
94+
return BTBridge(
95+
datalayer=datalayer,
96+
trigger_activity=TriggerActivityAdapter(datalayer),
97+
)
9098

9199

92100
# ============================================================================
@@ -116,6 +124,32 @@ def test_create_case_tree_second_child_is_sequence(case_obj, actor_id):
116124
assert isinstance(tree.children[1], py_trees.composites.Sequence)
117125

118126

127+
def test_propose_case_to_actor_node_wired_after_create_actor_node(
128+
case_obj, actor_id
129+
):
130+
"""ProposeCaseToActorNode appears immediately after CreateCaseActorNode (CP-04-002)."""
131+
from vultron.core.behaviors.case.nodes.actor import ProposeCaseToActorNode
132+
from vultron.core.behaviors.case.case_setup_tree import CreateCaseActorNode
133+
134+
tree = create_create_case_tree(case_obj=case_obj, actor_id=actor_id)
135+
effect_seq = tree.children[1]
136+
node_types = [type(c) for c in effect_seq.children]
137+
138+
assert (
139+
ProposeCaseToActorNode in node_types
140+
), "ProposeCaseToActorNode must be present in create_create_case_tree"
141+
propose_idx = node_types.index(ProposeCaseToActorNode)
142+
create_actor_idx = next(
143+
i
144+
for i, c in enumerate(effect_seq.children)
145+
if isinstance(c, CreateCaseActorNode)
146+
)
147+
assert propose_idx == create_actor_idx + 1, (
148+
"ProposeCaseToActorNode must appear immediately after "
149+
"CreateCaseActorNode (CP-04-002)"
150+
)
151+
152+
119153
# ============================================================================
120154
# Execution tests
121155
# ============================================================================

test/core/behaviors/case/test_receive_report_case_tree.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,44 @@ def test_tree_second_child_is_sequence(
9797
def test_tree_flow_has_ten_children(
9898
self, report, offer, reporter_actor_id
9999
):
100-
"""ReceiveReportCaseFlow sequence has exactly 9 action nodes."""
100+
"""ReceiveReportCaseFlow sequence has exactly 10 action nodes."""
101101
tree = create_receive_report_case_tree(
102102
report_id=report.id_,
103103
offer_id=offer.id_,
104104
reporter_actor_id=reporter_actor_id,
105105
)
106106
flow = tree.children[1]
107-
assert len(flow.children) == 9
107+
assert len(flow.children) == 10
108+
109+
def test_propose_case_to_actor_node_is_wired(
110+
self, report, offer, reporter_actor_id
111+
):
112+
"""ProposeCaseToActorNode appears after CreateCaseActorNode in the flow."""
113+
from vultron.core.behaviors.case.nodes.actor import (
114+
ProposeCaseToActorNode,
115+
)
116+
from vultron.core.behaviors.case.case_setup_tree import (
117+
CreateCaseActorNode,
118+
)
119+
120+
tree = create_receive_report_case_tree(
121+
report_id=report.id_,
122+
offer_id=offer.id_,
123+
reporter_actor_id=reporter_actor_id,
124+
)
125+
flow = tree.children[1]
126+
node_types = [type(c) for c in flow.children]
127+
assert ProposeCaseToActorNode in node_types
128+
propose_idx = node_types.index(ProposeCaseToActorNode)
129+
create_actor_idx = next(
130+
i
131+
for i, c in enumerate(flow.children)
132+
if isinstance(c, CreateCaseActorNode)
133+
)
134+
assert propose_idx == create_actor_idx + 1, (
135+
"ProposeCaseToActorNode must appear immediately after "
136+
"CreateCaseActorNode"
137+
)
108138

109139

110140
# ============================================================================

test/core/use_cases/received/test_case_proposal.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
)
3838
from vultron.wire.as2.vocab.examples._base import gen_report
3939
from vultron.wire.as2.vocab.objects.case_proposal import as_CaseProposal
40+
from vultron.wire.as2.vocab.objects.vulnerability_report import (
41+
VulnerabilityReport,
42+
)
4043

4144
_CASE_ACTOR_URI = "https://example.org/case-actors/svc-1"
4245
_VENDOR_URI = "https://example.org/vendors/acme"
@@ -134,7 +137,9 @@ def test_execute_report_linked_to_case(self, make_payload):
134137
case_obj = dl.read(case_rows[0].id_)
135138
assert is_case_model(case_obj)
136139

137-
report_id = proposal.object_.id_
140+
report_obj = proposal.object_
141+
assert isinstance(report_obj, VulnerabilityReport)
142+
report_id = report_obj.id_
138143
assert (
139144
report_id in case_obj.vulnerability_reports
140145
), f"Report '{report_id}' not linked to case"
@@ -200,6 +205,9 @@ def test_execute_records_case_actor_uri(self, make_payload):
200205
"""accept_case_proposal_received updates VultronReportCaseLink.trusted_case_actor_id."""
201206
dl = SqliteDataLayer("sqlite:///:memory:")
202207
proposal = _make_proposal()
208+
assert isinstance(
209+
proposal.object_, VulnerabilityReport
210+
), "_make_proposal() must embed a full VulnerabilityReport"
203211
report_id = proposal.object_.id_
204212

205213
# Seed a VultronReportCaseLink so the use case can find it

0 commit comments

Comments
 (0)