feat(tests): archive round history on reopen, stop pausing from setting Jira On Hold
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

- Reopening a test for rework (red or blue) now archives the round that's
  ending into a new test_round_history table before resetting the live
  fields — procedure/results/detection data is preserved instead of
  overwritten, and Phase Timeline renders every past round alongside the
  current one, so Blue Team keeps visibility into earlier Red attempts.
- Each archived round also posts a permanent Jira comment (push_round_archived)
  since Jira's custom fields only ever show the latest value once the next
  round resubmits — the comment thread is what preserves full history there.
- push_pause_event no longer transitions the Jira issue to "On Hold" — a
  timer pause is not a real Hold, and flipping Jira status for it was
  misleading. Only the explicit Hold action (push_hold_event) does that now.
This commit is contained in:
kitos
2026-07-13 12:08:45 +02:00
parent 0bb34f6834
commit 8fe38dc661
12 changed files with 526 additions and 26 deletions
+2
View File
@@ -43,6 +43,7 @@ from app.models.evidence import Evidence
from app.models.intel import IntelItem
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.test_template import TestTemplate
from app.models.user import User
@@ -86,4 +87,5 @@ __all__ = [
"SsoConfig",
"AlertRule",
"AlertInstance",
"TestRoundHistory",
]
+8
View File
@@ -108,6 +108,10 @@ class Test(Base):
# Assign blue_paused_seconds = Column(Integer, default=0)
blue_paused_seconds = Column(Integer, default=0)
# ── Round tracking (bumped on each reopen-for-rework) ────────────
red_round_number = Column(Integer, nullable=False, default=1, server_default="1")
blue_round_number = Column(Integer, nullable=False, default=1, server_default="1")
# ── Remediation fields ───────────────────────────────────────────
remediation_steps = Column(Text, nullable=True)
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
@@ -126,6 +130,10 @@ class Test(Base):
technique = relationship("Technique", back_populates="tests")
# Assign evidences = relationship("Evidence", back_populates="test")
evidences = relationship("Evidence", back_populates="test")
round_history = relationship(
"TestRoundHistory", back_populates="test",
order_by="TestRoundHistory.round_number", cascade="all, delete-orphan",
)
# Assign creator = relationship("User", foreign_keys=[created_by])
creator = relationship("User", foreign_keys=[created_by])
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
+60
View File
@@ -0,0 +1,60 @@
"""SQLAlchemy model for archived test rounds.
Each row is a snapshot of one team's round of work (procedure/results for
Red, detection/containment for Blue) taken right before a lead reopens the
test for rework. Reopening resets the live fields on ``Test`` for a fresh
attempt, but the prior attempt's data must not be lost — it's archived here
so the full history stays visible (e.g. to Blue Team, who need to see what
Red actually did on earlier rounds, not just the latest one).
"""
import uuid
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult
class TestRoundHistory(Base):
"""Archived snapshot of one red/blue round of a test, taken on reopen."""
__tablename__ = "test_round_history"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=False)
team = Column(String(10), nullable=False) # "red" or "blue"
round_number = Column(Integer, nullable=False)
started_at = Column(DateTime, nullable=True)
ended_at = Column(DateTime, nullable=True)
paused_seconds = Column(Integer, default=0)
# Red-side round output
procedure_text = Column(Text, nullable=True)
tool_used = Column(String, nullable=True)
# native_enum=False — stored as plain VARCHAR, not the shared Postgres
# enum type used by tests.attack_success. This is an archive table with
# no DB-level constraint needs, and reusing the native type name here
# would make Alembic try to (re)create it.
attack_success = Column(Enum(AttackSuccessResult, name="test_round_history_attack_success", native_enum=False), nullable=True)
red_summary = Column(Text, nullable=True)
execution_start_time = Column(DateTime, nullable=True)
execution_end_time = Column(DateTime, nullable=True)
# Blue-side round output
detection_result = Column(Enum(TestResult, name="test_round_history_detection_result", native_enum=False), nullable=True)
containment_result = Column(Enum(ContainmentResult, name="test_round_history_containment_result", native_enum=False), nullable=True)
detection_time = Column(DateTime, nullable=True)
containment_time = Column(DateTime, nullable=True)
blue_summary = Column(Text, nullable=True)
# Why the round was closed
review_notes = Column(Text, nullable=True)
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
archived_at = Column(DateTime(timezone=True), server_default=func.now())
test = relationship("Test", back_populates="round_history")
reviewer = relationship("User", foreign_keys=[reviewed_by])