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

- Reopening a test for rework (red or blue) now archives the round that's
  ending into a new test_round_history table before resetting the live
  fields — procedure/results/detection data is preserved instead of
  overwritten, and Phase Timeline renders every past round alongside the
  current one, so Blue Team keeps visibility into earlier Red attempts.
- Each archived round also posts a permanent Jira comment (push_round_archived)
  since Jira's custom fields only ever show the latest value once the next
  round resubmits — the comment thread is what preserves full history there.
- push_pause_event no longer transitions the Jira issue to "On Hold" — a
  timer pause is not a real Hold, and flipping Jira status for it was
  misleading. Only the explicit Hold action (push_hold_event) does that now.
This commit is contained in:
kitos
2026-07-13 12:08:45 +02:00
parent 0bb34f6834
commit 8fe38dc661
12 changed files with 526 additions and 26 deletions
+58 -12
View File
@@ -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.
+4 -1
View File
@@ -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()
+51 -4
View File
@@ -32,6 +32,7 @@ from app.models.campaign import Campaign, CampaignTest
from app.models.enums import TestState, TeamSide
from app.models.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)