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