feat(domain): add domain layer foundation -- enums, value objects, TechniqueEntity, repository ports
This commit is contained in:
53
backend/tests/test_domain_enums.py
Normal file
53
backend/tests/test_domain_enums.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests verifying domain enums are canonical and properly re-exported."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
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.enums import TechniqueStatus, TestState, TeamSide, TestResult
|
||||
|
||||
|
||||
def test_technique_status_values():
|
||||
assert TechniqueStatus.not_evaluated == "not_evaluated"
|
||||
assert TechniqueStatus.validated == "validated"
|
||||
assert TechniqueStatus.partial == "partial"
|
||||
assert TechniqueStatus.in_progress == "in_progress"
|
||||
assert TechniqueStatus.not_covered == "not_covered"
|
||||
assert TechniqueStatus.review_required == "review_required"
|
||||
|
||||
|
||||
def test_test_state_values():
|
||||
assert TestState.draft == "draft"
|
||||
assert TestState.red_executing == "red_executing"
|
||||
assert TestState.blue_evaluating == "blue_evaluating"
|
||||
assert TestState.in_review == "in_review"
|
||||
assert TestState.validated == "validated"
|
||||
assert TestState.rejected == "rejected"
|
||||
|
||||
|
||||
def test_team_side_values():
|
||||
assert TeamSide.red == "red"
|
||||
assert TeamSide.blue == "blue"
|
||||
|
||||
|
||||
def test_test_result_values():
|
||||
assert TestResult.detected == "detected"
|
||||
assert TestResult.not_detected == "not_detected"
|
||||
assert TestResult.partially_detected == "partially_detected"
|
||||
|
||||
|
||||
def test_models_enums_reexport_is_same_class():
|
||||
"""Verify models/enums.py re-exports the exact same class objects."""
|
||||
from app.models.enums import (
|
||||
TechniqueStatus as MS,
|
||||
TestState as MTS,
|
||||
TeamSide as MTeam,
|
||||
TestResult as MTR,
|
||||
)
|
||||
assert MS is TechniqueStatus
|
||||
assert MTS is TestState
|
||||
assert MTeam is TeamSide
|
||||
assert MTR is TestResult
|
||||
168
backend/tests/test_technique_entity.py
Normal file
168
backend/tests/test_technique_entity.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Tests for TechniqueEntity — pure domain logic, no DB."""
|
||||
|
||||
import uuid
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
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)
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.entities.technique import TechniqueEntity
|
||||
from app.domain.enums import TechniqueStatus
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _entity(**overrides) -> TechniqueEntity:
|
||||
defaults = dict(
|
||||
id=uuid.uuid4(),
|
||||
mitre_id="T1059",
|
||||
name="Command and Scripting Interpreter",
|
||||
tactic="execution",
|
||||
status_global=TechniqueStatus.not_evaluated,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return TechniqueEntity(**defaults)
|
||||
|
||||
|
||||
def _fake_orm(**overrides) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.id = uuid.uuid4()
|
||||
m.mitre_id = "T1059"
|
||||
m.name = "Command and Scripting Interpreter"
|
||||
m.tactic = "execution"
|
||||
m.description = None
|
||||
m.platforms = ["windows", "linux"]
|
||||
m.is_subtechnique = False
|
||||
m.parent_mitre_id = None
|
||||
m.status_global = "not_evaluated"
|
||||
m.review_required = False
|
||||
m.last_review_date = None
|
||||
for k, v in overrides.items():
|
||||
setattr(m, k, v)
|
||||
return m
|
||||
|
||||
|
||||
# ── 1. Factory: create() ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreate:
|
||||
|
||||
def test_valid_technique(self):
|
||||
e = TechniqueEntity.create(
|
||||
mitre_id="T1059",
|
||||
name="Command and Scripting Interpreter",
|
||||
tactic="execution",
|
||||
)
|
||||
assert e.mitre_id == "T1059"
|
||||
assert e.name == "Command and Scripting Interpreter"
|
||||
assert e.status_global == TechniqueStatus.not_evaluated
|
||||
assert not e.is_subtechnique
|
||||
|
||||
def test_valid_subtechnique(self):
|
||||
e = TechniqueEntity.create(
|
||||
mitre_id="T1059.001",
|
||||
name="PowerShell",
|
||||
tactic="execution",
|
||||
)
|
||||
assert e.is_subtechnique
|
||||
assert e.parent_mitre_id == "T1059"
|
||||
|
||||
def test_invalid_mitre_id_raises(self):
|
||||
with pytest.raises(ValueError, match="Invalid MITRE"):
|
||||
TechniqueEntity.create(mitre_id="INVALID", name="Bad", tactic="x")
|
||||
|
||||
def test_platforms_default_to_empty(self):
|
||||
e = TechniqueEntity.create(mitre_id="T1059", name="Test")
|
||||
assert e.platforms == []
|
||||
|
||||
|
||||
# ── 2. from_orm / apply_to ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOrmRoundTrip:
|
||||
|
||||
def test_from_orm_basic(self):
|
||||
orm = _fake_orm()
|
||||
e = TechniqueEntity.from_orm(orm)
|
||||
assert e.mitre_id == "T1059"
|
||||
assert e.status_global == TechniqueStatus.not_evaluated
|
||||
|
||||
def test_from_orm_coerces_string_status(self):
|
||||
orm = _fake_orm(status_global="validated")
|
||||
e = TechniqueEntity.from_orm(orm)
|
||||
assert e.status_global == TechniqueStatus.validated
|
||||
|
||||
def test_apply_to_updates_model(self):
|
||||
orm = _fake_orm()
|
||||
e = TechniqueEntity.from_orm(orm)
|
||||
e.status_global = TechniqueStatus.validated
|
||||
e.review_required = True
|
||||
e.apply_to(orm)
|
||||
assert orm.status_global == TechniqueStatus.validated
|
||||
assert orm.review_required is True
|
||||
|
||||
|
||||
# ── 3. recalculate_status ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRecalculateStatus:
|
||||
|
||||
def test_no_tests_gives_not_evaluated(self):
|
||||
e = _entity()
|
||||
result = e.recalculate_status([])
|
||||
assert result == TechniqueStatus.not_evaluated
|
||||
assert e.status_global == TechniqueStatus.not_evaluated
|
||||
|
||||
def test_all_validated_all_detected_gives_validated(self):
|
||||
e = _entity()
|
||||
tests = [("validated", "detected"), ("validated", "detected")]
|
||||
assert e.recalculate_status(tests) == TechniqueStatus.validated
|
||||
|
||||
def test_all_validated_some_partially_gives_partial(self):
|
||||
e = _entity()
|
||||
tests = [("validated", "detected"), ("validated", "partially_detected")]
|
||||
assert e.recalculate_status(tests) == TechniqueStatus.partial
|
||||
|
||||
def test_all_validated_none_detected_gives_not_covered(self):
|
||||
e = _entity()
|
||||
tests = [("validated", "not_detected"), ("validated", "not_detected")]
|
||||
assert e.recalculate_status(tests) == TechniqueStatus.not_covered
|
||||
|
||||
def test_all_validated_no_results_gives_not_covered(self):
|
||||
e = _entity()
|
||||
tests = [("validated", None)]
|
||||
assert e.recalculate_status(tests) == TechniqueStatus.not_covered
|
||||
|
||||
def test_mixed_validated_and_in_progress_gives_partial(self):
|
||||
e = _entity()
|
||||
tests = [("validated", "detected"), ("draft", None)]
|
||||
assert e.recalculate_status(tests) == TechniqueStatus.partial
|
||||
|
||||
def test_all_in_progress_gives_in_progress(self):
|
||||
e = _entity()
|
||||
tests = [("draft", None), ("red_executing", None)]
|
||||
assert e.recalculate_status(tests) == TechniqueStatus.in_progress
|
||||
|
||||
|
||||
# ── 4. mark_reviewed / flag_for_review ──────────────────────────────
|
||||
|
||||
|
||||
class TestReviewCycle:
|
||||
|
||||
def test_mark_reviewed_clears_flag(self):
|
||||
e = _entity(review_required=True)
|
||||
e.mark_reviewed()
|
||||
assert e.review_required is False
|
||||
assert e.last_review_date is not None
|
||||
|
||||
def test_flag_for_review(self):
|
||||
e = _entity(review_required=False)
|
||||
e.flag_for_review()
|
||||
assert e.review_required is True
|
||||
114
backend/tests/test_value_objects.py
Normal file
114
backend/tests/test_value_objects.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Tests for domain value objects: MitreId and ScoringWeights."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
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)
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.value_objects.mitre_id import MitreId
|
||||
from app.domain.value_objects.scoring_weights import ScoringWeights
|
||||
|
||||
|
||||
# ── MitreId ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMitreId:
|
||||
|
||||
def test_valid_technique(self):
|
||||
mid = MitreId("T1059")
|
||||
assert mid.value == "T1059"
|
||||
assert str(mid) == "T1059"
|
||||
assert not mid.is_subtechnique
|
||||
assert mid.parent_id is None
|
||||
|
||||
def test_valid_subtechnique(self):
|
||||
mid = MitreId("T1059.001")
|
||||
assert mid.value == "T1059.001"
|
||||
assert mid.is_subtechnique
|
||||
assert mid.parent_id == "T1059"
|
||||
|
||||
def test_invalid_empty_string(self):
|
||||
with pytest.raises(ValueError, match="Invalid MITRE"):
|
||||
MitreId("")
|
||||
|
||||
def test_invalid_no_prefix(self):
|
||||
with pytest.raises(ValueError, match="Invalid MITRE"):
|
||||
MitreId("1059")
|
||||
|
||||
def test_invalid_wrong_prefix(self):
|
||||
with pytest.raises(ValueError, match="Invalid MITRE"):
|
||||
MitreId("A1059")
|
||||
|
||||
def test_invalid_too_few_digits(self):
|
||||
with pytest.raises(ValueError, match="Invalid MITRE"):
|
||||
MitreId("T105")
|
||||
|
||||
def test_invalid_subtechnique_format(self):
|
||||
with pytest.raises(ValueError, match="Invalid MITRE"):
|
||||
MitreId("T1059.01") # needs 3 digits after dot
|
||||
|
||||
def test_invalid_trailing_garbage(self):
|
||||
with pytest.raises(ValueError, match="Invalid MITRE"):
|
||||
MitreId("T1059.001.002")
|
||||
|
||||
def test_equality_with_same_mitre_id(self):
|
||||
assert MitreId("T1059") == MitreId("T1059")
|
||||
|
||||
def test_equality_with_string(self):
|
||||
assert MitreId("T1059") == "T1059"
|
||||
|
||||
def test_inequality(self):
|
||||
assert MitreId("T1059") != MitreId("T1060")
|
||||
|
||||
def test_hashable(self):
|
||||
s = {MitreId("T1059"), MitreId("T1059"), MitreId("T1060")}
|
||||
assert len(s) == 2
|
||||
|
||||
def test_immutable(self):
|
||||
mid = MitreId("T1059")
|
||||
with pytest.raises(AttributeError):
|
||||
mid.value = "T1060"
|
||||
|
||||
|
||||
# ── ScoringWeights ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScoringWeights:
|
||||
|
||||
def test_valid_default(self):
|
||||
w = ScoringWeights.default()
|
||||
assert w.tests == 40.0
|
||||
assert w.detection_rules == 25.0
|
||||
assert w.d3fend == 15.0
|
||||
assert w.freshness == 10.0
|
||||
assert w.platform_diversity == 10.0
|
||||
|
||||
def test_valid_custom(self):
|
||||
w = ScoringWeights(
|
||||
tests=50, detection_rules=20, d3fend=10,
|
||||
freshness=10, platform_diversity=10,
|
||||
)
|
||||
assert w.tests == 50
|
||||
|
||||
def test_invalid_sum_not_100(self):
|
||||
with pytest.raises(ValueError, match="sum to 100"):
|
||||
ScoringWeights(
|
||||
tests=50, detection_rules=20, d3fend=10,
|
||||
freshness=10, platform_diversity=5,
|
||||
)
|
||||
|
||||
def test_invalid_negative_weight(self):
|
||||
with pytest.raises(ValueError, match="non-negative"):
|
||||
ScoringWeights(
|
||||
tests=-10, detection_rules=40, d3fend=30,
|
||||
freshness=20, platform_diversity=20,
|
||||
)
|
||||
|
||||
def test_immutable(self):
|
||||
w = ScoringWeights.default()
|
||||
with pytest.raises(AttributeError):
|
||||
w.tests = 50
|
||||
Reference in New Issue
Block a user