From 8fe38dc6613bf9c80af7cf64687cc535d2dd14d3 Mon Sep 17 00:00:00 2001 From: kitos Date: Mon, 13 Jul 2026 12:08:45 +0200 Subject: [PATCH] feat(tests): archive round history on reopen, stop pausing from setting Jira On Hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../versions/b061_test_round_history.py | 64 +++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/test.py | 8 ++ backend/app/models/test_round_history.py | 60 +++++++++++++ backend/app/schemas/test.py | 42 +++++++++ backend/app/services/jira_service.py | 70 ++++++++++++--- backend/app/services/test_crud_service.py | 5 +- backend/app/services/test_workflow_service.py | 55 +++++++++++- backend/tests/test_jira_service.py | 45 +++++++++- backend/tests/test_workflow.py | 90 +++++++++++++++++++ frontend/src/components/TestPhaseTimeline.tsx | 81 +++++++++++++++-- frontend/src/types/models.ts | 30 +++++++ 12 files changed, 526 insertions(+), 26 deletions(-) create mode 100644 backend/alembic/versions/b061_test_round_history.py create mode 100644 backend/app/models/test_round_history.py diff --git a/backend/alembic/versions/b061_test_round_history.py b/backend/alembic/versions/b061_test_round_history.py new file mode 100644 index 0000000..b16ddc1 --- /dev/null +++ b/backend/alembic/versions/b061_test_round_history.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 695a324..7f0cad5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/test.py b/backend/app/models/test.py index 7df0710..21bc7e9 100644 --- a/backend/app/models/test.py +++ b/backend/app/models/test.py @@ -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]) diff --git a/backend/app/models/test_round_history.py b/backend/app/models/test_round_history.py new file mode 100644 index 0000000..95916e8 --- /dev/null +++ b/backend/app/models/test_round_history.py @@ -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]) diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index a06cfef..36302fd 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -101,6 +101,36 @@ class TestBlueUpdate(BaseModel): 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 ──────────────────────────────────────────── @@ -269,6 +299,8 @@ class TestOut(BaseModel): red_paused_seconds: int = 0 # Assign blue_paused_seconds = 0 blue_paused_seconds: int = 0 + red_round_number: int = 1 + blue_round_number: int = 1 # Remediation fields remediation_steps: str | None = None @@ -313,6 +345,9 @@ class TestOut(BaseModel): red_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_validator(mode="before") @@ -369,4 +404,11 @@ class TestOut(BaseModel): obj.__dict__["red_evidences"] = red_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 diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 6062fd4..7604a4e 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -1025,11 +1025,13 @@ def push_hold_event( 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 - operator stepping away briefly) maps to "On Hold", while an explicit - hold (something external blocking progress) maps to "Blocked". + Distinct from :func:`push_hold_event` — a short timer pause (the timer + sitting idle between rework rounds, or the operator briefly stepping + 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. """ 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: 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: 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) 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) +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: """Transition the Jira issue to "In Progress" when a blue tech picks up a queued test to start evaluating. diff --git a/backend/app/services/test_crud_service.py b/backend/app/services/test_crud_service.py index e5bfea0..e0b2690 100644 --- a/backend/app/services/test_crud_service.py +++ b/backend/app/services/test_crud_service.py @@ -393,7 +393,10 @@ def get_test_detail(db: Session, test_id: uuid.UUID) -> Test: # Assign test = ( 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) # Chain .first() call .first() diff --git a/backend/app/services/test_workflow_service.py b/backend/app/services/test_workflow_service.py index a5a4395..83f8465 100644 --- a/backend/app/services/test_workflow_service.py +++ b/backend/app/services/test_workflow_service.py @@ -32,6 +32,7 @@ from app.models.campaign import Campaign, CampaignTest from app.models.enums import TestState, TeamSide from app.models.evidence import Evidence from app.models.test import Test +from app.models.test_round_history import TestRoundHistory from app.models.user import User from app.services.audit_service import log_action 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(): 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.reopen_red_review() entity.apply_to(test) test.red_review_by = user.id test.red_review_at = datetime.utcnow() 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() log_action( db, user_id=user.id, action="reopen_red_review", 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: @@ -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) try: - from app.services.jira_service import push_test_event + from app.services.jira_service import push_test_event, push_round_archived original_operator = ( db.query(User).filter(User.id == test.red_tech_assignee).first() if test.red_tech_assignee else None ) + push_round_archived(db, test, user, round_data=archived_round) push_test_event( db, test, user, "red_executing", 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(): 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.reopen_blue_review() 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_at = datetime.utcnow() 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() log_action( db, user_id=user.id, action="reopen_blue_review", 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: @@ -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) 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()}) except Exception as e: logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True) diff --git a/backend/tests/test_jira_service.py b/backend/tests/test_jira_service.py index 5222929..aa4692f 100644 --- a/backend/tests/test_jira_service.py +++ b/backend/tests/test_jira_service.py @@ -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.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_get_client.return_value = mock_jira 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) - 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() @patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @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_get_client.return_value = mock_jira 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) - 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) @@ -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") +@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( "new_state,expected_status", [ diff --git a/backend/tests/test_workflow.py b/backend/tests/test_workflow.py index 1bd985b..488eef3 100644 --- a/backend/tests/test_workflow.py +++ b/backend/tests/test_workflow.py @@ -142,6 +142,21 @@ def _make_test(state: TestState = TestState.draft, **kwargs) -> MagicMock: t.paused_at = kwargs.get("paused_at", None) t.red_paused_seconds = kwargs.get("red_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 @@ -723,6 +738,81 @@ class TestReviewDecisions: args, kwargs = mock_push.call_args 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: @patch("app.services.jira_service.push_bt_work_started") diff --git a/frontend/src/components/TestPhaseTimeline.tsx b/frontend/src/components/TestPhaseTimeline.tsx index 3336d80..195f396 100644 --- a/frontend/src/components/TestPhaseTimeline.tsx +++ b/frontend/src/components/TestPhaseTimeline.tsx @@ -12,9 +12,9 @@ import { useQuery } from "@tanstack/react-query"; import { - Clock, Sword, Shield, CheckCircle, XCircle, Timer, + Clock, Sword, Shield, CheckCircle, XCircle, Timer, History, } from "lucide-react"; -import type { Test } from "../types/models"; +import type { Test, TestRoundHistory } from "../types/models"; import { listTestWorklogs, type Worklog } from "../api/worklogs"; // ── 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 ( +
+
+ + + {isRed ? "Red" : "Blue"} Round {round.round_number} (archived) + + {duration != null && ( + + {fmtDuration(duration)} + + )} +
+ {round.started_at && ( +

{fmtTs(round.started_at)}

+ )} +
+ {isRed ? ( + <> + {round.attack_success &&

Attack success: {round.attack_success}

} + {round.red_summary &&

{round.red_summary}

} + + ) : ( + <> + {round.detection_result &&

Detection: {round.detection_result}

} + {round.blue_summary &&

{round.blue_summary}

} + + )} + {round.review_notes && ( +

+ Reopened: {round.review_notes} +

+ )} +
+
+ ); +} + // ── Main component ──────────────────────────────────────────────────── interface TestPhaseTimelineProps { @@ -148,8 +196,14 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro blue_validated_at, red_validation_status, blue_validation_status, + red_round_number, + blue_round_number, + round_history, } = 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 const { data: worklogs = [] } = useQuery({ queryKey: ["worklogs", "test", testId], @@ -183,7 +237,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro const hasBlueValidation = !!blue_validated_at; const anyPhase = - hasRedExec || hasBlueQueue || hasBlueEval || hasRedValidation || hasBlueValidation; + hasRedExec || hasBlueQueue || hasBlueEval || hasRedValidation || hasBlueValidation || + redArchivedRounds.length > 0 || blueArchivedRounds.length > 0; if (!anyPhase) return null; @@ -198,12 +253,20 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro Phase Timeline + {redArchivedRounds.length > 0 && ( +
+ {redArchivedRounds.map((round) => ( + + ))} +
+ )} +
{hasRedExec && ( } - label="Red Team Execution" + label={red_round_number > 1 ? `Red Team Execution (Round ${red_round_number})` : "Red Team Execution"} startTs={red_started_at} duration={redExecSecs} isLast={++phaseIdx === lastIdx} @@ -215,6 +278,14 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro /> )} + {blueArchivedRounds.length > 0 && ( +
+ {blueArchivedRounds.map((round) => ( + + ))} +
+ )} + {hasBlueQueue && ( } - label="Blue Evaluation" + label={blue_round_number > 1 ? `Blue Evaluation (Round ${blue_round_number})` : "Blue Evaluation"} startTs={blue_work_started_at} duration={blueEvalSecs} isLast={++phaseIdx === lastIdx} diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index b0eef2b..b0754a8 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -123,6 +123,8 @@ export interface Test { paused_at: string | null; red_paused_seconds: number; blue_paused_seconds: number; + red_round_number: number; + blue_round_number: number; // Remediation fields remediation_steps: string | null; @@ -165,6 +167,34 @@ export interface Test { // Separated evidences red_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) ──────────────────────────────────────