8fe38dc661
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.
1589 lines
57 KiB
Python
1589 lines
57 KiB
Python
"""Test workflow service — state-machine transitions for the Red/Blue validation flow.
|
|
|
|
Controls which state transitions are valid and exposes high-level helpers
|
|
for each step in the test lifecycle:
|
|
|
|
draft → red_executing → blue_evaluating → in_review → validated / rejected
|
|
↓
|
|
rejected → draft
|
|
|
|
Every public function validates the transition, mutates the test, and writes
|
|
an audit-log entry. The caller (router) is responsible for committing the
|
|
session via the Unit of Work pattern.
|
|
"""
|
|
|
|
# Import logging
|
|
import logging
|
|
|
|
# Import uuid
|
|
import uuid
|
|
|
|
# Import datetime from datetime
|
|
from datetime import datetime
|
|
|
|
# Import Session from sqlalchemy.orm
|
|
from sqlalchemy.orm import Session
|
|
|
|
# Import settings from app.config
|
|
from app.config import settings
|
|
from app.domain.exceptions import BusinessRuleViolation, InvalidOperationError
|
|
from app.domain.test_entity import TestEntity
|
|
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 (
|
|
notify_test_state_change,
|
|
create_notification,
|
|
notify_role_with_email,
|
|
)
|
|
|
|
# Assign logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Valid transition map
|
|
# ---------------------------------------------------------------------------
|
|
|
|
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
|
TestState.draft: [TestState.red_executing],
|
|
TestState.red_executing: [TestState.blue_evaluating],
|
|
TestState.blue_evaluating: [TestState.in_review],
|
|
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
|
TestState.disputed: [TestState.validated, TestState.rejected],
|
|
TestState.rejected: [TestState.draft],
|
|
TestState.validated: [], # terminal state
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def can_transition(test: Test, target_state: TestState) -> bool:
|
|
"""Return *True* if moving *test* to *target_state* is allowed.
|
|
|
|
Args:
|
|
test (Test): The test whose current state is being checked.
|
|
target_state (TestState): The state to transition to.
|
|
|
|
Returns:
|
|
bool: ``True`` if the transition is permitted by ``VALID_TRANSITIONS``.
|
|
"""
|
|
# Assign current = test.state if isinstance(test.state, TestState) else TestState(test...
|
|
current = test.state if isinstance(test.state, TestState) else TestState(test.state)
|
|
# Return target_state in VALID_TRANSITIONS.get(current, [])
|
|
return target_state in VALID_TRANSITIONS.get(current, [])
|
|
|
|
|
|
# Define function transition_state
|
|
def transition_state(
|
|
# Entry: db
|
|
db: Session,
|
|
# Entry: test
|
|
test: Test,
|
|
# Entry: target_state
|
|
target_state: TestState,
|
|
# Entry: user
|
|
user: User,
|
|
*,
|
|
# Entry: action_name
|
|
action_name: str = "transition_state",
|
|
# Entry: extra_details
|
|
extra_details: dict | None = None,
|
|
) -> Test:
|
|
"""Validate and perform a state transition, log it, and flush.
|
|
|
|
Delegates validation to :class:`TestEntity` which raises
|
|
:class:`InvalidStateTransition` (aliased as ``InvalidTransitionError``)
|
|
when the transition is illegal. The entity is authoritative for which
|
|
transitions are valid; the module-level ``VALID_TRANSITIONS`` dict is
|
|
kept temporarily for backward compatibility of ``can_transition()``.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test (Test): The test ORM object to transition.
|
|
target_state (TestState): Desired next state.
|
|
user (User): The user performing the transition (logged in audit).
|
|
action_name (str): Audit log action label; defaults to
|
|
``"transition_state"``.
|
|
extra_details (dict | None): Optional extra key-value pairs merged
|
|
into the audit log details.
|
|
|
|
Returns:
|
|
Test: The mutated test ORM object (state updated, flushed).
|
|
"""
|
|
# Assign entity = TestEntity.from_orm(test)
|
|
entity = TestEntity.from_orm(test)
|
|
# Assign previous_state = entity.transition_to(target_state)
|
|
previous_state = entity.transition_to(target_state)
|
|
|
|
# Assign test.state = entity.state
|
|
test.state = entity.state
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
|
|
# Assign details = {
|
|
details: dict = {
|
|
# Literal argument value
|
|
"previous_state": previous_state,
|
|
# Literal argument value
|
|
"new_state": target_state.value,
|
|
# Literal argument value
|
|
"test_name": test.name,
|
|
# Literal argument value
|
|
"technique_id": str(test.technique_id),
|
|
}
|
|
# Check: extra_details
|
|
if extra_details:
|
|
# Call details.update()
|
|
details.update(extra_details)
|
|
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=user.id,
|
|
# Keyword argument: action
|
|
action=action_name,
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details=details,
|
|
)
|
|
|
|
# Attempt the following; catch errors below
|
|
try:
|
|
# Call notify_test_state_change()
|
|
notify_test_state_change(db, test, target_state.value)
|
|
# Handle Exception
|
|
except Exception as e:
|
|
# Log warning: "Notification failed for test %s: %s", test.id, e
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle convenience functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_campaign_start_date(db: Session, test_id) -> datetime | None:
|
|
"""Return the scheduled start_date of the campaign *test_id* belongs to, if any."""
|
|
campaign = (
|
|
db.query(Campaign)
|
|
.join(CampaignTest, CampaignTest.campaign_id == Campaign.id)
|
|
.filter(CampaignTest.test_id == test_id)
|
|
.first()
|
|
)
|
|
return campaign.start_date if campaign else None
|
|
|
|
|
|
def start_execution(db: Session, test: Test, user: User) -> Test:
|
|
"""Move from ``draft`` → ``red_executing``."""
|
|
campaign_start_date = _get_campaign_start_date(db, test.id)
|
|
if campaign_start_date and campaign_start_date > datetime.utcnow():
|
|
raise BusinessRuleViolation(
|
|
"This test's campaign is scheduled to start on "
|
|
f"{campaign_start_date.date().isoformat()} — it cannot be started before then."
|
|
)
|
|
|
|
entity = TestEntity.from_orm(test)
|
|
# Call entity.start_execution()
|
|
entity.start_execution()
|
|
# Call entity.apply_to()
|
|
entity.apply_to(test)
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=user.id,
|
|
# Keyword argument: action
|
|
action="start_execution",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={
|
|
# Literal argument value
|
|
"previous_state": "draft",
|
|
# Literal argument value
|
|
"new_state": test.state.value,
|
|
# Literal argument value
|
|
"test_name": test.name,
|
|
# Literal argument value
|
|
"technique_id": str(test.technique_id),
|
|
},
|
|
)
|
|
|
|
# Attempt the following; catch errors below
|
|
try:
|
|
# Call notify_test_state_change()
|
|
notify_test_state_change(db, test, test.state.value)
|
|
# Handle Exception
|
|
except Exception as e:
|
|
# Log warning: "Notification failed for test %s: %s", test.id, e
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, user, "red_executing")
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_rt_started
|
|
push_rt_started(db, test)
|
|
except Exception as e:
|
|
logger.warning("Jira RT start date push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
return test
|
|
|
|
|
|
# Define function submit_red_evidence
|
|
def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|
"""Move from ``red_executing`` → ``blue_evaluating``.
|
|
|
|
Called by **red_tech** once they have finished documenting the attack.
|
|
Requires at least one Red Team evidence file to be uploaded.
|
|
Stops the Red Team timer and creates an automatic worklog.
|
|
Starts the Blue Team timer by recording ``blue_started_at``.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test (Test): The test whose red-team evidence is being submitted.
|
|
user (User): The red-team user submitting the evidence.
|
|
|
|
Returns:
|
|
Test: The mutated test with state advanced and blue timer started.
|
|
"""
|
|
# Evidence is mandatory before submitting
|
|
red_evidence_count = (
|
|
db.query(Evidence)
|
|
.filter(Evidence.test_id == test.id, Evidence.team == TeamSide.red)
|
|
.count()
|
|
)
|
|
if red_evidence_count == 0:
|
|
raise InvalidOperationError(
|
|
"Cannot submit to Blue Team: at least one Red Team evidence file must be uploaded first."
|
|
)
|
|
|
|
now = datetime.utcnow()
|
|
|
|
# Auto-resume if paused
|
|
paused_extra = 0
|
|
# Check: test.paused_at is not None
|
|
if test.paused_at is not None:
|
|
# Assign paused_extra = max(int((now - test.paused_at).total_seconds()), 0)
|
|
paused_extra = max(int((now - test.paused_at).total_seconds()), 0)
|
|
# Assign test.paused_at = None
|
|
test.paused_at = None
|
|
|
|
# Assign test = transition_state(
|
|
test = transition_state(
|
|
db, test, TestState.red_review, user,
|
|
# Keyword argument: action_name
|
|
action_name="submit_red_evidence",
|
|
)
|
|
|
|
# Create automatic worklog for Red Team phase (subtract paused time)
|
|
_create_phase_worklog(
|
|
db,
|
|
# Keyword argument: test
|
|
test=test,
|
|
# Keyword argument: user
|
|
user=user,
|
|
# Keyword argument: phase_started_at
|
|
phase_started_at=test.red_started_at,
|
|
# Keyword argument: phase_ended_at
|
|
phase_ended_at=now,
|
|
# Keyword argument: paused_seconds
|
|
paused_seconds=(test.red_paused_seconds or 0) + paused_extra,
|
|
# Keyword argument: activity_type
|
|
activity_type="red_team_execution",
|
|
# Keyword argument: description
|
|
description=f"Red Team execution: {test.name}",
|
|
)
|
|
|
|
reviewer = select_reviewer(
|
|
db, role="red_lead",
|
|
exclude_user_id=user.id if user.role == "red_lead" else None,
|
|
)
|
|
test.red_reviewer_assignee = reviewer.id
|
|
db.flush()
|
|
|
|
try:
|
|
create_notification(
|
|
db, user_id=reviewer.id, type="review_assigned",
|
|
title="Test awaiting your review",
|
|
message=f'Test "{test.name}" is waiting for your red-team review.',
|
|
entity_type="test", entity_id=test.id,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, user, "red_review", assignee=reviewer)
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_rt_submitted
|
|
push_rt_submitted(db, test)
|
|
except Exception as e:
|
|
logger.warning("Jira RT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
return test
|
|
|
|
|
|
def approve_red_review(db: Session, test: Test, user: User, notes: str | None = None) -> Test:
|
|
"""Red Lead approves the operator's work — moves red_review to blue_evaluating."""
|
|
entity = TestEntity.from_orm(test)
|
|
entity.approve_red_review()
|
|
entity.apply_to(test)
|
|
test.red_review_by = user.id
|
|
test.red_review_at = datetime.utcnow()
|
|
test.red_review_notes = notes
|
|
db.flush()
|
|
|
|
log_action(
|
|
db, user_id=user.id, action="approve_red_review",
|
|
entity_type="test", entity_id=test.id,
|
|
details={"notes": notes, "test_name": test.name},
|
|
)
|
|
|
|
try:
|
|
notify_test_state_change(db, test, "blue_evaluating")
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, user, "blue_evaluating")
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
return test
|
|
|
|
|
|
def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
|
"""Red Lead sends the operator's work back for rework — moves red_review to red_executing."""
|
|
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, "round_number": archived_round.round_number},
|
|
)
|
|
|
|
if test.red_tech_assignee:
|
|
try:
|
|
create_notification(
|
|
db, user_id=test.red_tech_assignee, type="test_reopened",
|
|
title="Test sent back for rework",
|
|
message=f'Test "{test.name}" was sent back by your Red Lead: {notes.strip()[:200]}',
|
|
entity_type="test", entity_id=test.id,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
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,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
return test
|
|
|
|
|
|
def start_blue_work(db: Session, test: Test, user: User) -> Test:
|
|
"""Mark that a blue tech has picked up this test to start evaluating.
|
|
|
|
Sets blue_work_started_at. Only valid in blue_evaluating state and
|
|
only if blue_work_started_at is not already set.
|
|
"""
|
|
if test.state != TestState.blue_evaluating:
|
|
raise InvalidOperationError(
|
|
f"Cannot start blue work in '{test.state.value}' state"
|
|
)
|
|
if test.blue_work_started_at is not None:
|
|
raise InvalidOperationError("Blue work already started")
|
|
|
|
test.blue_work_started_at = datetime.utcnow()
|
|
db.flush()
|
|
|
|
log_action(
|
|
db,
|
|
user_id=user.id,
|
|
action="start_blue_work",
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
details={"test_name": test.name},
|
|
)
|
|
|
|
try:
|
|
notify_test_state_change(db, test, "blue_work_started")
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_bt_started
|
|
push_bt_started(db, test)
|
|
except Exception as e:
|
|
logger.warning("Jira BT start date push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_bt_work_started
|
|
push_bt_work_started(db, test, user)
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
return test
|
|
|
|
|
|
# Define function submit_blue_evidence
|
|
def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|
"""Move from ``blue_evaluating`` → ``in_review``.
|
|
|
|
Called by **blue_tech** once they have finished documenting detection.
|
|
Requires at least one Blue Team evidence file to be uploaded.
|
|
Stops the Blue Team timer and creates an automatic worklog.
|
|
Uses blue_work_started_at as the phase start for Tempo if available,
|
|
otherwise falls back to blue_started_at (queue-entry timestamp).
|
|
"""
|
|
# Evidence is mandatory before submitting
|
|
blue_evidence_count = (
|
|
db.query(Evidence)
|
|
.filter(Evidence.test_id == test.id, Evidence.team == TeamSide.blue)
|
|
.count()
|
|
)
|
|
if blue_evidence_count == 0:
|
|
raise InvalidOperationError(
|
|
"Cannot submit for review: at least one Blue Team evidence file must be uploaded first."
|
|
)
|
|
|
|
now = datetime.utcnow()
|
|
|
|
# Auto-resume if paused
|
|
paused_extra = 0
|
|
# Check: test.paused_at is not None
|
|
if test.paused_at is not None:
|
|
# Assign paused_extra = max(int((now - test.paused_at).total_seconds()), 0)
|
|
paused_extra = max(int((now - test.paused_at).total_seconds()), 0)
|
|
# Assign test.paused_at = None
|
|
test.paused_at = None
|
|
|
|
# Assign test = transition_state(
|
|
test = transition_state(
|
|
db, test, TestState.blue_review, user,
|
|
# Keyword argument: action_name
|
|
action_name="submit_blue_evidence",
|
|
)
|
|
|
|
# Create automatic worklog for Blue Team phase (subtract paused time).
|
|
# Use blue_work_started_at (actual pick-up time) when available so the
|
|
# Tempo worklog reflects real working time, not just queue time.
|
|
_create_phase_worklog(
|
|
db,
|
|
# Keyword argument: test
|
|
test=test,
|
|
# Keyword argument: user
|
|
user=user,
|
|
phase_started_at=test.blue_work_started_at or test.blue_started_at,
|
|
phase_ended_at=now,
|
|
# Keyword argument: paused_seconds
|
|
paused_seconds=(test.blue_paused_seconds or 0) + paused_extra,
|
|
# Keyword argument: activity_type
|
|
activity_type="blue_team_evaluation",
|
|
# Keyword argument: description
|
|
description=f"Blue Team evaluation: {test.name}",
|
|
)
|
|
|
|
reviewer = select_reviewer(
|
|
db, role="blue_lead",
|
|
exclude_user_id=user.id if user.role == "blue_lead" else None,
|
|
)
|
|
test.blue_reviewer_assignee = reviewer.id
|
|
db.flush()
|
|
|
|
try:
|
|
create_notification(
|
|
db, user_id=reviewer.id, type="review_assigned",
|
|
title="Test awaiting your review",
|
|
message=f'Test "{test.name}" is waiting for your blue-team review.',
|
|
entity_type="test", entity_id=test.id,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, user, "blue_review", assignee=reviewer)
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_bt_submitted
|
|
push_bt_submitted(db, test)
|
|
except Exception as e:
|
|
logger.warning("Jira BT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
return test
|
|
|
|
|
|
def approve_blue_review(db: Session, test: Test, user: User, notes: str | None = None) -> Test:
|
|
"""Blue Lead approves the operator's work — moves blue_review to in_review."""
|
|
entity = TestEntity.from_orm(test)
|
|
entity.approve_blue_review()
|
|
entity.apply_to(test)
|
|
test.blue_review_by = user.id
|
|
test.blue_review_at = datetime.utcnow()
|
|
test.blue_review_notes = notes
|
|
db.flush()
|
|
|
|
log_action(
|
|
db, user_id=user.id, action="approve_blue_review",
|
|
entity_type="test", entity_id=test.id,
|
|
details={"notes": notes, "test_name": test.name},
|
|
)
|
|
|
|
try:
|
|
notify_test_state_change(db, test, "in_review")
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, user, "in_review")
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
return test
|
|
|
|
|
|
def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
|
"""Blue Lead sends the operator's work back for rework — moves blue_review to blue_evaluating."""
|
|
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)
|
|
test.blue_work_started_at = None # split responsibility: entity doesn't own this field
|
|
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, "round_number": archived_round.round_number},
|
|
)
|
|
|
|
if test.blue_tech_assignee:
|
|
try:
|
|
create_notification(
|
|
db, user_id=test.blue_tech_assignee, type="test_reopened",
|
|
title="Test sent back for rework",
|
|
message=f'Test "{test.name}" was sent back by your Blue Lead: {notes.strip()[:200]}',
|
|
entity_type="test", entity_id=test.id,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
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)
|
|
|
|
return test
|
|
|
|
|
|
def flag_blue_review_gap(db: Session, test: Test, user: User, system_gaps: str, notes: str | None = None) -> Test:
|
|
"""Blue Lead flags a capability gap — proceeds to in_review anyway (a retry can't fix a missing tool)."""
|
|
if not system_gaps or not system_gaps.strip():
|
|
raise InvalidOperationError("system_gaps description is required when flagging a capability gap")
|
|
|
|
entity = TestEntity.from_orm(test)
|
|
entity.flag_blue_review_gap()
|
|
entity.apply_to(test)
|
|
test.blue_review_by = user.id
|
|
test.blue_review_at = datetime.utcnow()
|
|
test.blue_review_notes = notes
|
|
test.system_gaps = system_gaps.strip()
|
|
db.flush()
|
|
|
|
log_action(
|
|
db, user_id=user.id, action="flag_blue_review_gap",
|
|
entity_type="test", entity_id=test.id,
|
|
details={"system_gaps": system_gaps, "notes": notes, "test_name": test.name},
|
|
)
|
|
|
|
try:
|
|
notify_test_state_change(db, test, "in_review")
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, user, "in_review")
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
return test
|
|
|
|
|
|
# Define function pause_timer
|
|
def pause_timer(db: Session, test: Test, user: User) -> Test:
|
|
"""Pause the active phase timer.
|
|
|
|
Can only be called when the test is in ``red_executing`` or
|
|
``blue_evaluating`` and is not already paused.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test (Test): The currently active test.
|
|
user (User): The user pausing the timer.
|
|
|
|
Returns:
|
|
Test: The mutated test with ``paused_at`` set to the current UTC time.
|
|
"""
|
|
# Check: test.state not in (TestState.red_executing, TestState.blue_evaluating)
|
|
if test.state not in (TestState.red_executing, TestState.blue_evaluating):
|
|
# Raise InvalidOperationError
|
|
raise InvalidOperationError(
|
|
f"Cannot pause timer in '{test.state.value}' state"
|
|
)
|
|
# Check: test.paused_at is not None
|
|
if test.paused_at is not None:
|
|
# Raise InvalidOperationError
|
|
raise InvalidOperationError("Timer is already paused")
|
|
|
|
# Assign test.paused_at = datetime.utcnow()
|
|
test.paused_at = datetime.utcnow()
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=user.id,
|
|
# Keyword argument: action
|
|
action="pause_timer",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={"state": test.state.value},
|
|
)
|
|
|
|
try:
|
|
from app.services.jira_service import push_pause_event
|
|
push_pause_event(db, test, user, resuming=False)
|
|
except Exception as e:
|
|
logger.warning("Jira pause push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function resume_timer
|
|
def resume_timer(db: Session, test: Test, user: User) -> Test:
|
|
"""Resume a paused phase timer.
|
|
|
|
Accumulates the paused duration into the appropriate counter so
|
|
it is subtracted from the final worklog.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test (Test): The paused test to resume.
|
|
user (User): The user resuming the timer.
|
|
|
|
Returns:
|
|
Test: The mutated test with ``paused_at`` cleared and accumulated
|
|
pause seconds updated.
|
|
"""
|
|
# Check: test.paused_at is None
|
|
if test.paused_at is None:
|
|
# Raise InvalidOperationError
|
|
raise InvalidOperationError("Timer is not paused")
|
|
|
|
# Assign now = datetime.utcnow()
|
|
now = datetime.utcnow()
|
|
# Assign paused_seconds = max(int((now - test.paused_at).total_seconds()), 0)
|
|
paused_seconds = max(int((now - test.paused_at).total_seconds()), 0)
|
|
|
|
# Check: test.state == TestState.red_executing
|
|
if test.state == TestState.red_executing:
|
|
# Assign test.red_paused_seconds = (test.red_paused_seconds or 0) + paused_seconds
|
|
test.red_paused_seconds = (test.red_paused_seconds or 0) + paused_seconds
|
|
# Alternative: test.state == TestState.blue_evaluating
|
|
elif test.state == TestState.blue_evaluating:
|
|
# Assign test.blue_paused_seconds = (test.blue_paused_seconds or 0) + paused_seconds
|
|
test.blue_paused_seconds = (test.blue_paused_seconds or 0) + paused_seconds
|
|
|
|
# Assign test.paused_at = None
|
|
test.paused_at = None
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=user.id,
|
|
# Keyword argument: action
|
|
action="resume_timer",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={"paused_seconds": paused_seconds, "state": test.state.value},
|
|
)
|
|
|
|
try:
|
|
from app.services.jira_service import push_pause_event
|
|
push_pause_event(db, test, user, resuming=True)
|
|
except Exception as e:
|
|
logger.warning("Jira resume push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
# Return test
|
|
return test
|
|
|
|
|
|
def select_reviewer(
|
|
db: Session,
|
|
*,
|
|
role: str,
|
|
exclude_user_id: uuid.UUID | None = None,
|
|
) -> User:
|
|
"""Pick the least-loaded active user with *role* to review a test.
|
|
|
|
Load is measured as the count of tests currently sitting in the
|
|
matching review state (``red_review`` for role ``red_lead``,
|
|
``blue_review`` for role ``blue_lead``) with that user set as the
|
|
reviewer. Ties broken by username for determinism.
|
|
|
|
Raises BusinessRuleViolation if no eligible reviewer exists (e.g. the
|
|
only lead is the person who executed the test, or there are no leads
|
|
with this role at all).
|
|
"""
|
|
review_state = TestState.red_review if role == "red_lead" else TestState.blue_review
|
|
reviewer_field = Test.red_reviewer_assignee if role == "red_lead" else Test.blue_reviewer_assignee
|
|
|
|
candidates_query = db.query(User).filter(User.role == role, User.is_active == True) # noqa: E712
|
|
if exclude_user_id is not None:
|
|
candidates_query = candidates_query.filter(User.id != exclude_user_id)
|
|
candidates = candidates_query.order_by(User.username).all()
|
|
|
|
if not candidates:
|
|
raise BusinessRuleViolation(
|
|
f"No available {role} to review this test (cannot self-review, "
|
|
f"and no other {role} is active)"
|
|
)
|
|
|
|
best_user = None
|
|
best_count = None
|
|
for candidate in candidates:
|
|
count = (
|
|
db.query(Test)
|
|
.filter(Test.state == review_state, reviewer_field == candidate.id)
|
|
.count()
|
|
)
|
|
if best_count is None or count < best_count:
|
|
best_user = candidate
|
|
best_count = count
|
|
|
|
return best_user
|
|
|
|
|
|
# Define function _create_phase_worklog
|
|
def _create_phase_worklog(
|
|
# Entry: db
|
|
db: Session,
|
|
*,
|
|
# Entry: test
|
|
test: Test,
|
|
# Entry: user
|
|
user: User,
|
|
# Entry: phase_started_at
|
|
phase_started_at: datetime | None,
|
|
# Entry: phase_ended_at
|
|
phase_ended_at: datetime,
|
|
# Entry: paused_seconds
|
|
paused_seconds: int = 0,
|
|
# Entry: activity_type
|
|
activity_type: str,
|
|
# Entry: description
|
|
description: str,
|
|
) -> None:
|
|
"""Create an automatic, integrity-hashed worklog for a completed phase.
|
|
|
|
Subtracts accumulated *paused_seconds* from the gross elapsed time
|
|
so the worklog reflects only active working time.
|
|
Also triggers Tempo sync if the test has a Jira link.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test (Test): The test for which the worklog is being created.
|
|
user (User): The user attributed to the worklog.
|
|
phase_started_at (datetime | None): Timestamp when the phase began;
|
|
if ``None`` the worklog is skipped with a warning.
|
|
phase_ended_at (datetime): Timestamp when the phase ended.
|
|
paused_seconds (int): Accumulated paused time in seconds to subtract
|
|
from gross elapsed time.
|
|
activity_type (str): Worklog activity type label (e.g.
|
|
``"red_team_execution"``).
|
|
description (str): Human-readable description for the worklog.
|
|
"""
|
|
# Check: not phase_started_at
|
|
if not phase_started_at:
|
|
# Log warning:
|
|
logger.warning(
|
|
# Literal argument value
|
|
"No phase start timestamp for test %s (%s), skipping worklog",
|
|
test.id, activity_type,
|
|
)
|
|
# Return control to caller
|
|
return
|
|
|
|
# Assign gross_seconds = int((phase_ended_at - phase_started_at).total_seconds())
|
|
gross_seconds = int((phase_ended_at - phase_started_at).total_seconds())
|
|
# Assign duration_seconds = max(gross_seconds - paused_seconds, 1)
|
|
duration_seconds = max(gross_seconds - paused_seconds, 1)
|
|
|
|
# Attempt the following; catch errors below
|
|
try:
|
|
# Import create_worklog from app.services.worklog_service
|
|
from app.services.worklog_service import create_worklog
|
|
|
|
# Assign wl = create_worklog(
|
|
wl = create_worklog(
|
|
db,
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: user_id
|
|
user_id=user.id,
|
|
# Keyword argument: activity_type
|
|
activity_type=activity_type,
|
|
# Keyword argument: started_at
|
|
started_at=phase_started_at,
|
|
# Keyword argument: ended_at
|
|
ended_at=phase_ended_at,
|
|
# Keyword argument: duration_seconds
|
|
duration_seconds=duration_seconds,
|
|
# Keyword argument: description
|
|
description=description,
|
|
)
|
|
# Log info:
|
|
logger.info(
|
|
# Literal argument value
|
|
"Auto-worklog created for test %s: %s, %ds (worklog %s)",
|
|
test.id, activity_type, duration_seconds, wl.id,
|
|
)
|
|
|
|
# Sync to Tempo: only red_team_execution, using the already-computed
|
|
# duration so the Tempo entry is identical to the Aegis worklog.
|
|
try:
|
|
# Import auto_log_test_worklog from app.services.tempo_service
|
|
from app.services.tempo_service import auto_log_test_worklog
|
|
tempo_result = auto_log_test_worklog(db, test, user, activity_type, duration_seconds)
|
|
if tempo_result and isinstance(tempo_result, dict):
|
|
wl.tempo_synced = datetime.utcnow()
|
|
wl.tempo_worklog_id = str(tempo_result.get("tempoWorklogId", ""))
|
|
db.flush()
|
|
except Exception as e:
|
|
# Log warning: "Tempo sync failed for worklog: %s", e, exc_info=T
|
|
logger.warning("Tempo sync failed for worklog: %s", e, exc_info=True)
|
|
|
|
# Handle Exception
|
|
except Exception as e:
|
|
# Log error: "Failed to create auto-worklog for test %s: %s", t
|
|
logger.error("Failed to create auto-worklog for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
|
|
# Define function validate_as_red_lead
|
|
def validate_as_red_lead(
|
|
# Entry: db
|
|
db: Session,
|
|
# Entry: test
|
|
test: Test,
|
|
# Entry: user
|
|
user: User,
|
|
# Entry: validation_status
|
|
validation_status: str,
|
|
# Entry: notes
|
|
notes: str | None = None,
|
|
) -> Test:
|
|
"""Record Red Lead's validation decision.
|
|
|
|
Delegates validation rules and state mutation entirely to
|
|
:meth:`TestEntity.validate_red`. If both leads have voted the
|
|
entity will also advance the test to ``validated`` or ``rejected``.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test (Test): The test being reviewed.
|
|
user (User): The red-lead user casting their vote.
|
|
validation_status (str): Validation decision, e.g. ``"approved"`` or
|
|
``"rejected"``.
|
|
notes (str | None): Optional freeform notes explaining the decision.
|
|
|
|
Returns:
|
|
Test: The mutated test with red-lead validation fields set.
|
|
"""
|
|
# Assign entity = TestEntity.from_orm(test)
|
|
entity = TestEntity.from_orm(test)
|
|
# Call entity.validate_red()
|
|
entity.validate_red(validation_status, by=user.id, notes=notes)
|
|
# Call entity.apply_to()
|
|
entity.apply_to(test)
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=user.id,
|
|
# Keyword argument: action
|
|
action="validate_as_red_lead",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={
|
|
# Literal argument value
|
|
"validation_status": validation_status,
|
|
# Literal argument value
|
|
"notes": notes,
|
|
# Literal argument value
|
|
"technique_id": str(test.technique_id),
|
|
},
|
|
)
|
|
|
|
_dispatch_dual_validation_effects(db, test, entity, actor=user)
|
|
return test
|
|
|
|
|
|
# Define function validate_as_blue_lead
|
|
def validate_as_blue_lead(
|
|
# Entry: db
|
|
db: Session,
|
|
# Entry: test
|
|
test: Test,
|
|
# Entry: user
|
|
user: User,
|
|
# Entry: validation_status
|
|
validation_status: str,
|
|
# Entry: notes
|
|
notes: str | None = None,
|
|
) -> Test:
|
|
"""Record Blue Lead's validation decision.
|
|
|
|
Delegates validation rules and state mutation entirely to
|
|
:meth:`TestEntity.validate_blue`. If both leads have voted the
|
|
entity will also advance the test to ``validated`` or ``rejected``.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test (Test): The test being reviewed.
|
|
user (User): The blue-lead user casting their vote.
|
|
validation_status (str): Validation decision, e.g. ``"approved"`` or
|
|
``"rejected"``.
|
|
notes (str | None): Optional freeform notes explaining the decision.
|
|
|
|
Returns:
|
|
Test: The mutated test with blue-lead validation fields set.
|
|
"""
|
|
# Assign entity = TestEntity.from_orm(test)
|
|
entity = TestEntity.from_orm(test)
|
|
# Call entity.validate_blue()
|
|
entity.validate_blue(validation_status, by=user.id, notes=notes)
|
|
# Call entity.apply_to()
|
|
entity.apply_to(test)
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=user.id,
|
|
# Keyword argument: action
|
|
action="validate_as_blue_lead",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={
|
|
# Literal argument value
|
|
"validation_status": validation_status,
|
|
# Literal argument value
|
|
"notes": notes,
|
|
# Literal argument value
|
|
"technique_id": str(test.technique_id),
|
|
},
|
|
)
|
|
|
|
_dispatch_dual_validation_effects(db, test, entity, actor=user)
|
|
return test
|
|
|
|
|
|
# Define function check_dual_validation
|
|
def check_dual_validation(db: Session, test: Test) -> Test:
|
|
"""Evaluate both leads' decisions and advance the test if both have voted.
|
|
|
|
All state mutation is delegated to :meth:`TestEntity.check_dual_validation`.
|
|
This function never assigns ``test.state`` directly.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test (Test): The test to evaluate.
|
|
|
|
Returns:
|
|
Test: The mutated test, potentially with state advanced to
|
|
``validated`` or ``rejected``.
|
|
"""
|
|
# Assign entity = TestEntity.from_orm(test)
|
|
entity = TestEntity.from_orm(test)
|
|
# Call entity.check_dual_validation()
|
|
entity.check_dual_validation()
|
|
# Call entity.apply_to()
|
|
entity.apply_to(test)
|
|
|
|
# Call _dispatch_dual_validation_effects()
|
|
_dispatch_dual_validation_effects(db, test, entity)
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function _dispatch_dual_validation_effects
|
|
def _dispatch_dual_validation_effects(
|
|
db: Session, test: Test, entity: TestEntity, actor: User | None = None
|
|
) -> None:
|
|
"""Dispatch side effects (notifications, cache, Jira) based on domain events."""
|
|
for event in entity.events:
|
|
# Check: event.name == "dual_validation_approved"
|
|
if event.name == "dual_validation_approved":
|
|
# Attempt the following; catch errors below
|
|
try:
|
|
# Import invalidate from app.services.score_cache
|
|
from app.services.score_cache import invalidate
|
|
# Call invalidate()
|
|
invalidate()
|
|
# Handle Exception
|
|
except Exception as e:
|
|
# Log warning: "Score cache invalidation failed: %s", e, exc_info
|
|
logger.warning("Score cache invalidation failed: %s", e, exc_info=True)
|
|
# Attempt the following; catch errors below
|
|
try:
|
|
# Call notify_test_state_change()
|
|
notify_test_state_change(db, test, "validated")
|
|
# Handle Exception
|
|
except Exception as e:
|
|
# Log warning:
|
|
logger.warning(
|
|
# Literal argument value
|
|
"Notification failed for test %s (validated): %s",
|
|
test.id, e, exc_info=True,
|
|
)
|
|
if actor:
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, actor, "validated")
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
elif event.name == "dual_validation_rejected":
|
|
# Attempt the following; catch errors below
|
|
try:
|
|
# Call notify_test_state_change()
|
|
notify_test_state_change(db, test, "rejected")
|
|
# Handle Exception
|
|
except Exception as e:
|
|
# Log warning:
|
|
logger.warning(
|
|
# Literal argument value
|
|
"Notification failed for test %s (rejected): %s",
|
|
test.id, e, exc_info=True,
|
|
)
|
|
if actor:
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, actor, "rejected")
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
elif event.name == "dual_validation_disputed":
|
|
if actor:
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, actor, "disputed")
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
# Notify the lead who APPROVED asking them to review the rejection
|
|
_notify_validation_conflict(db, test, actor)
|
|
# Notify managers too, in case the dispute stalls and needs escalation
|
|
try:
|
|
notify_role_with_email(
|
|
db,
|
|
role="manager",
|
|
type="validation_disputed",
|
|
title="Validation dispute needs oversight",
|
|
message=(
|
|
f'Test "{test.name}" has a validation dispute — one lead approved, '
|
|
f'the other rejected. Escalate if it does not resolve.'
|
|
),
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Manager dispute notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
|
|
def _notify_validation_conflict(db: Session, test: Test, actor: User | None) -> None:
|
|
"""Notify the lead who APPROVED about the other lead's rejection.
|
|
|
|
Tells them: 'The other lead rejected. Review their notes and either
|
|
change your vote to rejected or discuss with them to resolve.'
|
|
"""
|
|
|
|
red_approved = test.red_validation_status == "approved"
|
|
blue_approved = test.blue_validation_status == "approved"
|
|
|
|
# Identify who approved (they need to be notified)
|
|
approver_id = None
|
|
rejector_role = None
|
|
rejection_notes = None
|
|
|
|
if red_approved and test.blue_validation_status == "rejected":
|
|
approver_id = test.red_validated_by
|
|
rejector_role = "Blue Lead"
|
|
rejection_notes = test.blue_validation_notes
|
|
elif blue_approved and test.red_validation_status == "rejected":
|
|
approver_id = test.blue_validated_by
|
|
rejector_role = "Red Lead"
|
|
rejection_notes = test.red_validation_notes
|
|
|
|
if not approver_id:
|
|
return
|
|
|
|
notes_snippet = f': "{rejection_notes[:200]}"' if rejection_notes else ""
|
|
try:
|
|
create_notification(
|
|
db,
|
|
user_id=approver_id,
|
|
type="validation_conflict",
|
|
title="Validation conflict — action required",
|
|
message=(
|
|
f"{rejector_role} rejected test '{test.name}' while you approved it{notes_snippet}. "
|
|
f"Review their reason and either change your decision to 'rejected' "
|
|
f"or contact {rejector_role} to resolve the disagreement."
|
|
),
|
|
entity_type="test",
|
|
entity_id=test.id,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Failed to send conflict notification for test %s: %s",
|
|
test.id, e, exc_info=True,
|
|
)
|
|
|
|
|
|
def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes: str | None = None) -> Test:
|
|
"""Resolve a disputed test by flipping the approving vote to reject.
|
|
|
|
Called by the lead who originally approved, now agreeing with the other
|
|
lead's rejection. Unlike a plain rejection, the flipping lead identifies
|
|
WHICH team's work needs redoing — the test routes straight back to that
|
|
team's active queue (red_executing or blue_evaluating) instead of the
|
|
generic 'rejected' state that would force a full draft restart for both
|
|
teams.
|
|
"""
|
|
if target_team not in ("red", "blue"):
|
|
raise InvalidOperationError("target_team must be 'red' or 'blue'")
|
|
|
|
is_red_approver = user.role == "red_lead" and test.red_validation_status == "approved"
|
|
is_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved"
|
|
if not (is_red_approver or is_blue_approver):
|
|
raise InvalidOperationError(
|
|
"Only the lead who approved can flip their vote to resolve this dispute"
|
|
)
|
|
|
|
entity = TestEntity.from_orm(test)
|
|
entity.resolve_dispute_reject(target_team)
|
|
entity.apply_to(test)
|
|
|
|
if target_team == "blue":
|
|
test.blue_work_started_at = None # split responsibility: entity doesn't own this field
|
|
|
|
db.flush()
|
|
|
|
new_state = "red_executing" if target_team == "red" else "blue_evaluating"
|
|
|
|
log_action(
|
|
db, user_id=user.id, action="resolve_dispute",
|
|
entity_type="test", entity_id=test.id,
|
|
details={"target_team": target_team, "notes": notes, "test_name": test.name},
|
|
)
|
|
|
|
try:
|
|
notify_test_state_change(db, test, new_state)
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
operator_id = test.red_tech_assignee if target_team == "red" else test.blue_tech_assignee
|
|
if operator_id and notes:
|
|
try:
|
|
create_notification(
|
|
db, user_id=operator_id, type="test_reopened",
|
|
title="Test sent back for rework (validation dispute)",
|
|
message=f'Test "{test.name}" needs rework: {notes[:200]}',
|
|
entity_type="test", entity_id=test.id,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, user, new_state)
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
return test
|
|
|
|
|
|
# Define function handle_remediation_completed
|
|
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
|
|
"""Create a re-test when remediation is completed.
|
|
|
|
When a test's remediation_status changes to 'completed', this function
|
|
creates a new test (retest) with the same base data to verify that the
|
|
fix was effective.
|
|
|
|
Prevents infinite loops by enforcing ``MAX_RETEST_COUNT``.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test (Test): The test whose remediation was completed.
|
|
user (User): The user triggering the remediation completion.
|
|
|
|
Returns:
|
|
Test | None: The newly created retest, or ``None`` if the maximum
|
|
retest count has been reached.
|
|
"""
|
|
# Always reference the original test, not an intermediate retest
|
|
original_test_id = test.retest_of or test.id
|
|
|
|
# Check: test.retest_count >= settings.MAX_RETEST_COUNT
|
|
if test.retest_count >= settings.MAX_RETEST_COUNT:
|
|
# Max retests reached — notify and bail out
|
|
if test.created_by:
|
|
# Call create_notification()
|
|
create_notification(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=test.created_by,
|
|
# Keyword argument: type
|
|
type="max_retests_reached",
|
|
# Keyword argument: title
|
|
title="Maximum retests reached",
|
|
# Keyword argument: message
|
|
message=(
|
|
f'Test "{test.name}" has reached the maximum of '
|
|
f'{settings.MAX_RETEST_COUNT} retests. Manual review required.'
|
|
),
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
)
|
|
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=user.id,
|
|
# Keyword argument: action
|
|
action="max_retests_reached",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=test.id,
|
|
# Keyword argument: details
|
|
details={
|
|
# Literal argument value
|
|
"retest_count": test.retest_count,
|
|
# Literal argument value
|
|
"max_allowed": settings.MAX_RETEST_COUNT,
|
|
# Literal argument value
|
|
"original_test_id": str(original_test_id),
|
|
},
|
|
)
|
|
# Return None
|
|
return None
|
|
|
|
# Assign retest = Test(
|
|
retest = Test(
|
|
# Keyword argument: technique_id
|
|
technique_id=test.technique_id,
|
|
# Keyword argument: name
|
|
name=f"[Retest #{test.retest_count + 1}] {test.name.replace(f'[Retest #{test.retest_count}] ', '')}",
|
|
# Keyword argument: description
|
|
description=test.description,
|
|
# Keyword argument: platform
|
|
platform=test.platform,
|
|
# Keyword argument: procedure_text
|
|
procedure_text=test.procedure_text,
|
|
# Keyword argument: tool_used
|
|
tool_used=test.tool_used,
|
|
# Keyword argument: state
|
|
state=TestState.draft,
|
|
# Keyword argument: created_by
|
|
created_by=test.created_by,
|
|
# Keyword argument: retest_of
|
|
retest_of=original_test_id,
|
|
# Keyword argument: retest_count
|
|
retest_count=test.retest_count + 1,
|
|
)
|
|
# Stage new record(s) for database insertion
|
|
db.add(retest)
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
|
|
# Call log_action()
|
|
log_action(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=user.id,
|
|
# Keyword argument: action
|
|
action="create_retest",
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=retest.id,
|
|
# Keyword argument: details
|
|
details={
|
|
# Literal argument value
|
|
"original_test_id": str(original_test_id),
|
|
# Literal argument value
|
|
"retest_number": retest.retest_count,
|
|
# Literal argument value
|
|
"source_test_id": str(test.id),
|
|
},
|
|
)
|
|
|
|
# Notify the test creator and any red_tech users
|
|
if test.created_by:
|
|
# Call create_notification()
|
|
create_notification(
|
|
db,
|
|
# Keyword argument: user_id
|
|
user_id=test.created_by,
|
|
# Keyword argument: type
|
|
type="retest_created",
|
|
# Keyword argument: title
|
|
title="Re-test created",
|
|
# Keyword argument: message
|
|
message=(
|
|
f'A re-test has been automatically created for "{test.name}" '
|
|
f'after remediation was completed.'
|
|
),
|
|
# Keyword argument: entity_type
|
|
entity_type="test",
|
|
# Keyword argument: entity_id
|
|
entity_id=retest.id,
|
|
)
|
|
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
# Return retest
|
|
return retest
|
|
|
|
|
|
# Define function get_retest_chain
|
|
def get_retest_chain(db: Session, test_id: uuid.UUID) -> list[Test]:
|
|
"""Return the full chain of retests for a given test.
|
|
|
|
Includes the original test and all subsequent retests, ordered
|
|
by retest_count.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test_id (uuid.UUID): UUID of any test in the retest chain.
|
|
|
|
Returns:
|
|
list[Test]: The original test followed by all its retests in
|
|
ascending retest-count order. Returns an empty list if the
|
|
test is not found.
|
|
"""
|
|
# Import uuid
|
|
import uuid as _uuid
|
|
|
|
# Assign tid = _uuid.UUID(str(test_id)) if not isinstance(test_id, _uuid.UUID) els...
|
|
tid = _uuid.UUID(str(test_id)) if not isinstance(test_id, _uuid.UUID) else test_id
|
|
|
|
# Find the original test first
|
|
test = db.query(Test).filter(Test.id == tid).first()
|
|
# Check: not test
|
|
if not test:
|
|
# Return []
|
|
return []
|
|
|
|
# Assign original_id = test.retest_of or test.id
|
|
original_id = test.retest_of or test.id
|
|
|
|
# Get original
|
|
original = db.query(Test).filter(Test.id == original_id).first()
|
|
# Check: not original
|
|
if not original:
|
|
# Return [test]
|
|
return [test]
|
|
|
|
# Get all retests of the original
|
|
retests = (
|
|
db.query(Test)
|
|
# Chain .filter() call
|
|
.filter(Test.retest_of == original_id)
|
|
# Chain .order_by() call
|
|
.order_by(Test.retest_count)
|
|
# Chain .all() call
|
|
.all()
|
|
)
|
|
|
|
# Return [original] + retests
|
|
return [original] + retests
|
|
|
|
|
|
# Define function reopen_test
|
|
def reopen_test(db: Session, test: Test, user: User) -> Test:
|
|
"""Move a ``rejected`` test back to ``draft`` for continued work.
|
|
|
|
Preserves all content (procedure, summaries, evidences) and — crucially —
|
|
the rejection NOTES so teams know what to fix without losing context.
|
|
|
|
Clears validation decisions (status, who validated, when) so leads must
|
|
re-validate the updated submission. Phase timing is reset so the timer
|
|
starts fresh for the new execution attempt.
|
|
"""
|
|
# Assign test = transition_state(
|
|
test = transition_state(
|
|
db, test, TestState.draft, user,
|
|
# Keyword argument: action_name
|
|
action_name="reopen_test",
|
|
)
|
|
|
|
# Clear validation DECISIONS — leads must re-validate the new attempt.
|
|
# Rejection NOTES are intentionally kept so teams see what needs fixing.
|
|
test.red_validation_status = None
|
|
# Assign test.red_validated_by = None
|
|
test.red_validated_by = None
|
|
# Assign test.red_validated_at = None
|
|
test.red_validated_at = None
|
|
# Assign test.red_validation_notes = None
|
|
test.red_validation_notes = None
|
|
|
|
# Assign test.blue_validation_status = None
|
|
test.blue_validation_status = None
|
|
# Assign test.blue_validated_by = None
|
|
test.blue_validated_by = None
|
|
# Assign test.blue_validated_at = None
|
|
test.blue_validated_at = None
|
|
# Assign test.blue_validation_notes = None
|
|
test.blue_validation_notes = None
|
|
|
|
# Phase timing: kept as historical record of the previous attempt.
|
|
# When the team presses "Start Execution" again, red_started_at will be
|
|
# overwritten with the new timestamp — no manual reset needed.
|
|
# Only the active-pause marker is cleared (it should never be set on a
|
|
# rejected test, but clear it defensively to avoid a stuck timer).
|
|
test.paused_at = None
|
|
|
|
try:
|
|
from app.services.jira_service import push_test_event
|
|
push_test_event(db, test, user, "draft")
|
|
except Exception as e:
|
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
|
|
|
# Return test
|
|
return test
|