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
@@ -0,0 +1,64 @@
"""Archive round data on reopen instead of overwriting it.
Reopening a test for rework used to reset the live procedure/result fields
in place, losing the prior round's data (and clobbering the Phase Timeline,
which read those same mutable columns). This adds an append-only
``test_round_history`` table that snapshots a round's fields right before
they get reset, plus per-team round counters on ``tests`` so each round is
numbered.
Revision ID: b061
Revises: b060
Create Date: 2026-07-13
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b061"
down_revision = "b060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tests", sa.Column("red_round_number", sa.Integer(), nullable=False, server_default="1"))
op.add_column("tests", sa.Column("blue_round_number", sa.Integer(), nullable=False, server_default="1"))
op.create_table(
"test_round_history",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=False),
sa.Column("team", sa.String(10), nullable=False),
sa.Column("round_number", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("ended_at", sa.DateTime(), nullable=True),
sa.Column("paused_seconds", sa.Integer(), nullable=True),
sa.Column("procedure_text", sa.Text(), nullable=True),
sa.Column("tool_used", sa.String(), nullable=True),
# Plain strings, not the shared Postgres enum types tests.* uses —
# this is an archive table with no DB-level constraint needs, and
# reusing those native type names here would make Alembic try to
# (re)create them.
sa.Column("attack_success", sa.String(), nullable=True),
sa.Column("red_summary", sa.Text(), nullable=True),
sa.Column("execution_start_time", sa.DateTime(), nullable=True),
sa.Column("execution_end_time", sa.DateTime(), nullable=True),
sa.Column("detection_result", sa.String(), nullable=True),
sa.Column("containment_result", sa.String(), nullable=True),
sa.Column("detection_time", sa.DateTime(), nullable=True),
sa.Column("containment_time", sa.DateTime(), nullable=True),
sa.Column("blue_summary", sa.Text(), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_test_round_history_test_id", "test_round_history", ["test_id"])
def downgrade() -> None:
op.drop_index("ix_test_round_history_test_id", table_name="test_round_history")
op.drop_table("test_round_history")
op.drop_column("tests", "blue_round_number")
op.drop_column("tests", "red_round_number")
+2
View File
@@ -43,6 +43,7 @@ from app.models.evidence import Evidence
from app.models.intel import IntelItem from app.models.intel import IntelItem
from app.models.technique import Technique from app.models.technique import Technique
from app.models.test import Test from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.test_template import TestTemplate from app.models.test_template import TestTemplate
from app.models.user import User from app.models.user import User
@@ -86,4 +87,5 @@ __all__ = [
"SsoConfig", "SsoConfig",
"AlertRule", "AlertRule",
"AlertInstance", "AlertInstance",
"TestRoundHistory",
] ]
+8
View File
@@ -108,6 +108,10 @@ class Test(Base):
# Assign blue_paused_seconds = Column(Integer, default=0) # Assign blue_paused_seconds = Column(Integer, default=0)
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 fields ───────────────────────────────────────────
remediation_steps = Column(Text, nullable=True) remediation_steps = Column(Text, nullable=True)
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ... # Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
@@ -126,6 +130,10 @@ class Test(Base):
technique = relationship("Technique", back_populates="tests") technique = relationship("Technique", back_populates="tests")
# Assign evidences = relationship("Evidence", back_populates="test") # Assign evidences = relationship("Evidence", back_populates="test")
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]) # Assign creator = relationship("User", foreign_keys=[created_by])
creator = relationship("User", foreign_keys=[created_by]) creator = relationship("User", foreign_keys=[created_by])
# Assign red_validator = relationship("User", foreign_keys=[red_validated_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])
+42
View File
@@ -101,6 +101,36 @@ class TestBlueUpdate(BaseModel):
blue_summary: str | None = None blue_summary: str | None = None
# ── Round history (archived on reopen) ─────────────────────────────
class TestRoundHistoryOut(BaseModel):
"""One archived round snapshot, taken right before a reopen resets the live fields."""
id: uuid.UUID
team: str
round_number: int
started_at: datetime | None = None
ended_at: datetime | None = None
paused_seconds: int = 0
procedure_text: str | None = None
tool_used: str | None = None
attack_success: AttackSuccessResult | None = None
red_summary: str | None = None
execution_start_time: datetime | None = None
execution_end_time: datetime | None = None
detection_result: TestResult | None = None
containment_result: ContainmentResult | None = None
detection_time: datetime | None = None
containment_time: datetime | None = None
blue_summary: str | None = None
review_notes: str | None = None
reviewed_by: uuid.UUID | None = None
archived_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
# ── Red Lead validation ──────────────────────────────────────────── # ── Red Lead validation ────────────────────────────────────────────
@@ -269,6 +299,8 @@ class TestOut(BaseModel):
red_paused_seconds: int = 0 red_paused_seconds: int = 0
# Assign blue_paused_seconds = 0 # Assign blue_paused_seconds = 0
blue_paused_seconds: int = 0 blue_paused_seconds: int = 0
red_round_number: int = 1
blue_round_number: int = 1
# Remediation fields # Remediation fields
remediation_steps: str | None = None remediation_steps: str | None = None
@@ -313,6 +345,9 @@ class TestOut(BaseModel):
red_evidences: list[EvidenceOut] = [] red_evidences: list[EvidenceOut] = []
blue_evidences: list[EvidenceOut] = [] blue_evidences: list[EvidenceOut] = []
# Archived rounds — full history, oldest first (populated from the ORM relationship)
round_history: list[TestRoundHistoryOut] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before") @model_validator(mode="before")
@@ -369,4 +404,11 @@ 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
# Only populate round history when already loaded (via joinedload)
raw_rounds = obj.__dict__.get("round_history")
if raw_rounds is not None:
obj.__dict__["round_history"] = [
TestRoundHistoryOut.model_validate(r) for r in raw_rounds
]
return obj return obj
+58 -12
View File
@@ -1025,11 +1025,13 @@ def push_hold_event(
def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> None: def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> None:
"""Post a timer pause/resume comment to Jira and toggle 'On Hold' / 'In Progress'. """Post a timer pause/resume comment to Jira — status is left untouched.
Distinct from :func:`push_hold_event` — a short timer pause (the Distinct from :func:`push_hold_event` — a short timer pause (the timer
operator stepping away briefly) maps to "On Hold", while an explicit sitting idle between rework rounds, or the operator briefly stepping
hold (something external blocking progress) maps to "Blocked". away) is not the same thing as the test actually being blocked, so it
must not flip the Jira issue to "On Hold". Only a genuine Hold action
(:func:`push_hold_event`) does that. This just leaves a record.
Non-fatal — any Jira error is logged and swallowed. Non-fatal — any Jira error is logged and swallowed.
""" """
if not has_admin_jira_configured(db): if not has_admin_jira_configured(db):
@@ -1052,16 +1054,8 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non
if resuming: if resuming:
comment = f"h3. ▶ Timer Resumed\n\n*Resumed by:* {actor.username}\n*At:* {ts}" comment = f"h3. ▶ Timer Resumed\n\n*Resumed by:* {actor.username}\n*At:* {ts}"
try:
jira.set_issue_status(link.jira_issue_key, "In Progress")
except Exception as exc_t:
logger.warning("Could not transition %s off On Hold: %s", link.jira_issue_key, exc_t)
else: else:
comment = f"h3. ⏸ Timer Paused\n\n*Paused by:* {actor.username}\n*At:* {ts}" comment = f"h3. ⏸ Timer Paused\n\n*Paused by:* {actor.username}\n*At:* {ts}"
try:
jira.set_issue_status(link.jira_issue_key, "On Hold")
except Exception as exc_t:
logger.warning("Could not transition %s to On Hold: %s", link.jira_issue_key, exc_t)
jira.issue_add_comment(link.jira_issue_key, comment) jira.issue_add_comment(link.jira_issue_key, comment)
link.last_synced_at = datetime.utcnow() link.last_synced_at = datetime.utcnow()
@@ -1071,6 +1065,58 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non
logger.warning("Failed to push pause event for test %s: %s", test.id, exc, exc_info=True) logger.warning("Failed to push pause event for test %s: %s", test.id, exc, exc_info=True)
def push_round_archived(db: Session, test, actor, *, round_data) -> None:
"""Post a permanent Jira comment summarizing a round right before it's
reset for rework.
``round_data`` custom fields (attack success, detection result, etc.)
only ever show Jira's *latest* value — each new round's submission
overwrites the field. This comment is what preserves every prior
round's results in Jira, since comments are never overwritten.
Non-fatal — any Jira error is logged and swallowed.
"""
if not has_admin_jira_configured(db):
return
link = (
db.query(JiraLink)
.filter(
JiraLink.entity_type == JiraLinkEntityType.test,
JiraLink.entity_id == test.id,
)
.first()
)
if not link:
return
try:
jira = get_admin_jira_client(db)
team = round_data.team
lines = [f"h3. \U0001F4CB Round {round_data.round_number} archived ({team} team)\n"]
if team == "red":
lines.append(f"*Procedure:* {round_data.procedure_text or '_none recorded_'}")
lines.append(f"*Tool used:* {round_data.tool_used or '-'}")
lines.append(f"*Attack success:* {round_data.attack_success.value if round_data.attack_success else '-'}")
lines.append(f"*Summary:* {round_data.red_summary or '-'}")
else:
lines.append(f"*Detection result:* {round_data.detection_result.value if round_data.detection_result else '-'}")
lines.append(f"*Containment result:* {round_data.containment_result.value if round_data.containment_result else '-'}")
lines.append(f"*Summary:* {round_data.blue_summary or '-'}")
lines.append(f"*Sent back by:* {actor.username}")
lines.append(f"*Reopen notes:* {round_data.review_notes or '-'}")
comment = "\n".join(lines)
jira.issue_add_comment(link.jira_issue_key, comment)
link.last_synced_at = datetime.utcnow()
db.flush()
logger.info(
"Posted round-archived comment to Jira %s (team=%s, round=%s)",
link.jira_issue_key, team, round_data.round_number,
)
except Exception as exc:
logger.warning("Failed to push round-archived comment for test %s: %s", test.id, exc, exc_info=True)
def push_bt_work_started(db: Session, test, actor) -> None: def push_bt_work_started(db: Session, test, actor) -> None:
"""Transition the Jira issue to "In Progress" when a blue tech picks up """Transition the Jira issue to "In Progress" when a blue tech picks up
a queued test to start evaluating. a queued test to start evaluating.
+4 -1
View File
@@ -393,7 +393,10 @@ def get_test_detail(db: Session, test_id: uuid.UUID) -> Test:
# Assign test = ( # Assign test = (
test = ( test = (
db.query(Test) db.query(Test)
.options(joinedload(Test.evidences), joinedload(Test.technique)) .options(
joinedload(Test.evidences), joinedload(Test.technique),
joinedload(Test.round_history),
)
.filter(Test.id == test_id) .filter(Test.id == test_id)
# Chain .first() call # Chain .first() call
.first() .first()
+51 -4
View File
@@ -32,6 +32,7 @@ from app.models.campaign import Campaign, CampaignTest
from app.models.enums import TestState, TeamSide from app.models.enums import TestState, TeamSide
from app.models.evidence import Evidence from app.models.evidence import Evidence
from app.models.test import Test from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.user import User from app.models.user import User
from app.services.audit_service import log_action from app.services.audit_service import log_action
from app.services.notification_service import ( from app.services.notification_service import (
@@ -383,18 +384,41 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
if not notes or not notes.strip(): if not notes or not notes.strip():
raise InvalidOperationError("A comment is required when reopening a test for rework") raise InvalidOperationError("A comment is required when reopening a test for rework")
# Archive this round's data before resetting the live fields — reopening
# is for a fresh attempt, not for erasing what Blue Team already saw
# about the first one.
archived_round = TestRoundHistory(
test_id=test.id, team="red", round_number=test.red_round_number or 1,
started_at=test.red_started_at, ended_at=datetime.utcnow(),
paused_seconds=test.red_paused_seconds or 0,
procedure_text=test.procedure_text, tool_used=test.tool_used,
attack_success=test.attack_success, red_summary=test.red_summary,
execution_start_time=test.execution_start_time, execution_end_time=test.execution_end_time,
review_notes=notes.strip(), reviewed_by=user.id,
)
db.add(archived_round)
entity = TestEntity.from_orm(test) entity = TestEntity.from_orm(test)
entity.reopen_red_review() entity.reopen_red_review()
entity.apply_to(test) entity.apply_to(test)
test.red_review_by = user.id test.red_review_by = user.id
test.red_review_at = datetime.utcnow() test.red_review_at = datetime.utcnow()
test.red_review_notes = notes.strip() test.red_review_notes = notes.strip()
test.red_round_number = (test.red_round_number or 1) + 1
# New sheet — the operator fills these in fresh for this round; the
# values that were just archived stay visible via round_history.
test.procedure_text = None
test.tool_used = None
test.attack_success = None
test.red_summary = None
test.execution_start_time = None
test.execution_end_time = None
db.flush() db.flush()
log_action( log_action(
db, user_id=user.id, action="reopen_red_review", db, user_id=user.id, action="reopen_red_review",
entity_type="test", entity_id=test.id, entity_type="test", entity_id=test.id,
details={"notes": notes, "test_name": test.name}, details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
) )
if test.red_tech_assignee: if test.red_tech_assignee:
@@ -409,11 +433,12 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True) logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
try: try:
from app.services.jira_service import push_test_event from app.services.jira_service import push_test_event, push_round_archived
original_operator = ( original_operator = (
db.query(User).filter(User.id == test.red_tech_assignee).first() db.query(User).filter(User.id == test.red_tech_assignee).first()
if test.red_tech_assignee else None if test.red_tech_assignee else None
) )
push_round_archived(db, test, user, round_data=archived_round)
push_test_event( push_test_event(
db, test, user, "red_executing", db, test, user, "red_executing",
extra={"notes": notes.strip()}, assignee=original_operator, extra={"notes": notes.strip()}, assignee=original_operator,
@@ -594,6 +619,19 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
if not notes or not notes.strip(): if not notes or not notes.strip():
raise InvalidOperationError("A comment is required when reopening a test for rework") raise InvalidOperationError("A comment is required when reopening a test for rework")
# Archive this round's data before resetting the live fields — same
# reasoning as the red side: don't lose what was already evaluated.
archived_round = TestRoundHistory(
test_id=test.id, team="blue", round_number=test.blue_round_number or 1,
started_at=test.blue_work_started_at, ended_at=datetime.utcnow(),
paused_seconds=test.blue_paused_seconds or 0,
detection_result=test.detection_result, containment_result=test.containment_result,
detection_time=test.detection_time, containment_time=test.containment_time,
blue_summary=test.blue_summary,
review_notes=notes.strip(), reviewed_by=user.id,
)
db.add(archived_round)
entity = TestEntity.from_orm(test) entity = TestEntity.from_orm(test)
entity.reopen_blue_review() entity.reopen_blue_review()
entity.apply_to(test) entity.apply_to(test)
@@ -601,12 +639,20 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
test.blue_review_by = user.id test.blue_review_by = user.id
test.blue_review_at = datetime.utcnow() test.blue_review_at = datetime.utcnow()
test.blue_review_notes = notes.strip() test.blue_review_notes = notes.strip()
test.blue_round_number = (test.blue_round_number or 1) + 1
# New sheet for this round; the archived values above remain visible
# via round_history.
test.detection_result = None
test.containment_result = None
test.detection_time = None
test.containment_time = None
test.blue_summary = None
db.flush() db.flush()
log_action( log_action(
db, user_id=user.id, action="reopen_blue_review", db, user_id=user.id, action="reopen_blue_review",
entity_type="test", entity_id=test.id, entity_type="test", entity_id=test.id,
details={"notes": notes, "test_name": test.name}, details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
) )
if test.blue_tech_assignee: if test.blue_tech_assignee:
@@ -621,7 +667,8 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True) logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
try: try:
from app.services.jira_service import push_test_event from app.services.jira_service import push_test_event, push_round_archived
push_round_archived(db, test, user, round_data=archived_round)
push_test_event(db, test, user, "blue_evaluating", extra={"notes": notes.strip()}) push_test_event(db, test, user, "blue_evaluating", extra={"notes": notes.strip()})
except Exception as e: except Exception as e:
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True) logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
+41 -4
View File
@@ -74,7 +74,10 @@ def _make_test(**overrides):
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client") @patch("app.services.jira_service.get_admin_jira_client")
def test_push_pause_event_sets_on_hold(mock_get_client, mock_configured, db): def test_push_pause_event_does_not_change_jira_status(mock_get_client, mock_configured, db):
"""A timer pause is not the same as a real Hold — it must never flip the
Jira issue to 'On Hold'. Only push_hold_event (a genuine Hold action) is
allowed to change status. Regression: pausing used to set 'On Hold'."""
mock_jira = MagicMock() mock_jira = MagicMock()
mock_get_client.return_value = mock_jira mock_get_client.return_value = mock_jira
test = _make_test() test = _make_test()
@@ -84,13 +87,13 @@ def test_push_pause_event_sets_on_hold(mock_get_client, mock_configured, db):
jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=False) jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=False)
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "On Hold") mock_jira.set_issue_status.assert_not_called()
mock_jira.issue_add_comment.assert_called_once() mock_jira.issue_add_comment.assert_called_once()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client") @patch("app.services.jira_service.get_admin_jira_client")
def test_push_pause_event_resume_sets_in_progress(mock_get_client, mock_configured, db): def test_push_pause_event_resume_does_not_change_jira_status(mock_get_client, mock_configured, db):
mock_jira = MagicMock() mock_jira = MagicMock()
mock_get_client.return_value = mock_jira mock_get_client.return_value = mock_jira
test = _make_test() test = _make_test()
@@ -100,7 +103,8 @@ def test_push_pause_event_resume_sets_in_progress(mock_get_client, mock_configur
jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=True) jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=True)
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "In Progress") mock_jira.set_issue_status.assert_not_called()
mock_jira.issue_add_comment.assert_called_once()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@@ -118,6 +122,39 @@ def test_push_hold_event_sets_blocked(mock_get_client, mock_configured, db):
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "Blocked") mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "Blocked")
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_round_archived_posts_comment_without_changing_status(mock_get_client, mock_configured, db):
"""Archiving a round before reopen must leave a permanent Jira comment
(custom fields get overwritten by the next round, comments don't) but
must not touch the issue's status — that's push_test_event's job."""
from app.domain.enums import AttackSuccessResult
from app.models.test_round_history import TestRoundHistory
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
round_data = TestRoundHistory(
test_id=test.id, team="red", round_number=1,
attack_success=AttackSuccessResult.successful, red_summary="Got a shell.",
procedure_text="ran mimikatz", tool_used="mimikatz",
review_notes="Evidence was incomplete, redo with screenshots",
)
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
jira_service.push_round_archived(db, test, MagicMock(username="redlead"), round_data=round_data)
mock_jira.set_issue_status.assert_not_called()
mock_jira.issue_add_comment.assert_called_once()
comment = mock_jira.issue_add_comment.call_args[0][1]
assert "Round 1" in comment
assert "Got a shell." in comment
assert "Evidence was incomplete" in comment
@pytest.mark.parametrize( @pytest.mark.parametrize(
"new_state,expected_status", "new_state,expected_status",
[ [
+90
View File
@@ -142,6 +142,21 @@ def _make_test(state: TestState = TestState.draft, **kwargs) -> MagicMock:
t.paused_at = kwargs.get("paused_at", None) t.paused_at = kwargs.get("paused_at", None)
t.red_paused_seconds = kwargs.get("red_paused_seconds", 0) t.red_paused_seconds = kwargs.get("red_paused_seconds", 0)
t.blue_paused_seconds = kwargs.get("blue_paused_seconds", 0) t.blue_paused_seconds = kwargs.get("blue_paused_seconds", 0)
t.red_round_number = kwargs.get("red_round_number", 1)
t.blue_round_number = kwargs.get("blue_round_number", 1)
t.procedure_text = kwargs.get("procedure_text", None)
t.tool_used = kwargs.get("tool_used", None)
t.attack_success = kwargs.get("attack_success", None)
t.red_summary = kwargs.get("red_summary", None)
t.execution_start_time = kwargs.get("execution_start_time", None)
t.execution_end_time = kwargs.get("execution_end_time", None)
t.detection_result = kwargs.get("detection_result", None)
t.containment_result = kwargs.get("containment_result", None)
t.detection_time = kwargs.get("detection_time", None)
t.containment_time = kwargs.get("containment_time", None)
t.blue_summary = kwargs.get("blue_summary", None)
t.red_tech_assignee = kwargs.get("red_tech_assignee", None)
t.blue_tech_assignee = kwargs.get("blue_tech_assignee", None)
return t return t
@@ -723,6 +738,81 @@ class TestReviewDecisions:
args, kwargs = mock_push.call_args args, kwargs = mock_push.call_args
assert args[3] == "blue_evaluating" assert args[3] == "blue_evaluating"
@patch("app.services.test_workflow_service.log_action")
def test_reopen_red_review_archives_round_and_resets_fields(self, mock_log):
"""Reopening must not erase what Red already submitted — it archives
the round (visible via round_history) and only then blanks the live
fields for a fresh attempt, bumping the round counter."""
from app.models.test_round_history import TestRoundHistory
test = _make_test(
TestState.red_review,
procedure_text="ran mimikatz", tool_used="mimikatz",
attack_success="successful", red_summary="Got a shell.",
)
reviewer = _make_user("red_lead")
db = _make_db()
with patch("app.services.jira_service.push_test_event"), \
patch("app.services.jira_service.push_round_archived"):
from app.services.test_workflow_service import reopen_red_review
reopen_red_review(db, test, reviewer, notes="incomplete evidence")
archived = [c.args[0] for c in db.add.call_args_list if isinstance(c.args[0], TestRoundHistory)]
assert len(archived) == 1
assert archived[0].team == "red"
assert archived[0].round_number == 1
assert archived[0].attack_success == "successful"
assert archived[0].red_summary == "Got a shell."
assert archived[0].review_notes == "incomplete evidence"
assert test.procedure_text is None
assert test.tool_used is None
assert test.attack_success is None
assert test.red_summary is None
assert test.red_round_number == 2
@patch("app.services.test_workflow_service.log_action")
def test_reopen_blue_review_archives_round_and_resets_fields(self, mock_log):
from app.models.test_round_history import TestRoundHistory
test = _make_test(
TestState.blue_review,
detection_result="detected", blue_summary="Caught it via EDR.",
)
reviewer = _make_user("blue_lead")
db = _make_db()
with patch("app.services.jira_service.push_test_event"), \
patch("app.services.jira_service.push_round_archived"):
from app.services.test_workflow_service import reopen_blue_review
reopen_blue_review(db, test, reviewer, notes="missing containment steps")
archived = [c.args[0] for c in db.add.call_args_list if isinstance(c.args[0], TestRoundHistory)]
assert len(archived) == 1
assert archived[0].team == "blue"
assert archived[0].round_number == 1
assert archived[0].detection_result == "detected"
assert archived[0].blue_summary == "Caught it via EDR."
assert archived[0].review_notes == "missing containment steps"
assert test.detection_result is None
assert test.blue_summary is None
assert test.blue_round_number == 2
@patch("app.services.test_workflow_service.log_action")
def test_reopen_red_review_pushes_round_archived_comment_to_jira(self, mock_log):
test = _make_test(TestState.red_review)
reviewer = _make_user("red_lead")
db = _make_db()
with patch("app.services.jira_service.push_test_event"), \
patch("app.services.jira_service.push_round_archived") as mock_archive:
from app.services.test_workflow_service import reopen_red_review
reopen_red_review(db, test, reviewer, notes="redo it")
mock_archive.assert_called_once()
class TestStartBlueWork: class TestStartBlueWork:
@patch("app.services.jira_service.push_bt_work_started") @patch("app.services.jira_service.push_bt_work_started")
+76 -5
View File
@@ -12,9 +12,9 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Clock, Sword, Shield, CheckCircle, XCircle, Timer, Clock, Sword, Shield, CheckCircle, XCircle, Timer, History,
} from "lucide-react"; } from "lucide-react";
import type { Test } from "../types/models"; import type { Test, TestRoundHistory } from "../types/models";
import { listTestWorklogs, type Worklog } from "../api/worklogs"; import { listTestWorklogs, type Worklog } from "../api/worklogs";
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
@@ -130,6 +130,54 @@ function TempoSyncBadge({
); );
} }
// ── Archived round card ──────────────────────────────────────────────
function ArchivedRoundCard({ round }: { round: TestRoundHistory }) {
const duration =
round.started_at && round.ended_at
? durationSeconds(round.started_at, round.ended_at, round.paused_seconds)
: null;
const isRed = round.team === "red";
return (
<div className="mb-2 rounded-lg border border-gray-800 bg-gray-950/60 p-3">
<div className="flex items-center gap-2 flex-wrap">
<History className="h-3.5 w-3.5 text-gray-500" />
<span className="text-xs font-semibold text-gray-300">
{isRed ? "Red" : "Blue"} Round {round.round_number} (archived)
</span>
{duration != null && (
<span className="ml-auto text-xs font-semibold text-gray-500">
{fmtDuration(duration)}
</span>
)}
</div>
{round.started_at && (
<p className="mt-0.5 text-xs text-gray-600">{fmtTs(round.started_at)}</p>
)}
<div className="mt-1.5 space-y-0.5 text-xs text-gray-400">
{isRed ? (
<>
{round.attack_success && <p>Attack success: <span className="text-gray-300">{round.attack_success}</span></p>}
{round.red_summary && <p className="text-gray-500">{round.red_summary}</p>}
</>
) : (
<>
{round.detection_result && <p>Detection: <span className="text-gray-300">{round.detection_result}</span></p>}
{round.blue_summary && <p className="text-gray-500">{round.blue_summary}</p>}
</>
)}
{round.review_notes && (
<p className="mt-1 border-l-2 border-gray-700 pl-2 text-gray-500">
Reopened: {round.review_notes}
</p>
)}
</div>
</div>
);
}
// ── Main component ──────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────
interface TestPhaseTimelineProps { interface TestPhaseTimelineProps {
@@ -148,8 +196,14 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
blue_validated_at, blue_validated_at,
red_validation_status, red_validation_status,
blue_validation_status, blue_validation_status,
red_round_number,
blue_round_number,
round_history,
} = test; } = test;
const redArchivedRounds = (round_history ?? []).filter((r) => r.team === "red");
const blueArchivedRounds = (round_history ?? []).filter((r) => r.team === "blue");
// Fetch worklogs when testId is provided // Fetch worklogs when testId is provided
const { data: worklogs = [] } = useQuery({ const { data: worklogs = [] } = useQuery({
queryKey: ["worklogs", "test", testId], queryKey: ["worklogs", "test", testId],
@@ -183,7 +237,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
const hasBlueValidation = !!blue_validated_at; const hasBlueValidation = !!blue_validated_at;
const anyPhase = const anyPhase =
hasRedExec || hasBlueQueue || hasBlueEval || hasRedValidation || hasBlueValidation; hasRedExec || hasBlueQueue || hasBlueEval || hasRedValidation || hasBlueValidation ||
redArchivedRounds.length > 0 || blueArchivedRounds.length > 0;
if (!anyPhase) return null; if (!anyPhase) return null;
@@ -198,12 +253,20 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
Phase Timeline Phase Timeline
</h2> </h2>
{redArchivedRounds.length > 0 && (
<div className="mb-3">
{redArchivedRounds.map((round) => (
<ArchivedRoundCard key={round.id} round={round} />
))}
</div>
)}
<div className="relative space-y-0"> <div className="relative space-y-0">
{hasRedExec && ( {hasRedExec && (
<PhaseRow <PhaseRow
dotClass="border-orange-500/60" dotClass="border-orange-500/60"
icon={<Sword className="h-3 w-3 text-orange-400 inline" />} icon={<Sword className="h-3 w-3 text-orange-400 inline" />}
label="Red Team Execution" label={red_round_number > 1 ? `Red Team Execution (Round ${red_round_number})` : "Red Team Execution"}
startTs={red_started_at} startTs={red_started_at}
duration={redExecSecs} duration={redExecSecs}
isLast={++phaseIdx === lastIdx} isLast={++phaseIdx === lastIdx}
@@ -215,6 +278,14 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
/> />
)} )}
{blueArchivedRounds.length > 0 && (
<div className="my-2 ml-6">
{blueArchivedRounds.map((round) => (
<ArchivedRoundCard key={round.id} round={round} />
))}
</div>
)}
{hasBlueQueue && ( {hasBlueQueue && (
<PhaseRow <PhaseRow
dotClass="border-yellow-500/60" dotClass="border-yellow-500/60"
@@ -230,7 +301,7 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
<PhaseRow <PhaseRow
dotClass="border-indigo-500/60" dotClass="border-indigo-500/60"
icon={<Shield className="h-3 w-3 text-indigo-400 inline" />} icon={<Shield className="h-3 w-3 text-indigo-400 inline" />}
label="Blue Evaluation" label={blue_round_number > 1 ? `Blue Evaluation (Round ${blue_round_number})` : "Blue Evaluation"}
startTs={blue_work_started_at} startTs={blue_work_started_at}
duration={blueEvalSecs} duration={blueEvalSecs}
isLast={++phaseIdx === lastIdx} isLast={++phaseIdx === lastIdx}
+30
View File
@@ -123,6 +123,8 @@ export interface Test {
paused_at: string | null; paused_at: string | null;
red_paused_seconds: number; red_paused_seconds: number;
blue_paused_seconds: number; blue_paused_seconds: number;
red_round_number: number;
blue_round_number: number;
// Remediation fields // Remediation fields
remediation_steps: string | null; remediation_steps: string | null;
@@ -165,6 +167,34 @@ export interface Test {
// Separated evidences // Separated evidences
red_evidences: Evidence[]; red_evidences: Evidence[];
blue_evidences: Evidence[]; blue_evidences: Evidence[];
// Archived rounds — full history, oldest first
round_history: TestRoundHistory[];
}
// ── Round history (archived on reopen) ──────────────────────────────
export interface TestRoundHistory {
id: string;
team: "red" | "blue";
round_number: number;
started_at: string | null;
ended_at: string | null;
paused_seconds: number;
procedure_text: string | null;
tool_used: string | null;
attack_success: AttackSuccessResult | null;
red_summary: string | null;
execution_start_time: string | null;
execution_end_time: string | null;
detection_result: TestResult | null;
containment_result: ContainmentResult | null;
detection_time: string | null;
containment_time: string | null;
blue_summary: string | null;
review_notes: string | null;
reviewed_by: string | null;
archived_at: string | null;
} }
// ── Evidence (v2 — with team) ────────────────────────────────────── // ── Evidence (v2 — with team) ──────────────────────────────────────