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
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:
@@ -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")
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) ──────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user