diff --git a/pyrit/backend/mappers/attack_mappers.py b/pyrit/backend/mappers/attack_mappers.py index 0245e2af1..c35084c2f 100644 --- a/pyrit/backend/mappers/attack_mappers.py +++ b/pyrit/backend/mappers/attack_mappers.py @@ -199,9 +199,17 @@ def attack_result_to_summary( last_preview = stats.last_message_preview labels = dict(stats.labels) if stats.labels else {} + # Resolution order for created_at: explicit metadata override, then the + # persisted AttackResult.timestamp, and finally datetime.now() as a + # last-resort fallback for never-persisted results. created_str = ar.metadata.get("created_at") updated_str = ar.metadata.get("updated_at") - created_at = datetime.fromisoformat(created_str) if created_str else datetime.now(timezone.utc) + if created_str: + created_at = datetime.fromisoformat(created_str) + elif ar.timestamp is not None: + created_at = ar.timestamp + else: + created_at = datetime.now(timezone.utc) updated_at = datetime.fromisoformat(updated_str) if updated_str else created_at aid = ar.get_attack_strategy_identifier() diff --git a/pyrit/memory/memory_models.py b/pyrit/memory/memory_models.py index 5a11fa78c..d246a3269 100644 --- a/pyrit/memory/memory_models.py +++ b/pyrit/memory/memory_models.py @@ -894,6 +894,7 @@ def get_attack_result(self) -> AttackResult: outcome_reason=self.outcome_reason, related_conversations=related_conversations, metadata=self.attack_metadata or {}, + timestamp=_ensure_utc(self.timestamp), ) diff --git a/pyrit/models/attack_result.py b/pyrit/models/attack_result.py index a385ac36e..03a6a200c 100644 --- a/pyrit/models/attack_result.py +++ b/pyrit/models/attack_result.py @@ -12,6 +12,8 @@ from pyrit.models.strategy_result import StrategyResult if TYPE_CHECKING: + from datetime import datetime + from pyrit.identifiers.component_identifier import ComponentIdentifier from pyrit.models.conversation_reference import ConversationReference from pyrit.models.message_piece import MessagePiece @@ -81,6 +83,12 @@ class AttackResult(StrategyResult): # Optional reason for the outcome, providing additional context outcome_reason: Optional[str] = None + # Wall-clock time the result was persisted. Hydrated from + # AttackResultEntries.timestamp when loaded from memory; None when the + # AttackResult has never been persisted. Downstream consumers (e.g. the + # backend mapper) use this to populate user-facing creation times. + timestamp: Optional[datetime] = None + # Flexible conversation refs (nothing unused) related_conversations: set[ConversationReference] = field(default_factory=set) diff --git a/tests/unit/backend/test_mappers.py b/tests/unit/backend/test_mappers.py index 0f483b3f1..c3a06c08a 100644 --- a/tests/unit/backend/test_mappers.py +++ b/tests/unit/backend/test_mappers.py @@ -302,6 +302,48 @@ def test_message_count_from_stats(self) -> None: assert summary.message_count == 5 + def test_created_at_prefers_ar_timestamp_when_metadata_absent(self) -> None: + """When metadata['created_at'] is absent but ar.timestamp is set, use ar.timestamp.""" + persisted_ts = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc) + ar = AttackResult( + conversation_id="attack-1", + objective="test", + outcome=AttackOutcome.SUCCESS, + timestamp=persisted_ts, + ) + summary = attack_result_to_summary(ar, stats=ConversationStats(message_count=0)) + + assert summary.created_at == persisted_ts + assert summary.updated_at == persisted_ts + + def test_created_at_metadata_still_wins_over_ar_timestamp(self) -> None: + """When both metadata['created_at'] and ar.timestamp are set, metadata wins (backward compat).""" + metadata_ts = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + ar_ts = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc) + ar = AttackResult( + conversation_id="attack-1", + objective="test", + outcome=AttackOutcome.SUCCESS, + timestamp=ar_ts, + metadata={"created_at": metadata_ts.isoformat()}, + ) + summary = attack_result_to_summary(ar, stats=ConversationStats(message_count=0)) + + assert summary.created_at == metadata_ts + + def test_created_at_falls_back_to_now_when_both_absent(self) -> None: + """When neither metadata nor ar.timestamp is set, fall back to datetime.now().""" + ar = AttackResult( + conversation_id="attack-1", + objective="test", + outcome=AttackOutcome.SUCCESS, + ) + before = datetime.now(timezone.utc) + summary = attack_result_to_summary(ar, stats=ConversationStats(message_count=0)) + after = datetime.now(timezone.utc) + + assert before <= summary.created_at <= after + """Tests for pyrit_scores_to_dto function.""" def test_maps_scores(self) -> None: diff --git a/tests/unit/models/test_attack_result.py b/tests/unit/models/test_attack_result.py index 2ec585e18..34c3eaa0c 100644 --- a/tests/unit/models/test_attack_result.py +++ b/tests/unit/models/test_attack_result.py @@ -2,10 +2,12 @@ # Licensed under the MIT license. import warnings +from datetime import datetime, timezone from pyrit.identifiers import ComponentIdentifier from pyrit.identifiers.atomic_attack_identifier import build_atomic_attack_identifier -from pyrit.models.attack_result import AttackResult +from pyrit.memory.memory_models import AttackResultEntry +from pyrit.models.attack_result import AttackOutcome, AttackResult class TestAttackResultDeprecation: @@ -133,3 +135,47 @@ def test_constructor_with_no_identifier_at_all(self) -> None: result = AttackResult(conversation_id="c1", objective="test") assert result.atomic_attack_identifier is None assert result.get_attack_strategy_identifier() is None + + +class TestAttackResultTimestamp: + """Tests for the AttackResult.timestamp field and its round-trip through AttackResultEntry.""" + + def test_timestamp_defaults_to_none_when_not_set(self) -> None: + """AttackResult constructed without a timestamp exposes the field as None.""" + result = AttackResult(conversation_id="c1", objective="test") + assert result.timestamp is None + + def test_timestamp_accepts_and_preserves_aware_datetime(self) -> None: + """A tz-aware datetime passed to the constructor is stored as-is.""" + ts = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc) + result = AttackResult(conversation_id="c1", objective="test", timestamp=ts) + assert result.timestamp == ts + + def test_timestamp_roundtrips_through_attack_result_entry(self) -> None: + """AttackResultEntry.timestamp is surfaced on the hydrated AttackResult.""" + original = AttackResult( + conversation_id="c1", + objective="test", + outcome=AttackOutcome.SUCCESS, + ) + entry = AttackResultEntry(entry=original) + persisted_ts = datetime(2026, 4, 17, 12, 0, 0, tzinfo=timezone.utc) + entry.timestamp = persisted_ts + + hydrated = entry.get_attack_result() + + assert hydrated.timestamp == persisted_ts + + def test_naive_entry_timestamp_is_normalized_to_utc_on_hydration(self) -> None: + """SQLite returns naive datetimes; hydration must attach UTC tzinfo.""" + original = AttackResult(conversation_id="c1", objective="test") + entry = AttackResultEntry(entry=original) + # Simulate a SQLite-backed row: naive datetime, no tzinfo. The whole + # point of this test is to exercise that path, so DTZ001 is suppressed. + entry.timestamp = datetime(2026, 4, 17, 12, 0, 0) # noqa: DTZ001 + + hydrated = entry.get_attack_result() + + assert hydrated.timestamp is not None + assert hydrated.timestamp.tzinfo is timezone.utc + assert hydrated.timestamp.replace(tzinfo=None) == datetime(2026, 4, 17, 12, 0, 0) # noqa: DTZ001