feat: add Campaign/Compliance domain entities and extract users/audit/data_sources to services (LP-2 through LP-6)
This commit is contained in:
175
backend/tests/test_campaign_entity.py
Normal file
175
backend/tests/test_campaign_entity.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Tests for CampaignEntity — pure domain logic, no DB."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import uuid
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if backend_dir not in sys.path:
|
||||
sys.path.insert(0, backend_dir)
|
||||
|
||||
from app.domain.entities.campaign import (
|
||||
CampaignEntity,
|
||||
CampaignStatus,
|
||||
CampaignType,
|
||||
)
|
||||
from app.domain.errors import BusinessRuleViolation, InvalidStateTransition
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _entity(status: str = "draft", test_count: int = 0, **overrides) -> CampaignEntity:
|
||||
defaults = dict(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Campaign",
|
||||
type=CampaignType.custom,
|
||||
status=CampaignStatus(status),
|
||||
description=None,
|
||||
threat_actor_id=None,
|
||||
created_by=None,
|
||||
target_platform=None,
|
||||
tags=[],
|
||||
test_count=test_count,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return CampaignEntity(**defaults)
|
||||
|
||||
|
||||
def _fake_orm(status: str = "draft", test_count: int = 0, **overrides) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.id = uuid.uuid4()
|
||||
m.name = "Test Campaign"
|
||||
m.type = "custom"
|
||||
m.status = status
|
||||
m.description = None
|
||||
m.threat_actor_id = None
|
||||
m.created_by = None
|
||||
m.target_platform = None
|
||||
m.tags = []
|
||||
m.campaign_tests = [MagicMock()] * test_count if test_count else []
|
||||
for k, v in overrides.items():
|
||||
setattr(m, k, v)
|
||||
return m
|
||||
|
||||
|
||||
# ── 1. Test activation from draft with tests → success ───────────────
|
||||
|
||||
|
||||
def test_activate_from_draft_with_tests_success():
|
||||
e = _entity("draft", test_count=1)
|
||||
e.activate()
|
||||
assert e.status == CampaignStatus.active
|
||||
|
||||
|
||||
def test_activate_from_draft_with_multiple_tests_success():
|
||||
e = _entity("draft", test_count=3)
|
||||
e.activate()
|
||||
assert e.status == CampaignStatus.active
|
||||
|
||||
|
||||
# ── 2. Test activation from draft with 0 tests → BusinessRuleViolation ───
|
||||
|
||||
|
||||
def test_activate_from_draft_with_zero_tests_raises():
|
||||
e = _entity("draft", test_count=0)
|
||||
with pytest.raises(BusinessRuleViolation, match="at least one test"):
|
||||
e.activate()
|
||||
assert e.status == CampaignStatus.draft
|
||||
|
||||
|
||||
# ── 3. Test activation from active → InvalidStateTransition ────────────
|
||||
|
||||
|
||||
def test_activate_from_active_raises():
|
||||
e = _entity("active", test_count=2)
|
||||
with pytest.raises(InvalidStateTransition) as exc_info:
|
||||
e.activate()
|
||||
assert exc_info.value.current_state == "active"
|
||||
assert exc_info.value.target_state == "active"
|
||||
assert "completed" in exc_info.value.valid_transitions
|
||||
|
||||
|
||||
# ── 4. Test complete from active → success ──────────────────────────────
|
||||
|
||||
|
||||
def test_complete_from_active_success():
|
||||
e = _entity("active", test_count=2)
|
||||
e.complete()
|
||||
assert e.status == CampaignStatus.completed
|
||||
|
||||
|
||||
# ── 5. Test complete from draft → InvalidStateTransition ────────────────
|
||||
|
||||
|
||||
def test_complete_from_draft_raises():
|
||||
e = _entity("draft", test_count=1)
|
||||
with pytest.raises(InvalidStateTransition) as exc_info:
|
||||
e.complete()
|
||||
assert exc_info.value.current_state == "draft"
|
||||
assert exc_info.value.target_state == "completed"
|
||||
assert "active" in exc_info.value.valid_transitions
|
||||
|
||||
|
||||
# ── 6. Test ensure_modifiable in draft/active → ok ──────────────────────
|
||||
|
||||
|
||||
def test_ensure_modifiable_draft_ok():
|
||||
e = _entity("draft")
|
||||
e.ensure_modifiable() # no raise
|
||||
|
||||
|
||||
def test_ensure_modifiable_active_ok():
|
||||
e = _entity("active", test_count=1)
|
||||
e.ensure_modifiable() # no raise
|
||||
|
||||
|
||||
# ── 7. Test ensure_modifiable in completed → BusinessRuleViolation ──────
|
||||
|
||||
|
||||
def test_ensure_modifiable_completed_raises():
|
||||
e = _entity("completed", test_count=1)
|
||||
with pytest.raises(BusinessRuleViolation, match="Cannot modify"):
|
||||
e.ensure_modifiable()
|
||||
|
||||
|
||||
def test_ensure_modifiable_archived_raises():
|
||||
e = _entity("archived", test_count=1)
|
||||
with pytest.raises(BusinessRuleViolation, match="Cannot modify"):
|
||||
e.ensure_modifiable()
|
||||
|
||||
|
||||
# ── 8. Test from_orm conversion ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_from_orm_basic():
|
||||
orm = _fake_orm("draft", test_count=0)
|
||||
e = CampaignEntity.from_orm(orm)
|
||||
assert e.name == "Test Campaign"
|
||||
assert e.type == CampaignType.custom
|
||||
assert e.status == CampaignStatus.draft
|
||||
assert e.id == orm.id
|
||||
assert e.test_count == 0
|
||||
|
||||
|
||||
def test_from_orm_with_tests():
|
||||
orm = _fake_orm("draft", test_count=3)
|
||||
e = CampaignEntity.from_orm(orm)
|
||||
assert e.test_count == 3
|
||||
|
||||
|
||||
def test_from_orm_coerces_type_and_status():
|
||||
orm = _fake_orm(status="active", type="apt_emulation", test_count=1)
|
||||
e = CampaignEntity.from_orm(orm)
|
||||
assert e.status == CampaignStatus.active
|
||||
assert e.type == CampaignType.apt_emulation
|
||||
|
||||
|
||||
def test_from_orm_handles_none_tags():
|
||||
orm = _fake_orm("draft", test_count=0)
|
||||
orm.tags = None
|
||||
e = CampaignEntity.from_orm(orm)
|
||||
assert e.tags == []
|
||||
105
backend/tests/test_compliance_entity.py
Normal file
105
backend/tests/test_compliance_entity.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Tests for compliance domain entities."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.entities.compliance import (
|
||||
ComplianceControlEntity,
|
||||
ComplianceFrameworkEntity,
|
||||
ControlCoverageStatus,
|
||||
)
|
||||
|
||||
|
||||
# ── Control coverage status ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_control_all_techniques_validated_covered():
|
||||
"""All techniques validated → covered."""
|
||||
control = ComplianceControlEntity(
|
||||
control_id="AC-2",
|
||||
title="Account Management",
|
||||
technique_statuses=["validated", "validated"],
|
||||
)
|
||||
assert control.coverage_status == ControlCoverageStatus.covered
|
||||
|
||||
|
||||
def test_control_all_techniques_partial_covered():
|
||||
"""All techniques partial → covered."""
|
||||
control = ComplianceControlEntity(
|
||||
control_id="AC-2",
|
||||
title="Account Management",
|
||||
technique_statuses=["partial"],
|
||||
)
|
||||
assert control.coverage_status == ControlCoverageStatus.covered
|
||||
|
||||
|
||||
def test_control_mixed_statuses_partially_covered():
|
||||
"""Mixed statuses (some validated/partial, some not) → partially_covered."""
|
||||
control = ComplianceControlEntity(
|
||||
control_id="AC-2",
|
||||
title="Account Management",
|
||||
technique_statuses=["validated", "not_evaluated"],
|
||||
)
|
||||
assert control.coverage_status == ControlCoverageStatus.partially_covered
|
||||
|
||||
|
||||
def test_control_no_validated_techniques_not_covered():
|
||||
"""No validated/partial techniques → not_covered."""
|
||||
control = ComplianceControlEntity(
|
||||
control_id="AC-2",
|
||||
title="Account Management",
|
||||
technique_statuses=["not_evaluated", "not_covered"],
|
||||
)
|
||||
assert control.coverage_status == ControlCoverageStatus.not_covered
|
||||
|
||||
|
||||
def test_control_empty_techniques_not_covered():
|
||||
"""Empty technique_statuses → not_covered."""
|
||||
control = ComplianceControlEntity(
|
||||
control_id="AC-2",
|
||||
title="Account Management",
|
||||
technique_statuses=[],
|
||||
)
|
||||
assert control.coverage_status == ControlCoverageStatus.not_covered
|
||||
|
||||
|
||||
# ── Framework coverage ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_framework_coverage_pct_calculation():
|
||||
"""Framework coverage_pct = (covered_controls / total_controls) * 100."""
|
||||
controls = [
|
||||
ComplianceControlEntity("AC-1", "Title 1", technique_statuses=["validated"]),
|
||||
ComplianceControlEntity("AC-2", "Title 2", technique_statuses=["not_evaluated"]),
|
||||
ComplianceControlEntity("AC-3", "Title 3", technique_statuses=["validated", "partial"]),
|
||||
ComplianceControlEntity("AC-4", "Title 4", technique_statuses=["partial"]),
|
||||
ComplianceControlEntity("AC-5", "Title 5", technique_statuses=[]),
|
||||
]
|
||||
framework = ComplianceFrameworkEntity(name="NIST 800-53", controls=controls)
|
||||
# AC-1: covered, AC-2: not_covered, AC-3: covered, AC-4: covered, AC-5: not_covered
|
||||
assert framework.total_controls == 5
|
||||
assert framework.covered_controls == 3
|
||||
assert framework.coverage_pct == 60.0
|
||||
|
||||
|
||||
def test_framework_get_gap_controls():
|
||||
"""get_gap_controls returns only uncovered and partially_covered controls."""
|
||||
controls = [
|
||||
ComplianceControlEntity("AC-1", "Covered", technique_statuses=["validated"]),
|
||||
ComplianceControlEntity("AC-2", "Partial", technique_statuses=["validated", "not_evaluated"]),
|
||||
ComplianceControlEntity("AC-3", "Not Covered", technique_statuses=["not_evaluated"]),
|
||||
ComplianceControlEntity("AC-4", "Empty", technique_statuses=[]),
|
||||
]
|
||||
framework = ComplianceFrameworkEntity(name="Test", controls=controls)
|
||||
gaps = framework.get_gap_controls()
|
||||
assert len(gaps) == 3
|
||||
assert gaps[0].control_id == "AC-2"
|
||||
assert gaps[1].control_id == "AC-3"
|
||||
assert gaps[2].control_id == "AC-4"
|
||||
|
||||
|
||||
def test_framework_no_controls_coverage_pct_zero():
|
||||
"""Framework with no controls → coverage_pct is 0."""
|
||||
framework = ComplianceFrameworkEntity(name="Empty", controls=[])
|
||||
assert framework.total_controls == 0
|
||||
assert framework.covered_controls == 0
|
||||
assert framework.coverage_pct == 0.0
|
||||
Reference in New Issue
Block a user