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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user