fix(evidence): use @model_validator(mode='before') so evidences appear in API responses
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

FastAPI 0.136.1 + Pydantic 2.13.4 serialises responses via TypeAdapter which
calls the compiled Rust validator directly, bypassing any Python-level
`model_validate` classmethod override. The @model_validator(mode='before')
decorator IS invoked by the Rust pipeline, so the evidence red/blue split and
technique field population now run on every serialisation path.

Also eager-load technique in get_test_detail to avoid lazy-load surprises.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-28 13:37:18 +02:00
parent 2ee74bf6c9
commit cf5332f522
2 changed files with 24 additions and 10 deletions

View File

@@ -3,7 +3,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, model_validator
from app.domain.enums import DataClassification from app.domain.enums import DataClassification
from app.models.enums import TestResult, TestState from app.models.enums import TestResult, TestState
@@ -173,9 +173,16 @@ class TestOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before")
@classmethod @classmethod
def model_validate(cls, obj, **kwargs): def _populate_derived_fields(cls, obj):
"""Override to populate technique and evidence fields from ORM relationships. """Populate technique and evidence fields from ORM relationships.
Uses ``@model_validator(mode='before')`` so it is called by Pydantic's
internal Rust validation pipeline, including FastAPI's TypeAdapter path.
A plain ``model_validate`` classmethod override is **not** invoked by
FastAPI's response serialisation in Pydantic v2 — only registered
validators are guaranteed to run.
Evidences are only processed when the relationship was **explicitly loaded** Evidences are only processed when the relationship was **explicitly loaded**
(via joinedload or prior access). Accessing ``obj.evidences`` blindly on a (via joinedload or prior access). Accessing ``obj.evidences`` blindly on a
@@ -185,12 +192,19 @@ class TestOut(BaseModel):
the relationship is unloaded and we leave the lists empty (the frontend the relationship is unloaded and we leave the lists empty (the frontend
invalidates and refetches the detail endpoint, which *does* joinload). invalidates and refetches the detail endpoint, which *does* joinload).
""" """
if not hasattr(obj, "__dict__"):
return obj
# Technique info (lazy-load is fine here: session is still open on GET)
try:
if hasattr(obj, "technique") and obj.technique is not None: if hasattr(obj, "technique") and obj.technique is not None:
obj.__dict__["technique_mitre_id"] = obj.technique.mitre_id obj.__dict__["technique_mitre_id"] = obj.technique.mitre_id
obj.__dict__["technique_name"] = obj.technique.name obj.__dict__["technique_name"] = obj.technique.name
except Exception:
pass # DetachedInstanceError or similar — leave technique fields None
# Only split evidences when they are already in memory (loaded via joinedload) # Only split evidences when they are already in memory (loaded via joinedload)
raw_evs = obj.__dict__.get("evidences") if hasattr(obj, "__dict__") else None raw_evs = obj.__dict__.get("evidences")
if raw_evs is not None: if raw_evs is not None:
red_evs: list[EvidenceOut] = [] red_evs: list[EvidenceOut] = []
blue_evs: list[EvidenceOut] = [] blue_evs: list[EvidenceOut] = []
@@ -213,4 +227,4 @@ class TestOut(BaseModel):
obj.__dict__["red_evidences"] = red_evs obj.__dict__["red_evidences"] = red_evs
obj.__dict__["blue_evidences"] = blue_evs obj.__dict__["blue_evidences"] = blue_evs
return super().model_validate(obj, **kwargs) return obj

View File

@@ -137,13 +137,13 @@ def create_test_from_template(
def get_test_detail(db: Session, test_id: uuid.UUID) -> Test: def get_test_detail(db: Session, test_id: uuid.UUID) -> Test:
"""Fetch a test with evidences eager-loaded. """Fetch a test with evidences and technique eager-loaded.
Raises EntityNotFoundError if the test does not exist. Raises EntityNotFoundError if the test does not exist.
""" """
test = ( test = (
db.query(Test) db.query(Test)
.options(joinedload(Test.evidences)) .options(joinedload(Test.evidences), joinedload(Test.technique))
.filter(Test.id == test_id) .filter(Test.id == test_id)
.first() .first()
) )