feat(scoring): composite recency decay and severity weights persisted in DB [FASE-5.1]

This commit is contained in:
2026-05-18 15:07:12 +02:00
parent 2ee59d4e18
commit 05b221a22d
13 changed files with 588 additions and 154 deletions

View File

@@ -18,7 +18,10 @@ from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqu
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping
from app.models.audit import AuditLog
from app.models.enums import TestState, TestResult, TechniqueStatus
from app.models.scoring_config import ScoringConfig
from app.services.scoring_config_service import get_scoring_weights, update_scoring_weights
from app.services.scoring_service import (
_recency_factor,
calculate_technique_score,
calculate_tactic_score,
calculate_organization_score,
@@ -175,9 +178,11 @@ class TestScoring:
assert result["breakdown"]["tests_validated"]["score"] > 0
def test_technique_score_no_tests(self, db, sample_technique_no_tests):
"""Técnica sin tests → score 0."""
"""Técnica sin tests → solo puede puntuar severidad por defecto."""
result = calculate_technique_score(sample_technique_no_tests, db)
assert result["total_score"] == 0
assert result["breakdown"]["tests_validated"]["score"] == 0
assert result["breakdown"]["recency"]["score"] == 0
assert result["total_score"] == result["breakdown"]["severity"]["score"]
def test_technique_score_partial_detection(self, db, sample_technique, validated_tests):
"""Técnica con detección parcial → score intermedio."""
@@ -187,8 +192,8 @@ class TestScoring:
breakdown = result["breakdown"]
assert "2/3" in breakdown["tests_validated"]["detail"]
def test_technique_score_freshness_penalty(self, db, sample_technique, admin_user):
"""Tests > 180 días → penalización en freshness."""
def test_technique_score_recency_penalty(self, db, sample_technique, admin_user):
"""Tests > 180 días → factor de recencia reducido."""
old_date = datetime.utcnow() - timedelta(days=200)
test = Test(
technique_id=sample_technique.id,
@@ -198,14 +203,53 @@ class TestScoring:
created_by=admin_user.id,
platform="windows",
red_validated_at=old_date,
blue_validated_at=old_date,
)
db.add(test)
db.commit()
result = calculate_technique_score(sample_technique, db)
# Freshness should be 0 for tests > 180 days old
assert result["breakdown"]["freshness"]["score"] == 0
assert "200" in result["breakdown"]["freshness"]["detail"]
assert result["breakdown"]["recency"]["score"] == 5.0 # 0.5 * 10 (181365 días)
assert "200" in result["breakdown"]["recency"]["detail"]
def test_recency_recent_scores_higher_than_old(self, db, sample_technique, admin_user):
"""Mismo resultado de detección: test reciente puntúa más que uno de hace 1 año."""
now = datetime.utcnow()
for days_ago in (1, 400):
db.add(
Test(
technique_id=sample_technique.id,
name=f"Recency {days_ago}",
state=TestState.validated,
detection_result=TestResult.detected,
created_by=admin_user.id,
platform="windows",
red_validated_at=now - timedelta(days=days_ago),
blue_validated_at=now - timedelta(days=days_ago),
)
)
db.commit()
result = calculate_technique_score(sample_technique, db)
assert _recency_factor(now - timedelta(days=1)) == 1.0
assert _recency_factor(now - timedelta(days=400)) == 0.2
assert result["breakdown"]["recency"]["score"] == 10.0
def test_scoring_weights_persist_in_database(self, db, sample_technique, validated_tests):
"""Cambiar pesos en BD se refleja en el breakdown."""
update_scoring_weights(db, tests=50, detection_rules=20, d3fend=10, recency=10, severity=10)
db.commit()
score = calculate_technique_score(sample_technique, db)
assert score["breakdown"]["tests_validated"]["max"] == 50
row = db.query(ScoringConfig).first()
assert row is not None
assert row.weight_tests == 50
update_scoring_weights(db, tests=40, detection_rules=25, d3fend=15, recency=10, severity=10)
db.commit()
assert get_scoring_weights(db).tests == 40
def test_scoring_weights_configurable(self, db, sample_technique, validated_tests):
"""Scoring weights are reflected in the breakdown max values."""