Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 388c9773ab | |||
| d5e3df00f9 | |||
| 82033b5037 | |||
| 8a028bf0ed | |||
| b527eeac7d | |||
| e3cd75bb56 | |||
| 22be620665 | |||
| c41876b62f | |||
| 2d98ed5ec6 | |||
| 58d1da93ce |
@@ -0,0 +1,44 @@
|
|||||||
|
"""Add red_review, blue_review states and review-assignment columns to tests.
|
||||||
|
|
||||||
|
Revision ID: b057
|
||||||
|
Revises: b056
|
||||||
|
Create Date: 2026-07-06
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "b057"
|
||||||
|
down_revision = "b056"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'red_review'")
|
||||||
|
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'blue_review'")
|
||||||
|
|
||||||
|
op.add_column("tests", sa.Column("red_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("red_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("red_review_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("red_review_notes", sa.Text(), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("blue_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("blue_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("blue_review_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("blue_review_notes", sa.Text(), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("system_gaps", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tests", "system_gaps")
|
||||||
|
op.drop_column("tests", "blue_review_notes")
|
||||||
|
op.drop_column("tests", "blue_review_at")
|
||||||
|
op.drop_column("tests", "blue_review_by")
|
||||||
|
op.drop_column("tests", "blue_reviewer_assignee")
|
||||||
|
op.drop_column("tests", "red_review_notes")
|
||||||
|
op.drop_column("tests", "red_review_at")
|
||||||
|
op.drop_column("tests", "red_review_by")
|
||||||
|
op.drop_column("tests", "red_reviewer_assignee")
|
||||||
|
# PostgreSQL does not support removing enum values; downgrade is a no-op
|
||||||
|
# for the enum part, matching b046_add_disputed_test_state.py.
|
||||||
@@ -35,8 +35,12 @@ class TestState(str, enum.Enum):
|
|||||||
draft = "draft"
|
draft = "draft"
|
||||||
# Assign red_executing = "red_executing"
|
# Assign red_executing = "red_executing"
|
||||||
red_executing = "red_executing"
|
red_executing = "red_executing"
|
||||||
|
# Red Lead reviews the operator's work before it queues for Blue Team
|
||||||
|
red_review = "red_review"
|
||||||
# Assign blue_evaluating = "blue_evaluating"
|
# Assign blue_evaluating = "blue_evaluating"
|
||||||
blue_evaluating = "blue_evaluating"
|
blue_evaluating = "blue_evaluating"
|
||||||
|
# Blue Lead reviews the operator's work before cross-validation
|
||||||
|
blue_review = "blue_review"
|
||||||
# Assign in_review = "in_review"
|
# Assign in_review = "in_review"
|
||||||
in_review = "in_review"
|
in_review = "in_review"
|
||||||
# Assign validated = "validated"
|
# Assign validated = "validated"
|
||||||
|
|||||||
@@ -60,8 +60,12 @@ class TestState(str, enum.Enum):
|
|||||||
draft = "draft"
|
draft = "draft"
|
||||||
# Assign red_executing = "red_executing"
|
# Assign red_executing = "red_executing"
|
||||||
red_executing = "red_executing"
|
red_executing = "red_executing"
|
||||||
|
# Red Lead reviews the operator's work before it queues for Blue Team
|
||||||
|
red_review = "red_review"
|
||||||
# Assign blue_evaluating = "blue_evaluating"
|
# Assign blue_evaluating = "blue_evaluating"
|
||||||
blue_evaluating = "blue_evaluating"
|
blue_evaluating = "blue_evaluating"
|
||||||
|
# Blue Lead reviews the operator's work before cross-validation
|
||||||
|
blue_review = "blue_review"
|
||||||
# Assign in_review = "in_review"
|
# Assign in_review = "in_review"
|
||||||
in_review = "in_review"
|
in_review = "in_review"
|
||||||
# Assign validated = "validated"
|
# Assign validated = "validated"
|
||||||
@@ -74,8 +78,10 @@ class TestState(str, enum.Enum):
|
|||||||
# Assign VALID_TRANSITIONS = {
|
# Assign VALID_TRANSITIONS = {
|
||||||
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
||||||
TestState.draft: [TestState.red_executing],
|
TestState.draft: [TestState.red_executing],
|
||||||
TestState.red_executing: [TestState.blue_evaluating],
|
TestState.red_executing: [TestState.red_review],
|
||||||
TestState.blue_evaluating: [TestState.in_review],
|
TestState.red_review: [TestState.blue_evaluating, TestState.red_executing],
|
||||||
|
TestState.blue_evaluating: [TestState.blue_review],
|
||||||
|
TestState.blue_review: [TestState.in_review, TestState.blue_evaluating],
|
||||||
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
||||||
TestState.disputed: [TestState.validated, TestState.rejected],
|
TestState.disputed: [TestState.validated, TestState.rejected],
|
||||||
TestState.rejected: [TestState.draft],
|
TestState.rejected: [TestState.draft],
|
||||||
@@ -363,10 +369,12 @@ class TestEntity:
|
|||||||
|
|
||||||
# Define function submit_red_evidence
|
# Define function submit_red_evidence
|
||||||
def submit_red_evidence(self) -> int:
|
def submit_red_evidence(self) -> int:
|
||||||
"""Transition the test from ``red_executing`` to ``blue_evaluating``.
|
"""Transition the test from ``red_executing`` to ``red_review``.
|
||||||
|
|
||||||
Auto-resumes if paused. Returns paused seconds accumulated
|
Auto-resumes if paused. Returns paused seconds accumulated
|
||||||
during this phase (for worklog calculation).
|
during this phase (for worklog calculation). The Blue Team queue
|
||||||
|
timer does not start yet — that happens in ``approve_red_review``,
|
||||||
|
once the Red Lead actually releases the test to Blue Team.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: Total seconds the red phase was paused.
|
int: Total seconds the red phase was paused.
|
||||||
@@ -374,13 +382,9 @@ class TestEntity:
|
|||||||
# Assign paused_extra = self._auto_resume()
|
# Assign paused_extra = self._auto_resume()
|
||||||
paused_extra = self._auto_resume()
|
paused_extra = self._auto_resume()
|
||||||
# Call self._transition()
|
# Call self._transition()
|
||||||
self._transition(TestState.blue_evaluating)
|
self._transition(TestState.red_review)
|
||||||
# Assign total_paused = self.red_paused_seconds + paused_extra
|
# Assign total_paused = self.red_paused_seconds + paused_extra
|
||||||
total_paused = self.red_paused_seconds + paused_extra
|
total_paused = self.red_paused_seconds + paused_extra
|
||||||
# Assign self.blue_started_at = datetime.utcnow()
|
|
||||||
self.blue_started_at = datetime.utcnow()
|
|
||||||
# Assign self.blue_paused_seconds = 0
|
|
||||||
self.blue_paused_seconds = 0
|
|
||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent(
|
self._events.append(DomainEvent(
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -390,9 +394,32 @@ class TestEntity:
|
|||||||
# Return total_paused
|
# Return total_paused
|
||||||
return total_paused
|
return total_paused
|
||||||
|
|
||||||
|
def approve_red_review(self) -> None:
|
||||||
|
"""Transition the test from ``red_review`` to ``blue_evaluating``.
|
||||||
|
|
||||||
|
Called when the assigned Red Lead approves the operator's work.
|
||||||
|
Starts the Blue Team queue timer.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.blue_evaluating)
|
||||||
|
self.blue_started_at = datetime.utcnow()
|
||||||
|
self.blue_paused_seconds = 0
|
||||||
|
self._events.append(DomainEvent("red_review_approved"))
|
||||||
|
|
||||||
|
def reopen_red_review(self) -> None:
|
||||||
|
"""Transition the test from ``red_review`` back to ``red_executing``.
|
||||||
|
|
||||||
|
Called when the assigned Red Lead sends the work back for rework.
|
||||||
|
Resets the red-phase timer for a fresh attempt (the first attempt's
|
||||||
|
worklog was already recorded at submit time, so this loses nothing).
|
||||||
|
"""
|
||||||
|
self._transition(TestState.red_executing)
|
||||||
|
self.red_started_at = datetime.utcnow()
|
||||||
|
self.red_paused_seconds = 0
|
||||||
|
self._events.append(DomainEvent("red_review_reopened"))
|
||||||
|
|
||||||
# Define function submit_blue_evidence
|
# Define function submit_blue_evidence
|
||||||
def submit_blue_evidence(self) -> int:
|
def submit_blue_evidence(self) -> int:
|
||||||
"""Transition the test from ``blue_evaluating`` to ``in_review``.
|
"""Transition the test from ``blue_evaluating`` to ``blue_review``.
|
||||||
|
|
||||||
Auto-resumes if paused. Returns paused seconds accumulated
|
Auto-resumes if paused. Returns paused seconds accumulated
|
||||||
during this phase (for worklog calculation).
|
during this phase (for worklog calculation).
|
||||||
@@ -403,7 +430,7 @@ class TestEntity:
|
|||||||
# Assign paused_extra = self._auto_resume()
|
# Assign paused_extra = self._auto_resume()
|
||||||
paused_extra = self._auto_resume()
|
paused_extra = self._auto_resume()
|
||||||
# Call self._transition()
|
# Call self._transition()
|
||||||
self._transition(TestState.in_review)
|
self._transition(TestState.blue_review)
|
||||||
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
||||||
total_paused = self.blue_paused_seconds + paused_extra
|
total_paused = self.blue_paused_seconds + paused_extra
|
||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
@@ -415,6 +442,39 @@ class TestEntity:
|
|||||||
# Return total_paused
|
# Return total_paused
|
||||||
return total_paused
|
return total_paused
|
||||||
|
|
||||||
|
def approve_blue_review(self) -> None:
|
||||||
|
"""Transition the test from ``blue_review`` to ``in_review``.
|
||||||
|
|
||||||
|
Called when the assigned Blue Lead approves the operator's work.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.in_review)
|
||||||
|
self._events.append(DomainEvent("blue_review_approved"))
|
||||||
|
|
||||||
|
def reopen_blue_review(self) -> None:
|
||||||
|
"""Transition the test from ``blue_review`` back to ``blue_evaluating``.
|
||||||
|
|
||||||
|
Called when the assigned Blue Lead sends the work back for rework.
|
||||||
|
Resets the blue-phase timer for a fresh attempt. Note:
|
||||||
|
``blue_work_started_at`` (the pickup timestamp) lives only on the
|
||||||
|
ORM model, not on this entity — the service layer resets it there.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.blue_evaluating)
|
||||||
|
self.blue_started_at = datetime.utcnow()
|
||||||
|
self.blue_paused_seconds = 0
|
||||||
|
self._events.append(DomainEvent("blue_review_reopened"))
|
||||||
|
|
||||||
|
def flag_blue_review_gap(self) -> None:
|
||||||
|
"""Transition the test from ``blue_review`` to ``in_review``.
|
||||||
|
|
||||||
|
Called when the assigned Blue Lead determines the shortfall is a
|
||||||
|
missing capability, not operator error — the test still proceeds
|
||||||
|
to cross-validation since a retry can't fix a capability gap. The
|
||||||
|
gap description itself is stored on the ORM model
|
||||||
|
(``Test.system_gaps``), not on this entity.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.in_review)
|
||||||
|
self._events.append(DomainEvent("blue_review_gap_flagged"))
|
||||||
|
|
||||||
# Define function pause_timer
|
# Define function pause_timer
|
||||||
def pause_timer(self) -> None:
|
def pause_timer(self) -> None:
|
||||||
"""Pause the active phase timer.
|
"""Pause the active phase timer.
|
||||||
|
|||||||
@@ -139,6 +139,18 @@ class Test(Base):
|
|||||||
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# ── Review assignment fields ────────────────────────────────────
|
||||||
|
red_reviewer_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
red_review_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
red_review_at = Column(DateTime, nullable=True)
|
||||||
|
red_review_notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
blue_reviewer_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
blue_review_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
blue_review_at = Column(DateTime, nullable=True)
|
||||||
|
blue_review_notes = Column(Text, nullable=True)
|
||||||
|
system_gaps = Column(Text, nullable=True)
|
||||||
|
|
||||||
# ── On-hold fields ─────────────────────────────────────────────
|
# ── On-hold fields ─────────────────────────────────────────────
|
||||||
is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false")
|
is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||||
hold_reason = Column(Text, nullable=True)
|
hold_reason = Column(Text, nullable=True)
|
||||||
@@ -147,6 +159,11 @@ class Test(Base):
|
|||||||
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
|
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
|
||||||
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
|
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
|
||||||
|
|
||||||
|
red_reviewer = relationship("User", foreign_keys=[red_reviewer_assignee])
|
||||||
|
red_review_actor = relationship("User", foreign_keys=[red_review_by])
|
||||||
|
blue_reviewer = relationship("User", foreign_keys=[blue_reviewer_assignee])
|
||||||
|
blue_review_actor = relationship("User", foreign_keys=[blue_review_by])
|
||||||
|
|
||||||
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||||
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||||
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
|
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
|
||||||
|
|||||||
@@ -10,13 +10,19 @@ PATCH /tests/{id} — general update (draft/rejected only)
|
|||||||
PATCH /tests/{id}/red — Red Team updates (draft, red_executing)
|
PATCH /tests/{id}/red — Red Team updates (draft, red_executing)
|
||||||
PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating)
|
PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating)
|
||||||
POST /tests/{id}/start-execution — draft → red_executing
|
POST /tests/{id}/start-execution — draft → red_executing
|
||||||
POST /tests/{id}/submit-red — red_executing → blue_evaluating
|
POST /tests/{id}/submit-red — red_executing → red_review
|
||||||
|
POST /tests/{id}/review-red — assigned Red Lead approves/reopens
|
||||||
POST /tests/{id}/start-blue-work — blue tech picks up (sets Tempo timer)
|
POST /tests/{id}/start-blue-work — blue tech picks up (sets Tempo timer)
|
||||||
POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
POST /tests/{id}/submit-blue — blue_evaluating → blue_review
|
||||||
|
POST /tests/{id}/review-blue — assigned Blue Lead approves/reopens/flags gap
|
||||||
POST /tests/{id}/validate-red — Red Lead validates
|
POST /tests/{id}/validate-red — Red Lead validates
|
||||||
POST /tests/{id}/validate-blue — Blue Lead validates
|
POST /tests/{id}/validate-blue — Blue Lead validates
|
||||||
POST /tests/{id}/reopen — rejected → draft
|
POST /tests/{id}/reopen — rejected → draft
|
||||||
GET /tests/{id}/timeline — audit-log history for this test
|
GET /tests/{id}/timeline — audit-log history for this test
|
||||||
|
|
||||||
|
GET /tests/{id} hides the other team's fields while a test is blind
|
||||||
|
(draft through blue_review) for red_tech/red_lead/blue_tech/blue_lead
|
||||||
|
viewers — admin and viewer always see everything.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -52,12 +58,14 @@ from app.models.user import User
|
|||||||
# Import from app.schemas.test
|
# Import from app.schemas.test
|
||||||
from app.schemas.test import (
|
from app.schemas.test import (
|
||||||
TestAssign,
|
TestAssign,
|
||||||
|
TestBlueReview,
|
||||||
TestBlueUpdate,
|
TestBlueUpdate,
|
||||||
TestBlueValidate,
|
TestBlueValidate,
|
||||||
TestClassificationUpdate,
|
TestClassificationUpdate,
|
||||||
TestCreate,
|
TestCreate,
|
||||||
TestHold,
|
TestHold,
|
||||||
TestOut,
|
TestOut,
|
||||||
|
TestRedReview,
|
||||||
TestRedUpdate,
|
TestRedUpdate,
|
||||||
TestRedValidate,
|
TestRedValidate,
|
||||||
TestRemediationUpdate,
|
TestRemediationUpdate,
|
||||||
@@ -126,7 +134,12 @@ from app.services.test_crud_service import (
|
|||||||
from app.services.test_workflow_service import (
|
from app.services.test_workflow_service import (
|
||||||
start_execution as wf_start_execution,
|
start_execution as wf_start_execution,
|
||||||
submit_red_evidence as wf_submit_red,
|
submit_red_evidence as wf_submit_red,
|
||||||
|
approve_red_review as wf_approve_red_review,
|
||||||
|
reopen_red_review as wf_reopen_red_review,
|
||||||
submit_blue_evidence as wf_submit_blue,
|
submit_blue_evidence as wf_submit_blue,
|
||||||
|
approve_blue_review as wf_approve_blue_review,
|
||||||
|
reopen_blue_review as wf_reopen_blue_review,
|
||||||
|
flag_blue_review_gap as wf_flag_blue_review_gap,
|
||||||
start_blue_work as wf_start_blue_work,
|
start_blue_work as wf_start_blue_work,
|
||||||
validate_as_red_lead as wf_validate_red,
|
validate_as_red_lead as wf_validate_red,
|
||||||
validate_as_blue_lead as wf_validate_blue,
|
validate_as_blue_lead as wf_validate_blue,
|
||||||
@@ -141,6 +154,46 @@ from app.services.test_workflow_service import (
|
|||||||
router = APIRouter(prefix="/tests", tags=["tests"])
|
router = APIRouter(prefix="/tests", tags=["tests"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Blind visibility — hide the other team's fields until both reviews pass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_RED_ONLY_FIELDS = [
|
||||||
|
"procedure_text", "tool_used", "attack_success",
|
||||||
|
"execution_start_time", "execution_end_time", "red_summary",
|
||||||
|
"red_validation_status", "red_validated_by", "red_validated_at", "red_validation_notes",
|
||||||
|
]
|
||||||
|
_BLUE_ONLY_FIELDS = [
|
||||||
|
"detection_result", "containment_result", "detection_time", "containment_time",
|
||||||
|
"blue_summary", "blue_validation_status", "blue_validated_by", "blue_validated_at",
|
||||||
|
"blue_validation_notes", "system_gaps",
|
||||||
|
]
|
||||||
|
_BLIND_STATES = {"draft", "red_executing", "red_review", "blue_evaluating", "blue_review"}
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_for_team_blindness(test_out: TestOut, *, viewer_role: str) -> TestOut:
|
||||||
|
"""Null out the other team's fields while the test is still blind.
|
||||||
|
|
||||||
|
admin and viewer are never blinded. Once the test reaches in_review or
|
||||||
|
beyond, both sides see everything (existing cross-validation behavior).
|
||||||
|
"""
|
||||||
|
if viewer_role in ("admin", "viewer"):
|
||||||
|
return test_out
|
||||||
|
|
||||||
|
test_state = test_out.state.value if hasattr(test_out.state, "value") else str(test_out.state)
|
||||||
|
if test_state not in _BLIND_STATES:
|
||||||
|
return test_out
|
||||||
|
|
||||||
|
if viewer_role in ("blue_tech", "blue_lead"):
|
||||||
|
hide_fields = _RED_ONLY_FIELDS
|
||||||
|
elif viewer_role in ("red_tech", "red_lead"):
|
||||||
|
hide_fields = _BLUE_ONLY_FIELDS
|
||||||
|
else:
|
||||||
|
return test_out
|
||||||
|
|
||||||
|
return test_out.model_copy(update={f: None for f in hide_fields})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /tests — list with filters
|
# GET /tests — list with filters
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -406,9 +459,12 @@ def get_test(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TestOut: Full test detail including split red/blue evidence lists.
|
TestOut: Full test detail including split red/blue evidence lists.
|
||||||
|
Fields belonging to the other team are nulled out while the
|
||||||
|
test is blind (see :func:`_mask_for_team_blindness`).
|
||||||
"""
|
"""
|
||||||
# Return crud_get_test_detail(db, test_id)
|
test = crud_get_test_detail(db, test_id)
|
||||||
return crud_get_test_detail(db, test_id)
|
test_out = TestOut.model_validate(test)
|
||||||
|
return _mask_for_team_blindness(test_out, viewer_role=current_user.role)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -848,6 +904,68 @@ def start_blue_work(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/review-red — Red Lead reviews the operator's submission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/review-red", response_model=TestOut)
|
||||||
|
def review_red(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestRedReview,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("red_lead", "admin")),
|
||||||
|
) -> TestOut:
|
||||||
|
"""Assigned Red Lead approves or reopens a test sitting in red_review."""
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if current_user.role != "admin" and test.red_reviewer_assignee != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
||||||
|
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
if payload.decision == "approve":
|
||||||
|
test = wf_approve_red_review(db, test, current_user, notes=payload.notes)
|
||||||
|
elif payload.decision == "reopen":
|
||||||
|
test = wf_reopen_red_review(db, test, current_user, notes=payload.notes or "")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="decision must be 'approve' or 'reopen'")
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/review-blue — Blue Lead reviews the operator's submission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/review-blue", response_model=TestOut)
|
||||||
|
def review_blue(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestBlueReview,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("blue_lead", "admin")),
|
||||||
|
) -> TestOut:
|
||||||
|
"""Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review."""
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if current_user.role != "admin" and test.blue_reviewer_assignee != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
||||||
|
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
if payload.decision == "approve":
|
||||||
|
test = wf_approve_blue_review(db, test, current_user, notes=payload.notes)
|
||||||
|
elif payload.decision == "reopen":
|
||||||
|
test = wf_reopen_blue_review(db, test, current_user, notes=payload.notes or "")
|
||||||
|
elif payload.decision == "gap":
|
||||||
|
test = wf_flag_blue_review_gap(db, test, current_user, system_gaps=payload.system_gaps or "", notes=payload.notes)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="decision must be 'approve', 'reopen', or 'gap'")
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /tests/{id}/pause-timer — pause the active phase timer
|
# POST /tests/{id}/pause-timer — pause the active phase timer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -125,6 +125,27 @@ class TestBlueValidate(BaseModel):
|
|||||||
blue_validation_notes: str | None = None
|
blue_validation_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Red Lead review gate (pre-Blue-Team) ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedReview(BaseModel):
|
||||||
|
"""Payload sent by the assigned Red Lead reviewer."""
|
||||||
|
|
||||||
|
decision: str # "approve" | "reopen"
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Blue Lead review gate (pre-cross-validation) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlueReview(BaseModel):
|
||||||
|
"""Payload sent by the assigned Blue Lead reviewer."""
|
||||||
|
|
||||||
|
decision: str # "approve" | "reopen" | "gap"
|
||||||
|
notes: str | None = None
|
||||||
|
system_gaps: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ── Remediation update ────────────────────────────────────────────
|
# ── Remediation update ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -248,6 +269,17 @@ class TestOut(BaseModel):
|
|||||||
red_tech_assignee: uuid.UUID | None = None
|
red_tech_assignee: uuid.UUID | None = None
|
||||||
blue_tech_assignee: uuid.UUID | None = None
|
blue_tech_assignee: uuid.UUID | None = None
|
||||||
|
|
||||||
|
# Review assignment fields
|
||||||
|
red_reviewer_assignee: uuid.UUID | None = None
|
||||||
|
red_review_by: uuid.UUID | None = None
|
||||||
|
red_review_at: datetime | None = None
|
||||||
|
red_review_notes: str | None = None
|
||||||
|
blue_reviewer_assignee: uuid.UUID | None = None
|
||||||
|
blue_review_by: uuid.UUID | None = None
|
||||||
|
blue_review_at: datetime | None = None
|
||||||
|
blue_review_notes: str | None = None
|
||||||
|
system_gaps: str | None = None
|
||||||
|
|
||||||
# On-hold fields
|
# On-hold fields
|
||||||
is_on_hold: bool = False
|
is_on_hold: bool = False
|
||||||
hold_reason: str | None = None
|
hold_reason: str | None = None
|
||||||
|
|||||||
@@ -307,7 +307,9 @@ _SEVERITY_TO_PRIORITY: dict[str, str] = {
|
|||||||
_STATE_EMOJI: dict[str, str] = {
|
_STATE_EMOJI: dict[str, str] = {
|
||||||
"draft": "📝 Draft",
|
"draft": "📝 Draft",
|
||||||
"red_executing": "🔴 Red Team Executing",
|
"red_executing": "🔴 Red Team Executing",
|
||||||
|
"red_review": "🔎 Red Lead Review",
|
||||||
"blue_evaluating": "🔵 Blue Team Evaluating",
|
"blue_evaluating": "🔵 Blue Team Evaluating",
|
||||||
|
"blue_review": "🔎 Blue Lead Review",
|
||||||
"in_review": "📋 In Review",
|
"in_review": "📋 In Review",
|
||||||
"validated": "✅ Validated",
|
"validated": "✅ Validated",
|
||||||
"rejected": "❌ Rejected",
|
"rejected": "❌ Rejected",
|
||||||
@@ -370,6 +372,18 @@ def _build_state_comment(
|
|||||||
"Red Team has started the attack execution.",
|
"Red Team has started the attack execution.",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
elif new_state == "red_review":
|
||||||
|
lines += [
|
||||||
|
"Red Team has submitted evidence and the test is awaiting Red Lead review "
|
||||||
|
"before it queues for Blue Team.",
|
||||||
|
]
|
||||||
|
|
||||||
|
elif new_state == "blue_review":
|
||||||
|
lines += [
|
||||||
|
"Blue Team has submitted evidence and the test is awaiting Blue Lead review "
|
||||||
|
"before cross-validation.",
|
||||||
|
]
|
||||||
|
|
||||||
elif new_state == "blue_evaluating":
|
elif new_state == "blue_evaluating":
|
||||||
lines += [
|
lines += [
|
||||||
"Red Team has finished execution and submitted evidence for Blue Team evaluation.",
|
"Red Team has finished execution and submitted evidence for Blue Team evaluation.",
|
||||||
@@ -667,6 +681,7 @@ def push_test_event(
|
|||||||
new_state: str,
|
new_state: str,
|
||||||
*,
|
*,
|
||||||
extra: dict | None = None,
|
extra: dict | None = None,
|
||||||
|
assignee: User | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Post a lifecycle comment to the Jira issue linked to *test*.
|
"""Post a lifecycle comment to the Jira issue linked to *test*.
|
||||||
|
|
||||||
@@ -720,6 +735,21 @@ def push_test_event(
|
|||||||
link.jira_issue_key, jira_account_id, exc_a,
|
link.jira_issue_key, jira_account_id, exc_a,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if new_state in ("red_review", "blue_review") and assignee:
|
||||||
|
jira_account_id = getattr(assignee, "jira_account_id", None)
|
||||||
|
if jira_account_id:
|
||||||
|
try:
|
||||||
|
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
||||||
|
logger.info(
|
||||||
|
"Assigned Jira ticket %s to reviewer account %s",
|
||||||
|
link.jira_issue_key, jira_account_id,
|
||||||
|
)
|
||||||
|
except Exception as exc_a:
|
||||||
|
logger.warning(
|
||||||
|
"Could not assign %s to reviewer %s: %s",
|
||||||
|
link.jira_issue_key, jira_account_id, exc_a,
|
||||||
|
)
|
||||||
|
|
||||||
link.last_synced_at = datetime.utcnow()
|
link.last_synced_at = datetime.utcnow()
|
||||||
db.flush()
|
db.flush()
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
# Import settings from app.config
|
# Import settings from app.config
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.domain.exceptions import InvalidOperationError
|
from app.domain.exceptions import BusinessRuleViolation, InvalidOperationError
|
||||||
from app.domain.test_entity import TestEntity
|
from app.domain.test_entity import TestEntity
|
||||||
from app.models.enums import TestState, TeamSide
|
from app.models.enums import TestState, TeamSide
|
||||||
from app.models.evidence import Evidence
|
from app.models.evidence import Evidence
|
||||||
@@ -263,7 +263,7 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
|
|
||||||
# Assign test = transition_state(
|
# Assign test = transition_state(
|
||||||
test = transition_state(
|
test = transition_state(
|
||||||
db, test, TestState.blue_evaluating, user,
|
db, test, TestState.red_review, user,
|
||||||
# Keyword argument: action_name
|
# Keyword argument: action_name
|
||||||
action_name="submit_red_evidence",
|
action_name="submit_red_evidence",
|
||||||
)
|
)
|
||||||
@@ -287,10 +287,52 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
description=f"Red Team execution: {test.name}",
|
description=f"Red Team execution: {test.name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start Blue Team timer
|
reviewer = select_reviewer(
|
||||||
test.blue_started_at = now
|
db, role="red_lead",
|
||||||
# Assign test.blue_paused_seconds = 0
|
exclude_user_id=user.id if user.role == "red_lead" else None,
|
||||||
test.blue_paused_seconds = 0
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
from app.services.jira_service import push_test_event
|
from app.services.jira_service import push_test_event
|
||||||
@@ -301,6 +343,39 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
return test
|
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")
|
||||||
|
|
||||||
|
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()
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
def start_blue_work(db: Session, test: Test, user: User) -> 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.
|
"""Mark that a blue tech has picked up this test to start evaluating.
|
||||||
|
|
||||||
@@ -368,7 +443,7 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
|
|
||||||
# Assign test = transition_state(
|
# Assign test = transition_state(
|
||||||
test = transition_state(
|
test = transition_state(
|
||||||
db, test, TestState.in_review, user,
|
db, test, TestState.blue_review, user,
|
||||||
# Keyword argument: action_name
|
# Keyword argument: action_name
|
||||||
action_name="submit_blue_evidence",
|
action_name="submit_blue_evidence",
|
||||||
)
|
)
|
||||||
@@ -392,6 +467,121 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
description=f"Blue Team evaluation: {test.name}",
|
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)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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()
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
from app.services.jira_service import push_test_event
|
from app.services.jira_service import push_test_event
|
||||||
push_test_event(db, test, user, "in_review")
|
push_test_event(db, test, user, "in_review")
|
||||||
@@ -502,6 +692,52 @@ def resume_timer(db: Session, test: Test, user: User) -> Test:
|
|||||||
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
|
# Define function _create_phase_worklog
|
||||||
def _create_phase_worklog(
|
def _create_phase_worklog(
|
||||||
# Entry: db
|
# Entry: db
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Tests for red/blue blind visibility on GET /tests/{id}.
|
||||||
|
|
||||||
|
Neither team should see the other team's data while a test is in
|
||||||
|
draft/red_executing/red_review/blue_evaluating/blue_review. Both sides
|
||||||
|
see everything once the test reaches in_review (and beyond). admin and
|
||||||
|
viewer are never blinded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.evidence import Evidence
|
||||||
|
from app.models.enums import TeamSide
|
||||||
|
|
||||||
|
_RED_FIELDS = [
|
||||||
|
"procedure_text", "tool_used", "attack_success",
|
||||||
|
"execution_start_time", "execution_end_time", "red_summary",
|
||||||
|
]
|
||||||
|
_BLUE_FIELDS = [
|
||||||
|
"detection_result", "containment_result", "detection_time",
|
||||||
|
"containment_time", "blue_summary", "system_gaps",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_evidence(db, test_id, team: TeamSide):
|
||||||
|
ev = Evidence(
|
||||||
|
test_id=uuid.UUID(test_id),
|
||||||
|
file_name="proof.txt",
|
||||||
|
file_path="s3://bucket/proof.txt",
|
||||||
|
sha256_hash="a" * 64,
|
||||||
|
team=team,
|
||||||
|
)
|
||||||
|
db.add(ev)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def technique(api, auth_headers):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": "T1059.100", "name": "Command Line"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def blind_test(client, db, api, auth_headers, red_tech_headers, technique):
|
||||||
|
"""A test in red_executing with red-side content already filled in."""
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests", auth_headers,
|
||||||
|
json={"technique_id": technique, "name": "Blind visibility test"},
|
||||||
|
)
|
||||||
|
test_id = resp.json()["id"]
|
||||||
|
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||||
|
api(
|
||||||
|
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||||
|
json={"procedure_text": "run mimikatz", "tool_used": "mimikatz", "red_summary": "dumped creds"},
|
||||||
|
)
|
||||||
|
return test_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_viewer_cannot_see_red_fields_during_red_executing(
|
||||||
|
client, db, api, blind_test, blue_tech_headers
|
||||||
|
):
|
||||||
|
resp = api("get", f"/api/v1/tests/{blind_test}", blue_tech_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
for field in _RED_FIELDS:
|
||||||
|
assert body[field] is None, f"{field} should be hidden from blue viewer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_viewer_cannot_see_blue_fields_during_red_executing(
|
||||||
|
client, db, api, blind_test, red_tech_headers
|
||||||
|
):
|
||||||
|
resp = api("get", f"/api/v1/tests/{blind_test}", red_tech_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
for field in _BLUE_FIELDS:
|
||||||
|
assert body[field] is None, f"{field} should be hidden from red viewer"
|
||||||
|
# Red's own fields ARE visible to red
|
||||||
|
assert body["procedure_text"] == "run mimikatz"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_sees_everything_during_blind_states(
|
||||||
|
client, db, api, blind_test, auth_headers
|
||||||
|
):
|
||||||
|
resp = api("get", f"/api/v1/tests/{blind_test}", auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["procedure_text"] == "run mimikatz"
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_sides_see_everything_once_in_review(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, red_lead_user, blue_lead_user, blind_test,
|
||||||
|
):
|
||||||
|
test_id = blind_test
|
||||||
|
_add_evidence(db, test_id, TeamSide.red)
|
||||||
|
submit_red = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||||
|
assert submit_red.status_code == 200, submit_red.text
|
||||||
|
|
||||||
|
approve_red = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve"})
|
||||||
|
assert approve_red.status_code == 200, approve_red.text
|
||||||
|
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/start-blue-work", blue_tech_headers)
|
||||||
|
api(
|
||||||
|
"patch", f"/api/v1/tests/{test_id}/blue", blue_tech_headers,
|
||||||
|
json={"detection_result": "detected", "blue_summary": "caught it"},
|
||||||
|
)
|
||||||
|
_add_evidence(db, test_id, TeamSide.blue)
|
||||||
|
submit_blue = api("post", f"/api/v1/tests/{test_id}/submit-blue", blue_tech_headers)
|
||||||
|
assert submit_blue.status_code == 200, submit_blue.text
|
||||||
|
|
||||||
|
approve_blue = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "approve"})
|
||||||
|
assert approve_blue.status_code == 200, approve_blue.text
|
||||||
|
assert approve_blue.json()["state"] == "in_review"
|
||||||
|
|
||||||
|
resp_as_red = api("get", f"/api/v1/tests/{test_id}", red_tech_headers)
|
||||||
|
body_red = resp_as_red.json()
|
||||||
|
assert body_red["detection_result"] == "detected"
|
||||||
|
assert body_red["blue_summary"] == "caught it"
|
||||||
|
|
||||||
|
resp_as_blue = api("get", f"/api/v1/tests/{test_id}", blue_tech_headers)
|
||||||
|
body_blue = resp_as_blue.json()
|
||||||
|
assert body_blue["procedure_text"] == "run mimikatz"
|
||||||
|
assert body_blue["red_summary"] == "dumped creds"
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""HTTP-level tests for the red_review / blue_review lead-review gates.
|
||||||
|
|
||||||
|
Covers POST /tests/{id}/review-red and POST /tests/{id}/review-blue: the
|
||||||
|
assigned-reviewer-only guard, approve/reopen/gap decisions, and the state
|
||||||
|
transitions they drive. Uses the ``api`` fixture (see conftest.py) for any
|
||||||
|
step that switches role, since the TestClient cookie jar otherwise bleeds
|
||||||
|
one role's session into the next request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.evidence import Evidence
|
||||||
|
from app.models.enums import TeamSide
|
||||||
|
|
||||||
|
|
||||||
|
def _create_technique(api, auth_headers, mitre_id="T1059.099"):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": mitre_id, "name": "Command Line"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_test(api, auth_headers, technique_id, name="Review gate test"):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests", auth_headers,
|
||||||
|
json={"technique_id": technique_id, "name": name},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_evidence(db, test_id, team: TeamSide):
|
||||||
|
ev = Evidence(
|
||||||
|
test_id=uuid.UUID(test_id),
|
||||||
|
file_name="proof.txt",
|
||||||
|
file_path="s3://bucket/proof.txt",
|
||||||
|
sha256_hash="a" * 64,
|
||||||
|
team=team,
|
||||||
|
)
|
||||||
|
db.add(ev)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def technique(api, auth_headers):
|
||||||
|
return _create_technique(api, auth_headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique_id, name="Review gate test"):
|
||||||
|
"""Drive a fresh test from draft to red_review via real HTTP calls."""
|
||||||
|
test_id = _create_test(api, auth_headers, technique_id, name)
|
||||||
|
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||||
|
_add_evidence(db, test_id, TeamSide.red)
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["state"] == "red_review"
|
||||||
|
return test_id
|
||||||
|
|
||||||
|
|
||||||
|
def _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique_id, name="Review gate test"):
|
||||||
|
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique_id, name)
|
||||||
|
|
||||||
|
approve = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve"})
|
||||||
|
assert approve.status_code == 200, approve.text
|
||||||
|
assert approve.json()["state"] == "blue_evaluating"
|
||||||
|
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/start-blue-work", blue_tech_headers)
|
||||||
|
_add_evidence(db, test_id, TeamSide.blue)
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/submit-blue", blue_tech_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["state"] == "blue_review"
|
||||||
|
return test_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# review-red
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_red_forbidden_for_non_assigned_lead(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
||||||
|
):
|
||||||
|
"""A red_lead who isn't the assigned reviewer gets 403."""
|
||||||
|
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||||
|
# red_lead_user IS the only red_lead fixture, so it WILL be auto-assigned
|
||||||
|
# as reviewer (single-candidate load balancing). To exercise the 403
|
||||||
|
# path we need a second, non-assigned red_lead.
|
||||||
|
from app.auth import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
other_lead = User(
|
||||||
|
username="otherredlead", email="otherredlead@test.com",
|
||||||
|
hashed_password=hash_password("x"), role="red_lead", is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(other_lead)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
login = client.post("/api/v1/auth/login", data={"username": "otherredlead", "password": "x"})
|
||||||
|
assert login.status_code == 200
|
||||||
|
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", other_headers, json={"decision": "approve"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_red_approve_moves_to_blue_evaluating(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
||||||
|
):
|
||||||
|
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve", "notes": "LGTM"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["state"] == "blue_evaluating"
|
||||||
|
assert body["red_review_notes"] == "LGTM"
|
||||||
|
assert body["blue_started_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_red_reopen_requires_notes(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
||||||
|
):
|
||||||
|
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "reopen"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_red_reopen_moves_to_red_executing(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
||||||
|
):
|
||||||
|
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "reopen", "notes": "add more detail"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["state"] == "red_executing"
|
||||||
|
assert body["red_review_notes"] == "add more detail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_red_admin_can_always_act(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_user, technique
|
||||||
|
):
|
||||||
|
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-red", auth_headers, json={"decision": "approve"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["state"] == "blue_evaluating"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# review-blue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_blue_forbidden_for_non_assigned_lead(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||||
|
red_lead_user, blue_lead_user, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||||
|
|
||||||
|
from app.auth import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
other_lead = User(
|
||||||
|
username="otherbluelead", email="otherbluelead@test.com",
|
||||||
|
hashed_password=hash_password("x"), role="blue_lead", is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(other_lead)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
login = client.post("/api/v1/auth/login", data={"username": "otherbluelead", "password": "x"})
|
||||||
|
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", other_headers, json={"decision": "approve"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_blue_approve_moves_to_in_review(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||||
|
red_lead_user, blue_lead_user, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "approve"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["state"] == "in_review"
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_blue_reopen_requires_notes(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||||
|
red_lead_user, blue_lead_user, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "reopen"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_blue_reopen_moves_to_blue_evaluating(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||||
|
red_lead_user, blue_lead_user, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "reopen", "notes": "redo detection"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["state"] == "blue_evaluating"
|
||||||
|
assert body["blue_work_started_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_blue_gap_requires_system_gaps(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||||
|
red_lead_user, blue_lead_user, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "gap"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_blue_gap_moves_to_in_review_with_system_gaps(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||||
|
red_lead_user, blue_lead_user, technique,
|
||||||
|
):
|
||||||
|
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers,
|
||||||
|
json={"decision": "gap", "system_gaps": "Missing EDR agent on host X"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["state"] == "in_review"
|
||||||
|
assert body["system_gaps"] == "Missing EDR agent on host X"
|
||||||
@@ -13,6 +13,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
from app.domain.exceptions import InvalidTransitionError
|
from app.domain.exceptions import InvalidTransitionError
|
||||||
from app.models.enums import TestState
|
from app.models.enums import TestState
|
||||||
from app.services.test_workflow_service import (
|
from app.services.test_workflow_service import (
|
||||||
|
approve_red_review,
|
||||||
can_transition,
|
can_transition,
|
||||||
reopen_test,
|
reopen_test,
|
||||||
start_execution,
|
start_execution,
|
||||||
@@ -108,18 +109,20 @@ def test_draft_to_validated_fails(mock_log):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.select_reviewer")
|
||||||
@patch("app.services.test_workflow_service.log_action")
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
def test_red_executing_to_blue_evaluating(mock_log):
|
def test_red_executing_to_blue_evaluating(mock_log, mock_select_reviewer):
|
||||||
test = _make_test(TestState.red_executing)
|
test = _make_test(TestState.red_executing)
|
||||||
user = _make_user("red_tech")
|
user = _make_user("red_tech")
|
||||||
db = _make_db()
|
db = _make_db()
|
||||||
|
mock_select_reviewer.return_value = _make_user("red_lead")
|
||||||
|
|
||||||
result = submit_red_evidence(db, test, user)
|
result = submit_red_evidence(db, test, user)
|
||||||
|
|
||||||
assert result.state == TestState.blue_evaluating
|
assert result.state == TestState.red_review
|
||||||
db.flush.assert_called()
|
db.flush.assert_called()
|
||||||
mock_log.assert_called()
|
mock_log.assert_called()
|
||||||
print(" [PASS] Transition red_executing -> blue_evaluating works")
|
print(" [PASS] Transition red_executing -> red_review works")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -193,11 +196,13 @@ def test_reopen_clears_validation(mock_log):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.select_reviewer")
|
||||||
@patch("app.services.test_workflow_service.log_action")
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
def test_transitions_generate_audit_logs(mock_log):
|
def test_transitions_generate_audit_logs(mock_log, mock_select_reviewer):
|
||||||
test = _make_test(TestState.draft)
|
test = _make_test(TestState.draft)
|
||||||
user = _make_user("red_tech")
|
user = _make_user("red_tech")
|
||||||
db = _make_db()
|
db = _make_db()
|
||||||
|
mock_select_reviewer.return_value = _make_user("red_lead")
|
||||||
|
|
||||||
start_execution(db, test, user)
|
start_execution(db, test, user)
|
||||||
assert mock_log.call_count >= 1
|
assert mock_log.call_count >= 1
|
||||||
@@ -207,8 +212,12 @@ def test_transitions_generate_audit_logs(mock_log):
|
|||||||
assert mock_log.call_count > c1
|
assert mock_log.call_count > c1
|
||||||
c2 = mock_log.call_count
|
c2 = mock_log.call_count
|
||||||
|
|
||||||
submit_blue_evidence(db, test, user)
|
approve_red_review(db, test, _make_user("red_lead"))
|
||||||
assert mock_log.call_count > c2
|
assert mock_log.call_count > c2
|
||||||
|
c3 = mock_log.call_count
|
||||||
|
|
||||||
|
submit_blue_evidence(db, test, user)
|
||||||
|
assert mock_log.call_count > c3
|
||||||
|
|
||||||
print(" [PASS] Each transition generates an audit log")
|
print(" [PASS] Each transition generates an audit log")
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ from app.domain.test_entity import (
|
|||||||
VALID_TRANSITIONS,
|
VALID_TRANSITIONS,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
)
|
)
|
||||||
from app.domain.errors import BusinessRuleViolation, InvalidStateTransition
|
from app.domain.errors import (
|
||||||
|
BusinessRuleViolation,
|
||||||
|
InvalidOperationError,
|
||||||
|
InvalidStateTransition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
@@ -97,9 +101,15 @@ def test_validated_is_terminal():
|
|||||||
("draft", "red_executing", True),
|
("draft", "red_executing", True),
|
||||||
("draft", "validated", False),
|
("draft", "validated", False),
|
||||||
("draft", "blue_evaluating", False),
|
("draft", "blue_evaluating", False),
|
||||||
("red_executing", "blue_evaluating", True),
|
("red_executing", "red_review", True),
|
||||||
|
("red_executing", "blue_evaluating", False),
|
||||||
("red_executing", "draft", False),
|
("red_executing", "draft", False),
|
||||||
("blue_evaluating", "in_review", True),
|
("red_review", "blue_evaluating", True),
|
||||||
|
("red_review", "red_executing", True),
|
||||||
|
("blue_evaluating", "blue_review", True),
|
||||||
|
("blue_evaluating", "in_review", False),
|
||||||
|
("blue_review", "in_review", True),
|
||||||
|
("blue_review", "blue_evaluating", True),
|
||||||
("in_review", "validated", True),
|
("in_review", "validated", True),
|
||||||
("in_review", "rejected", True),
|
("in_review", "rejected", True),
|
||||||
("in_review", "draft", False),
|
("in_review", "draft", False),
|
||||||
@@ -187,10 +197,9 @@ def test_start_execution_from_wrong_state():
|
|||||||
def test_submit_red_evidence():
|
def test_submit_red_evidence():
|
||||||
e = _entity("red_executing", red_started_at=datetime.utcnow())
|
e = _entity("red_executing", red_started_at=datetime.utcnow())
|
||||||
total_paused = e.submit_red_evidence()
|
total_paused = e.submit_red_evidence()
|
||||||
assert e.state == TestState.blue_evaluating
|
assert e.state == TestState.red_review
|
||||||
assert total_paused == 0
|
assert total_paused == 0
|
||||||
assert e.blue_started_at is not None
|
assert e.blue_started_at is None
|
||||||
assert e.blue_paused_seconds == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_submit_red_evidence_auto_resumes():
|
def test_submit_red_evidence_auto_resumes():
|
||||||
@@ -201,13 +210,55 @@ def test_submit_red_evidence_auto_resumes():
|
|||||||
assert total_paused >= 40
|
assert total_paused >= 40
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5b. Lifecycle: red_review gate ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_red_evidence_now_goes_to_red_review():
|
||||||
|
e = _entity("red_executing", red_started_at=datetime.utcnow())
|
||||||
|
total_paused = e.submit_red_evidence()
|
||||||
|
assert e.state == TestState.red_review
|
||||||
|
assert total_paused == 0
|
||||||
|
# blue timer must NOT start yet — that's approve_red_review's job
|
||||||
|
assert e.blue_started_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_red_review_moves_to_blue_evaluating():
|
||||||
|
e = _entity("red_review")
|
||||||
|
e.approve_red_review()
|
||||||
|
assert e.state == TestState.blue_evaluating
|
||||||
|
assert e.blue_started_at is not None
|
||||||
|
assert e.blue_paused_seconds == 0
|
||||||
|
assert any(ev.name == "red_review_approved" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_red_review_wrong_state():
|
||||||
|
e = _entity("red_executing")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.approve_red_review()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_red_review_moves_to_red_executing():
|
||||||
|
e = _entity("red_review", red_paused_seconds=50)
|
||||||
|
e.reopen_red_review()
|
||||||
|
assert e.state == TestState.red_executing
|
||||||
|
assert e.red_started_at is not None
|
||||||
|
assert e.red_paused_seconds == 0
|
||||||
|
assert any(ev.name == "red_review_reopened" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_red_review_wrong_state():
|
||||||
|
e = _entity("blue_evaluating")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.reopen_red_review()
|
||||||
|
|
||||||
|
|
||||||
# ── 6. Lifecycle: submit_blue_evidence ───────────────────────────────
|
# ── 6. Lifecycle: submit_blue_evidence ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_submit_blue_evidence():
|
def test_submit_blue_evidence():
|
||||||
e = _entity("blue_evaluating", blue_started_at=datetime.utcnow())
|
e = _entity("blue_evaluating", blue_started_at=datetime.utcnow())
|
||||||
total_paused = e.submit_blue_evidence()
|
total_paused = e.submit_blue_evidence()
|
||||||
assert e.state == TestState.in_review
|
assert e.state == TestState.blue_review
|
||||||
assert total_paused == 0
|
assert total_paused == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -219,6 +270,62 @@ def test_submit_blue_evidence_auto_resumes():
|
|||||||
assert total_paused >= 25
|
assert total_paused >= 25
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6b. Lifecycle: blue_review gate ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_blue_review_moves_to_in_review():
|
||||||
|
e = _entity("blue_review")
|
||||||
|
e.approve_blue_review()
|
||||||
|
assert e.state == TestState.in_review
|
||||||
|
assert any(ev.name == "blue_review_approved" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_blue_review_wrong_state():
|
||||||
|
e = _entity("blue_evaluating")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.approve_blue_review()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_blue_review_moves_to_blue_evaluating():
|
||||||
|
e = _entity("blue_review", blue_paused_seconds=30)
|
||||||
|
e.reopen_blue_review()
|
||||||
|
assert e.state == TestState.blue_evaluating
|
||||||
|
assert e.blue_started_at is not None
|
||||||
|
assert e.blue_paused_seconds == 0
|
||||||
|
assert any(ev.name == "blue_review_reopened" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopen_blue_review_wrong_state():
|
||||||
|
e = _entity("in_review")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.reopen_blue_review()
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_blue_review_gap_moves_to_in_review():
|
||||||
|
e = _entity("blue_review")
|
||||||
|
e.flag_blue_review_gap()
|
||||||
|
assert e.state == TestState.in_review
|
||||||
|
assert any(ev.name == "blue_review_gap_flagged" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_blue_review_gap_wrong_state():
|
||||||
|
e = _entity("blue_evaluating")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.flag_blue_review_gap()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_red_not_reachable_from_red_review():
|
||||||
|
e = _entity("red_review")
|
||||||
|
with pytest.raises(InvalidOperationError, match="must be in_review"):
|
||||||
|
e.validate_red("approved", by=uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_blue_not_reachable_from_blue_review():
|
||||||
|
e = _entity("blue_review")
|
||||||
|
with pytest.raises(InvalidOperationError, match="must be in_review"):
|
||||||
|
e.validate_blue("approved", by=uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
# ── 7. pause_timer / resume_timer ────────────────────────────────────
|
# ── 7. pause_timer / resume_timer ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -408,9 +515,15 @@ def test_full_lifecycle_happy_path():
|
|||||||
assert e.state == TestState.red_executing
|
assert e.state == TestState.red_executing
|
||||||
|
|
||||||
e.submit_red_evidence()
|
e.submit_red_evidence()
|
||||||
|
assert e.state == TestState.red_review
|
||||||
|
|
||||||
|
e.approve_red_review()
|
||||||
assert e.state == TestState.blue_evaluating
|
assert e.state == TestState.blue_evaluating
|
||||||
|
|
||||||
e.submit_blue_evidence()
|
e.submit_blue_evidence()
|
||||||
|
assert e.state == TestState.blue_review
|
||||||
|
|
||||||
|
e.approve_blue_review()
|
||||||
assert e.state == TestState.in_review
|
assert e.state == TestState.in_review
|
||||||
|
|
||||||
e.validate_red("approved", by=uid_red)
|
e.validate_red("approved", by=uid_red)
|
||||||
@@ -428,7 +541,9 @@ def test_full_lifecycle_rejection_reopen():
|
|||||||
e = _entity("draft")
|
e = _entity("draft")
|
||||||
e.start_execution()
|
e.start_execution()
|
||||||
e.submit_red_evidence()
|
e.submit_red_evidence()
|
||||||
|
e.approve_red_review()
|
||||||
e.submit_blue_evidence()
|
e.submit_blue_evidence()
|
||||||
|
e.approve_blue_review()
|
||||||
e.validate_red("rejected", by=uuid.uuid4())
|
e.validate_red("rejected", by=uuid.uuid4())
|
||||||
assert e.state == TestState.rejected
|
assert e.state == TestState.rejected
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ without requiring a running database.
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
import pytest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -100,13 +101,16 @@ for _mod in [
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from app.domain.exceptions import InvalidOperationError, InvalidTransitionError
|
from app.domain.exceptions import InvalidOperationError, InvalidTransitionError
|
||||||
from app.models.enums import TestState, TestResult
|
from app.models.enums import TestState, TestResult
|
||||||
|
from app.models.test import Test
|
||||||
from app.services.test_workflow_service import (
|
from app.services.test_workflow_service import (
|
||||||
VALID_TRANSITIONS,
|
VALID_TRANSITIONS,
|
||||||
can_transition,
|
can_transition,
|
||||||
transition_state,
|
transition_state,
|
||||||
start_execution,
|
start_execution,
|
||||||
submit_red_evidence,
|
submit_red_evidence,
|
||||||
|
approve_red_review,
|
||||||
submit_blue_evidence,
|
submit_blue_evidence,
|
||||||
|
approve_blue_review,
|
||||||
validate_as_red_lead,
|
validate_as_red_lead,
|
||||||
validate_as_blue_lead,
|
validate_as_blue_lead,
|
||||||
check_dual_validation,
|
check_dual_validation,
|
||||||
@@ -158,27 +162,33 @@ def _make_db() -> MagicMock:
|
|||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.select_reviewer")
|
||||||
@patch("app.services.test_workflow_service.log_action")
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
def test_full_happy_path(mock_log):
|
def test_full_happy_path(mock_log, mock_select_reviewer):
|
||||||
"""draft -> red_executing -> blue_evaluating -> in_review -> validated"""
|
"""draft -> red_executing -> red_review -> blue_evaluating -> blue_review -> in_review -> validated"""
|
||||||
test = _make_test(TestState.draft)
|
test = _make_test(TestState.draft)
|
||||||
red_tech = _make_user("red_tech")
|
red_tech = _make_user("red_tech")
|
||||||
blue_tech = _make_user("blue_tech")
|
blue_tech = _make_user("blue_tech")
|
||||||
red_lead = _make_user("red_lead")
|
red_lead = _make_user("red_lead")
|
||||||
blue_lead = _make_user("blue_lead")
|
blue_lead = _make_user("blue_lead")
|
||||||
db = _make_db()
|
db = _make_db()
|
||||||
|
mock_select_reviewer.side_effect = [red_lead, blue_lead]
|
||||||
|
|
||||||
# Step 1: draft -> red_executing
|
# Step 1: draft -> red_executing
|
||||||
result = start_execution(db, test, red_tech)
|
result = start_execution(db, test, red_tech)
|
||||||
assert result.state == TestState.red_executing
|
assert result.state == TestState.red_executing
|
||||||
assert result.execution_date is not None
|
assert result.execution_date is not None
|
||||||
|
|
||||||
# Step 2: red_executing -> blue_evaluating
|
# Step 2: red_executing -> red_review -> (Red Lead approves) -> blue_evaluating
|
||||||
result = submit_red_evidence(db, result, red_tech)
|
result = submit_red_evidence(db, result, red_tech)
|
||||||
|
assert result.state == TestState.red_review
|
||||||
|
result = approve_red_review(db, result, red_lead)
|
||||||
assert result.state == TestState.blue_evaluating
|
assert result.state == TestState.blue_evaluating
|
||||||
|
|
||||||
# Step 3: blue_evaluating -> in_review
|
# Step 3: blue_evaluating -> blue_review -> (Blue Lead approves) -> in_review
|
||||||
result = submit_blue_evidence(db, result, blue_tech)
|
result = submit_blue_evidence(db, result, blue_tech)
|
||||||
|
assert result.state == TestState.blue_review
|
||||||
|
result = approve_blue_review(db, result, blue_lead)
|
||||||
assert result.state == TestState.in_review
|
assert result.state == TestState.in_review
|
||||||
|
|
||||||
# Step 4: Red Lead approves
|
# Step 4: Red Lead approves
|
||||||
@@ -205,19 +215,24 @@ def test_full_happy_path(mock_log):
|
|||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.select_reviewer")
|
||||||
@patch("app.services.test_workflow_service.log_action")
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
def test_rejection_and_reopen(mock_log):
|
def test_rejection_and_reopen(mock_log, mock_select_reviewer):
|
||||||
"""in_review -> rejected -> draft -> red_executing -> ..."""
|
"""in_review -> rejected -> draft -> red_executing -> ..."""
|
||||||
test = _make_test(TestState.draft)
|
test = _make_test(TestState.draft)
|
||||||
red_tech = _make_user("red_tech")
|
red_tech = _make_user("red_tech")
|
||||||
blue_tech = _make_user("blue_tech")
|
blue_tech = _make_user("blue_tech")
|
||||||
red_lead = _make_user("red_lead")
|
red_lead = _make_user("red_lead")
|
||||||
|
blue_lead = _make_user("blue_lead")
|
||||||
db = _make_db()
|
db = _make_db()
|
||||||
|
mock_select_reviewer.side_effect = [red_lead, blue_lead]
|
||||||
|
|
||||||
# Advance to in_review
|
# Advance to in_review
|
||||||
start_execution(db, test, red_tech)
|
start_execution(db, test, red_tech)
|
||||||
submit_red_evidence(db, test, red_tech)
|
submit_red_evidence(db, test, red_tech)
|
||||||
|
approve_red_review(db, test, red_lead)
|
||||||
submit_blue_evidence(db, test, blue_tech)
|
submit_blue_evidence(db, test, blue_tech)
|
||||||
|
approve_blue_review(db, test, blue_lead)
|
||||||
assert test.state == TestState.in_review
|
assert test.state == TestState.in_review
|
||||||
|
|
||||||
# Red Lead rejects -> rejected
|
# Red Lead rejects -> rejected
|
||||||
@@ -577,6 +592,169 @@ def test_cannot_reopen_non_rejected_test(mock_log):
|
|||||||
# Run all
|
# Run all
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 12b. Review-decision functions (approve/reopen/gap)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewDecisions:
|
||||||
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
|
def test_approve_red_review_moves_to_blue_evaluating(self, mock_log):
|
||||||
|
test = _make_test(TestState.red_review)
|
||||||
|
reviewer = _make_user("red_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import approve_red_review
|
||||||
|
result = approve_red_review(db, test, reviewer, notes="looks good")
|
||||||
|
|
||||||
|
assert result.state == TestState.blue_evaluating
|
||||||
|
assert result.red_review_by == reviewer.id
|
||||||
|
assert result.red_review_notes == "looks good"
|
||||||
|
assert result.blue_started_at is not None
|
||||||
|
|
||||||
|
def test_reopen_red_review_requires_notes(self):
|
||||||
|
test = _make_test(TestState.red_review)
|
||||||
|
reviewer = _make_user("red_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import reopen_red_review
|
||||||
|
with pytest.raises(InvalidOperationError):
|
||||||
|
reopen_red_review(db, test, reviewer, notes="")
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
|
def test_reopen_red_review_moves_to_red_executing_with_notes(self, mock_log):
|
||||||
|
test = _make_test(TestState.red_review, red_tech_assignee=uuid.uuid4())
|
||||||
|
reviewer = _make_user("red_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import reopen_red_review
|
||||||
|
result = reopen_red_review(db, test, reviewer, notes="add more detail")
|
||||||
|
|
||||||
|
assert result.state == TestState.red_executing
|
||||||
|
assert result.red_review_notes == "add more detail"
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
|
def test_approve_blue_review_moves_to_in_review(self, mock_log):
|
||||||
|
test = _make_test(TestState.blue_review)
|
||||||
|
reviewer = _make_user("blue_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import approve_blue_review
|
||||||
|
result = approve_blue_review(db, test, reviewer)
|
||||||
|
|
||||||
|
assert result.state == TestState.in_review
|
||||||
|
|
||||||
|
def test_reopen_blue_review_requires_notes(self):
|
||||||
|
test = _make_test(TestState.blue_review)
|
||||||
|
reviewer = _make_user("blue_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import reopen_blue_review
|
||||||
|
with pytest.raises(InvalidOperationError):
|
||||||
|
reopen_blue_review(db, test, reviewer, notes=None)
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
|
def test_reopen_blue_review_moves_to_blue_evaluating(self, mock_log):
|
||||||
|
test = _make_test(TestState.blue_review, blue_tech_assignee=uuid.uuid4())
|
||||||
|
reviewer = _make_user("blue_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import reopen_blue_review
|
||||||
|
result = reopen_blue_review(db, test, reviewer, notes="redo it")
|
||||||
|
|
||||||
|
assert result.state == TestState.blue_evaluating
|
||||||
|
assert result.blue_work_started_at is None
|
||||||
|
|
||||||
|
def test_flag_blue_review_gap_requires_system_gaps_text(self):
|
||||||
|
test = _make_test(TestState.blue_review)
|
||||||
|
reviewer = _make_user("blue_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import flag_blue_review_gap
|
||||||
|
with pytest.raises(InvalidOperationError):
|
||||||
|
flag_blue_review_gap(db, test, reviewer, system_gaps="")
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
|
def test_flag_blue_review_gap_moves_to_in_review(self, mock_log):
|
||||||
|
test = _make_test(TestState.blue_review)
|
||||||
|
reviewer = _make_user("blue_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import flag_blue_review_gap
|
||||||
|
result = flag_blue_review_gap(db, test, reviewer, system_gaps="Missing EDR agent on host X")
|
||||||
|
|
||||||
|
assert result.state == TestState.in_review
|
||||||
|
assert result.system_gaps == "Missing EDR agent on host X"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 13. select_reviewer — load-balanced reviewer assignment
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestReviewerSelection:
|
||||||
|
"""Uses the real sqlite `db` fixture from conftest.py (not MagicMock),
|
||||||
|
since load-balancing needs real COUNT() queries."""
|
||||||
|
|
||||||
|
def _make_lead(self, db, username, role="red_lead"):
|
||||||
|
from app.models.user import User
|
||||||
|
u = User(username=username, role=role, hashed_password="x", is_active=True)
|
||||||
|
db.add(u)
|
||||||
|
db.flush()
|
||||||
|
return u
|
||||||
|
|
||||||
|
def _make_technique(self, db, mitre_id="T1059"):
|
||||||
|
from app.models.technique import Technique
|
||||||
|
t = Technique(mitre_id=mitre_id, name="Command Line", tactic="execution")
|
||||||
|
db.add(t)
|
||||||
|
db.flush()
|
||||||
|
return t
|
||||||
|
|
||||||
|
def test_picks_lead_with_fewest_active_reviews(self, db):
|
||||||
|
from app.services.test_workflow_service import select_reviewer
|
||||||
|
|
||||||
|
lead_a = self._make_lead(db, "reda_selrev")
|
||||||
|
lead_b = self._make_lead(db, "redb_selrev")
|
||||||
|
tech = self._make_technique(db, "T1059.selrev1")
|
||||||
|
|
||||||
|
busy_test = Test(
|
||||||
|
technique_id=tech.id, name="Busy",
|
||||||
|
state=TestState.red_review, red_reviewer_assignee=lead_a.id,
|
||||||
|
)
|
||||||
|
db.add(busy_test)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
chosen = select_reviewer(db, role="red_lead")
|
||||||
|
assert chosen.id == lead_b.id
|
||||||
|
|
||||||
|
def test_excludes_the_submitter_if_they_are_a_lead(self, db):
|
||||||
|
from app.services.test_workflow_service import select_reviewer
|
||||||
|
from app.domain.exceptions import BusinessRuleViolation
|
||||||
|
|
||||||
|
lead_a = self._make_lead(db, "reda_excl")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="No available"):
|
||||||
|
select_reviewer(db, role="red_lead", exclude_user_id=lead_a.id)
|
||||||
|
|
||||||
|
def test_no_candidates_raises_clear_error(self, db):
|
||||||
|
from app.services.test_workflow_service import select_reviewer
|
||||||
|
from app.domain.exceptions import BusinessRuleViolation
|
||||||
|
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="No available"):
|
||||||
|
select_reviewer(db, role="red_lead")
|
||||||
|
|
||||||
|
def test_ties_broken_by_username(self, db):
|
||||||
|
from app.services.test_workflow_service import select_reviewer
|
||||||
|
|
||||||
|
self._make_lead(db, "zzz_tie", role="blue_lead")
|
||||||
|
self._make_lead(db, "aaa_tie", role="blue_lead")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
chosen = select_reviewer(db, role="blue_lead")
|
||||||
|
assert chosen.username == "aaa_tie"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("T-125 Validation: Workflow Tests")
|
print("T-125 Validation: Workflow Tests")
|
||||||
print("=" * 55)
|
print("=" * 55)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,31 @@ export async function startBlueWork(testId: string): Promise<Test> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Lead Review Gates (red_review / blue_review) ────────────────────
|
||||||
|
|
||||||
|
export interface RedReviewPayload {
|
||||||
|
decision: "approve" | "reopen";
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlueReviewPayload {
|
||||||
|
decision: "approve" | "reopen" | "gap";
|
||||||
|
notes?: string;
|
||||||
|
system_gaps?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assigned Red Lead approves or reopens a test sitting in red_review. */
|
||||||
|
export async function reviewAsRedLead(testId: string, payload: RedReviewPayload): Promise<Test> {
|
||||||
|
const { data } = await client.post<Test>(`/tests/${testId}/review-red`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review. */
|
||||||
|
export async function reviewAsBlueLead(testId: string, payload: BlueReviewPayload): Promise<Test> {
|
||||||
|
const { data } = await client.post<Test>(`/tests/${testId}/review-blue`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lead Validation ────────────────────────────────────────────────
|
// ── Lead Validation ────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Red Lead approves/rejects the red side. */
|
/** Red Lead approves/rejects the red side. */
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import type { Test, TestState, TestTemplateSummary } from "../types/models";
|
|||||||
const stateBadge: Record<TestState, string> = {
|
const stateBadge: Record<TestState, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||||||
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
||||||
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ const PHASES = [
|
|||||||
const stateColors: Record<string, { bg: string; text: string; border: string }> = {
|
const stateColors: Record<string, { bg: string; text: string; border: string }> = {
|
||||||
draft: { bg: "bg-gray-800", text: "text-gray-400", border: "border-gray-600" },
|
draft: { bg: "bg-gray-800", text: "text-gray-400", border: "border-gray-600" },
|
||||||
red_executing: { bg: "bg-orange-900/50", text: "text-orange-400", border: "border-orange-500/50" },
|
red_executing: { bg: "bg-orange-900/50", text: "text-orange-400", border: "border-orange-500/50" },
|
||||||
|
red_review: { bg: "bg-amber-900/50", text: "text-amber-400", border: "border-amber-500/50" },
|
||||||
blue_evaluating: { bg: "bg-indigo-900/50", text: "text-indigo-400", border: "border-indigo-500/50" },
|
blue_evaluating: { bg: "bg-indigo-900/50", text: "text-indigo-400", border: "border-indigo-500/50" },
|
||||||
|
blue_review: { bg: "bg-purple-900/50", text: "text-purple-400", border: "border-purple-500/50" },
|
||||||
in_review: { bg: "bg-blue-900/50", text: "text-blue-400", border: "border-blue-500/50" },
|
in_review: { bg: "bg-blue-900/50", text: "text-blue-400", border: "border-blue-500/50" },
|
||||||
validated: { bg: "bg-green-900/50", text: "text-green-400", border: "border-green-500/50" },
|
validated: { bg: "bg-green-900/50", text: "text-green-400", border: "border-green-500/50" },
|
||||||
rejected: { bg: "bg-red-900/50", text: "text-red-400", border: "border-red-500/50" },
|
rejected: { bg: "bg-red-900/50", text: "text-red-400", border: "border-red-500/50" },
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
RotateCcw,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
Shield,
|
||||||
|
ShieldCheck,
|
||||||
|
FileIcon,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { Test } from "../../types/models";
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type RedDecision = "approve" | "reopen";
|
||||||
|
type BlueDecision = "approve" | "reopen" | "gap";
|
||||||
|
|
||||||
|
interface ReviewModalProps {
|
||||||
|
side: "red" | "blue";
|
||||||
|
test: Test;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onSubmitRed?: (decision: RedDecision, notes: string) => void;
|
||||||
|
onSubmitBlue?: (decision: BlueDecision, notes: string, systemGaps: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ReviewModal({
|
||||||
|
side,
|
||||||
|
test,
|
||||||
|
isSubmitting,
|
||||||
|
onSubmitRed,
|
||||||
|
onSubmitBlue,
|
||||||
|
onClose,
|
||||||
|
}: ReviewModalProps) {
|
||||||
|
const isRed = side === "red";
|
||||||
|
const [decision, setDecision] = useState<RedDecision | BlueDecision | null>(null);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [systemGaps, setSystemGaps] = useState("");
|
||||||
|
|
||||||
|
const title = isRed ? "Review Red Team Submission" : "Review Blue Team Submission";
|
||||||
|
const accent = isRed ? "orange" : "indigo";
|
||||||
|
const evidences = isRed ? test.red_evidences || [] : test.blue_evidences || [];
|
||||||
|
|
||||||
|
const requiresNotes = decision === "reopen" && notes.trim().length === 0;
|
||||||
|
const requiresGaps = decision === "gap" && systemGaps.trim().length === 0;
|
||||||
|
|
||||||
|
const canSubmit = decision !== null && !isSubmitting && !requiresNotes && !requiresGaps;
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!decision) return;
|
||||||
|
if (isRed) {
|
||||||
|
onSubmitRed?.(decision as RedDecision, notes);
|
||||||
|
} else {
|
||||||
|
onSubmitBlue?.(decision as BlueDecision, notes, systemGaps);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isRed ? (
|
||||||
|
<Shield className={`h-5 w-5 text-${accent}-400`} />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck className={`h-5 w-5 text-${accent}-400`} />
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="space-y-5 px-6 py-5">
|
||||||
|
{/* Evidence summary */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-sm font-medium text-gray-300">
|
||||||
|
{isRed ? "Red" : "Blue"} Team Evidence ({evidences.length})
|
||||||
|
</h4>
|
||||||
|
{evidences.length > 0 ? (
|
||||||
|
<div className="max-h-32 space-y-1 overflow-y-auto rounded-lg border border-gray-700 bg-gray-800/50 p-2">
|
||||||
|
{evidences.map((ev) => (
|
||||||
|
<div key={ev.id} className="flex items-center gap-2 text-xs text-gray-400">
|
||||||
|
<FileIcon className="h-3.5 w-3.5 text-gray-500" />
|
||||||
|
<span className="truncate">{ev.file_name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">No evidence files uploaded.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decision */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-sm font-medium text-gray-300">Decision</h4>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDecision("approve")}
|
||||||
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
|
||||||
|
decision === "approve"
|
||||||
|
? "border-green-500 bg-green-500/10 text-green-400"
|
||||||
|
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDecision("reopen")}
|
||||||
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
|
||||||
|
decision === "reopen"
|
||||||
|
? "border-amber-500 bg-amber-500/10 text-amber-400"
|
||||||
|
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Reopen
|
||||||
|
</button>
|
||||||
|
{!isRed && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDecision("gap")}
|
||||||
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
|
||||||
|
decision === "gap"
|
||||||
|
? "border-purple-500 bg-purple-500/10 text-purple-400"
|
||||||
|
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
System Gap
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{decision === "gap" && (
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
Use this when the operator did everything right but Blue Team is missing a
|
||||||
|
capability (tooling, visibility) needed to detect/block this technique. The test
|
||||||
|
still proceeds to cross-validation.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes (reopen) */}
|
||||||
|
{decision === "reopen" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Notes <span className="text-red-400">(required)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-amber-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
|
||||||
|
placeholder="Explain what needs to be redone..."
|
||||||
|
/>
|
||||||
|
{requiresNotes && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Notes are required when reopening.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* System gaps (blue gap only) */}
|
||||||
|
{decision === "gap" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
System Gaps <span className="text-red-400">(required)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={systemGaps}
|
||||||
|
onChange={(e) => setSystemGaps(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
|
||||||
|
placeholder="What's missing to properly detect/block this technique?"
|
||||||
|
/>
|
||||||
|
{requiresGaps && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">
|
||||||
|
Describe the gap before flagging it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="mb-1.5 mt-3 block text-sm font-medium text-gray-300">
|
||||||
|
Notes (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
|
||||||
|
placeholder="Optional notes..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes (approve, optional) */}
|
||||||
|
{decision === "approve" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Notes (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||||
|
placeholder="Optional notes..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 border-t border-gray-800 px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={`flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 ${
|
||||||
|
decision === "reopen"
|
||||||
|
? "bg-amber-600 hover:bg-amber-500"
|
||||||
|
: decision === "gap"
|
||||||
|
? "bg-purple-600 hover:bg-purple-500"
|
||||||
|
: "bg-green-600 hover:bg-green-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{decision === "approve"
|
||||||
|
? "Confirm Approval"
|
||||||
|
: decision === "reopen"
|
||||||
|
? "Confirm Reopen"
|
||||||
|
: decision === "gap"
|
||||||
|
? "Confirm System Gap"
|
||||||
|
: "Select a decision"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -148,6 +148,13 @@ export default function TeamTabs({
|
|||||||
((role === "blue_lead" || role === "admin") ||
|
((role === "blue_lead" || role === "admin") ||
|
||||||
(role === "blue_tech" && !!test.blue_work_started_at));
|
(role === "blue_tech" && !!test.blue_work_started_at));
|
||||||
|
|
||||||
|
// Blind visibility: neither side sees the other's data until both reviews
|
||||||
|
// pass (matches the backend's field-masking on GET /tests/{id}).
|
||||||
|
const BLIND_STATES = ["draft", "red_executing", "red_review", "blue_evaluating", "blue_review"];
|
||||||
|
const isBlind = BLIND_STATES.includes(test.state) && role !== "admin" && role !== "viewer";
|
||||||
|
const hideBlueFromMe = isBlind && (role === "red_tech" || role === "red_lead");
|
||||||
|
const hideRedFromMe = isBlind && (role === "blue_tech" || role === "blue_lead");
|
||||||
|
|
||||||
// Containment fields only visible when attack was detected (draft or saved value)
|
// Containment fields only visible when attack was detected (draft or saved value)
|
||||||
const isDetected = canEditBlue
|
const isDetected = canEditBlue
|
||||||
? blueDraft.detection_result === "detected" || blueDraft.detection_result === "partially_detected"
|
? blueDraft.detection_result === "detected" || blueDraft.detection_result === "partially_detected"
|
||||||
@@ -168,7 +175,19 @@ export default function TeamTabs({
|
|||||||
|
|
||||||
// ── Red Team Tab ─────────────────────────────────────────────────
|
// ── Red Team Tab ─────────────────────────────────────────────────
|
||||||
|
|
||||||
const renderRedTab = () => (
|
const renderRedTab = () => {
|
||||||
|
if (hideRedFromMe) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<Shield className="mx-auto h-10 w-10 text-gray-600" />
|
||||||
|
<p className="mt-2 text-sm text-gray-400">
|
||||||
|
Red Team work is hidden until both teams' reviews are complete — this keeps
|
||||||
|
detection testing blind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Locked hint for red_tech in draft state */}
|
{/* Locked hint for red_tech in draft state */}
|
||||||
{redLockedHint && (
|
{redLockedHint && (
|
||||||
@@ -344,10 +363,23 @@ export default function TeamTabs({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Blue Team Tab ────────────────────────────────────────────────
|
// ── Blue Team Tab ────────────────────────────────────────────────
|
||||||
|
|
||||||
const renderBlueTab = () => (
|
const renderBlueTab = () => {
|
||||||
|
if (hideBlueFromMe) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<ShieldCheck className="mx-auto h-10 w-10 text-gray-600" />
|
||||||
|
<p className="mt-2 text-sm text-gray-400">
|
||||||
|
Blue Team work is hidden until both teams' reviews are complete — this keeps
|
||||||
|
detection testing blind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Locked hint for blue_tech before Start Evaluation */}
|
{/* Locked hint for blue_tech before Start Evaluation */}
|
||||||
{blueLockedHint && (
|
{blueLockedHint && (
|
||||||
@@ -611,6 +643,7 @@ export default function TeamTabs({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Summary Tab ──────────────────────────────────────────────────
|
// ── Summary Tab ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -713,6 +746,12 @@ export default function TeamTabs({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{test.system_gaps && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase text-gray-500">System Gaps Flagged</dt>
|
||||||
|
<dd className="mt-0.5 text-sm text-purple-300">{test.system_gaps}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import LiveTimer from "./LiveTimer";
|
|||||||
const PROGRESS_STEPS: { key: TestState; label: string }[] = [
|
const PROGRESS_STEPS: { key: TestState; label: string }[] = [
|
||||||
{ key: "draft", label: "Draft" },
|
{ key: "draft", label: "Draft" },
|
||||||
{ key: "red_executing", label: "Red Exec" },
|
{ key: "red_executing", label: "Red Exec" },
|
||||||
|
{ key: "red_review", label: "Red Review" },
|
||||||
{ key: "blue_evaluating", label: "Blue Eval" },
|
{ key: "blue_evaluating", label: "Blue Eval" },
|
||||||
|
{ key: "blue_review", label: "Blue Review" },
|
||||||
{ key: "in_review", label: "Review" },
|
{ key: "in_review", label: "Review" },
|
||||||
{ key: "validated", label: "Validated" },
|
{ key: "validated", label: "Validated" },
|
||||||
];
|
];
|
||||||
@@ -34,11 +36,13 @@ const PROGRESS_STEPS: { key: TestState; label: string }[] = [
|
|||||||
const STATE_INDEX: Record<TestState, number> = {
|
const STATE_INDEX: Record<TestState, number> = {
|
||||||
draft: 0,
|
draft: 0,
|
||||||
red_executing: 1,
|
red_executing: 1,
|
||||||
blue_evaluating: 2,
|
red_review: 2,
|
||||||
in_review: 3,
|
blue_evaluating: 3,
|
||||||
validated: 4,
|
blue_review: 4,
|
||||||
|
in_review: 5,
|
||||||
|
validated: 6,
|
||||||
rejected: -1,
|
rejected: -1,
|
||||||
disputed: 3, // same step as in_review (still in validation phase)
|
disputed: 5, // same step as in_review (still in validation phase)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Badge colors ───────────────────────────────────────────────────
|
// ── Badge colors ───────────────────────────────────────────────────
|
||||||
@@ -46,13 +50,23 @@ const STATE_INDEX: Record<TestState, number> = {
|
|||||||
const STATE_BADGE: Record<TestState, string> = {
|
const STATE_BADGE: Record<TestState, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||||||
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
||||||
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
disputed: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
disputed: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** "Queued Blue Team" is a display-only label for blue_evaluating + unassigned. */
|
||||||
|
function getStateLabel(test: Test): string {
|
||||||
|
if (test.state === "blue_evaluating" && !test.blue_work_started_at) {
|
||||||
|
return "Queued Blue Team";
|
||||||
|
}
|
||||||
|
return test.state.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Props ──────────────────────────────────────────────────────────
|
// ── Props ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface TestDetailHeaderProps {
|
interface TestDetailHeaderProps {
|
||||||
@@ -64,6 +78,7 @@ interface TestDetailHeaderProps {
|
|||||||
onSubmitBlue: () => void;
|
onSubmitBlue: () => void;
|
||||||
onStartBlueWork: () => void;
|
onStartBlueWork: () => void;
|
||||||
onOpenValidateModal: (side: "red" | "blue") => void;
|
onOpenValidateModal: (side: "red" | "blue") => void;
|
||||||
|
onOpenReviewModal: (side: "red" | "blue") => void;
|
||||||
onReopen: () => void;
|
onReopen: () => void;
|
||||||
onPauseTimer: () => void;
|
onPauseTimer: () => void;
|
||||||
onResumeTimer: () => void;
|
onResumeTimer: () => void;
|
||||||
@@ -84,6 +99,7 @@ export default function TestDetailHeader({
|
|||||||
onSubmitBlue,
|
onSubmitBlue,
|
||||||
onStartBlueWork,
|
onStartBlueWork,
|
||||||
onOpenValidateModal,
|
onOpenValidateModal,
|
||||||
|
onOpenReviewModal,
|
||||||
onReopen,
|
onReopen,
|
||||||
onPauseTimer,
|
onPauseTimer,
|
||||||
onResumeTimer,
|
onResumeTimer,
|
||||||
@@ -190,6 +206,40 @@ export default function TestDetailHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Red Lead assigned to review this submission -> Review Red Submission
|
||||||
|
if (
|
||||||
|
test.state === "red_review" &&
|
||||||
|
(role === "admin" || (role === "red_lead" && test.red_reviewer_assignee === user?.id))
|
||||||
|
) {
|
||||||
|
buttons.push(
|
||||||
|
<button
|
||||||
|
key="review-red"
|
||||||
|
onClick={() => onOpenReviewModal("red")}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Review Red Submission
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blue Lead assigned to review this submission -> Review Blue Submission
|
||||||
|
if (
|
||||||
|
test.state === "blue_review" &&
|
||||||
|
(role === "admin" || (role === "blue_lead" && test.blue_reviewer_assignee === user?.id))
|
||||||
|
) {
|
||||||
|
buttons.push(
|
||||||
|
<button
|
||||||
|
key="review-blue"
|
||||||
|
onClick={() => onOpenReviewModal("blue")}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-500 transition-colors"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
Review Blue Submission
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Blue Team in blue_evaluating:
|
// Blue Team in blue_evaluating:
|
||||||
// - if not picked up yet: show "Start Evaluation" button
|
// - if not picked up yet: show "Start Evaluation" button
|
||||||
// - if already picked up: show "Submit for Review" button
|
// - if already picked up: show "Submit for Review" button
|
||||||
@@ -472,7 +522,7 @@ export default function TestDetailHeader({
|
|||||||
STATE_BADGE[test.state]
|
STATE_BADGE[test.state]
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{test.state.replace(/_/g, " ")}
|
{getStateLabel(test)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-gray-400">
|
<p className="mt-1 text-sm text-gray-400">
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ const typeLabels: Record<string, string> = {
|
|||||||
const testStateColors: Record<string, string> = {
|
const testStateColors: Record<string, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||||||
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
||||||
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ import type { TestState } from "../types/models";
|
|||||||
const testStateBadgeColors: Record<string, string> = {
|
const testStateBadgeColors: Record<string, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||||||
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
||||||
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
@@ -60,7 +62,9 @@ const testStateBadgeColors: Record<string, string> = {
|
|||||||
const testStateLabels: Record<string, string> = {
|
const testStateLabels: Record<string, string> = {
|
||||||
draft: "Draft",
|
draft: "Draft",
|
||||||
red_executing: "Red Executing",
|
red_executing: "Red Executing",
|
||||||
|
red_review: "Red Review",
|
||||||
blue_evaluating: "Blue Evaluating",
|
blue_evaluating: "Blue Evaluating",
|
||||||
|
blue_review: "Blue Review",
|
||||||
in_review: "In Review",
|
in_review: "In Review",
|
||||||
validated: "Validated",
|
validated: "Validated",
|
||||||
rejected: "Rejected",
|
rejected: "Rejected",
|
||||||
|
|||||||
@@ -213,7 +213,9 @@ export default function ReportsPage() {
|
|||||||
<option value="">All states</option>
|
<option value="">All states</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">Draft</option>
|
||||||
<option value="red_executing">Red Executing</option>
|
<option value="red_executing">Red Executing</option>
|
||||||
|
<option value="red_review">Red Review</option>
|
||||||
<option value="blue_evaluating">Blue Evaluating</option>
|
<option value="blue_evaluating">Blue Evaluating</option>
|
||||||
|
<option value="blue_review">Blue Review</option>
|
||||||
<option value="in_review">In Review</option>
|
<option value="in_review">In Review</option>
|
||||||
<option value="validated">Validated</option>
|
<option value="validated">Validated</option>
|
||||||
<option value="rejected">Rejected</option>
|
<option value="rejected">Rejected</option>
|
||||||
@@ -452,7 +454,9 @@ const statusColors: Record<string, string> = {
|
|||||||
not_evaluated: "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
not_evaluated: "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
||||||
draft: "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
draft: "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
||||||
red_executing: "bg-orange-500/10 text-orange-400 border-orange-500/30",
|
red_executing: "bg-orange-500/10 text-orange-400 border-orange-500/30",
|
||||||
|
red_review: "bg-amber-500/10 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-500/10 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-indigo-500/10 text-indigo-400 border-indigo-500/30",
|
||||||
|
blue_review: "bg-purple-500/10 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
in_review: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
||||||
rejected: "bg-red-500/10 text-red-400 border-red-500/30",
|
rejected: "bg-red-500/10 text-red-400 border-red-500/30",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ const statusBadgeColors: Record<TechniqueStatus, string> = {
|
|||||||
const testStateBadgeColors: Record<TestState, string> = {
|
const testStateBadgeColors: Record<TestState, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||||||
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
||||||
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
@@ -493,7 +495,7 @@ export default function TechniqueDetailPage() {
|
|||||||
|
|
||||||
// Any test currently in a non-terminal state
|
// Any test currently in a non-terminal state
|
||||||
const ACTIVE_STATES: TestState[] = [
|
const ACTIVE_STATES: TestState[] = [
|
||||||
"draft", "red_executing", "blue_evaluating", "in_review",
|
"draft", "red_executing", "red_review", "blue_evaluating", "blue_review", "in_review",
|
||||||
];
|
];
|
||||||
const activeTest = allTests.find(
|
const activeTest = allTests.find(
|
||||||
(t: { state: TestState }) => ACTIVE_STATES.includes(t.state)
|
(t: { state: TestState }) => ACTIVE_STATES.includes(t.state)
|
||||||
@@ -520,7 +522,9 @@ export default function TechniqueDetailPage() {
|
|||||||
const ACTIVE_LABEL: Partial<Record<TestState, string>> = {
|
const ACTIVE_LABEL: Partial<Record<TestState, string>> = {
|
||||||
draft: "Draft",
|
draft: "Draft",
|
||||||
red_executing: "Executing",
|
red_executing: "Executing",
|
||||||
|
red_review: "Red Review",
|
||||||
blue_evaluating: "Evaluating",
|
blue_evaluating: "Evaluating",
|
||||||
|
blue_review: "Blue Review",
|
||||||
in_review: "In Review",
|
in_review: "In Review",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
startBlueWork,
|
startBlueWork,
|
||||||
validateAsRedLead,
|
validateAsRedLead,
|
||||||
validateAsBlueLead,
|
validateAsBlueLead,
|
||||||
|
reviewAsRedLead,
|
||||||
|
reviewAsBlueLead,
|
||||||
reopenTest,
|
reopenTest,
|
||||||
pauseTimer,
|
pauseTimer,
|
||||||
resumeTimer,
|
resumeTimer,
|
||||||
@@ -29,6 +31,7 @@ import type { TestResult, ContainmentResult, TeamSide, TestTimelineEntry } from
|
|||||||
import TestDetailHeader from "../components/test-detail/TestDetailHeader";
|
import TestDetailHeader from "../components/test-detail/TestDetailHeader";
|
||||||
import TeamTabs from "../components/test-detail/TeamTabs";
|
import TeamTabs from "../components/test-detail/TeamTabs";
|
||||||
import ValidationModal from "../components/test-detail/ValidationModal";
|
import ValidationModal from "../components/test-detail/ValidationModal";
|
||||||
|
import ReviewModal from "../components/test-detail/ReviewModal";
|
||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||||
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
||||||
@@ -56,6 +59,11 @@ export default function TestDetailPage() {
|
|||||||
side: "red" | "blue";
|
side: "red" | "blue";
|
||||||
}>({ open: false, side: "red" });
|
}>({ open: false, side: "red" });
|
||||||
|
|
||||||
|
const [reviewModal, setReviewModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
side: "red" | "blue";
|
||||||
|
}>({ open: false, side: "red" });
|
||||||
|
|
||||||
const [confirmReopen, setConfirmReopen] = useState(false);
|
const [confirmReopen, setConfirmReopen] = useState(false);
|
||||||
const [holdModal, setHoldModal] = useState(false);
|
const [holdModal, setHoldModal] = useState(false);
|
||||||
const [holdReason, setHoldReason] = useState("");
|
const [holdReason, setHoldReason] = useState("");
|
||||||
@@ -254,6 +262,28 @@ export default function TestDetailPage() {
|
|||||||
onError: (err: unknown) => showToast(extractError(err), "error"),
|
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reviewRedMutation = useMutation({
|
||||||
|
mutationFn: (payload: { decision: "approve" | "reopen"; notes?: string }) =>
|
||||||
|
reviewAsRedLead(testId!, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
setReviewModal({ open: false, side: "red" });
|
||||||
|
showToast("Red review submitted", "success");
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewBlueMutation = useMutation({
|
||||||
|
mutationFn: (payload: { decision: "approve" | "reopen" | "gap"; notes?: string; system_gaps?: string }) =>
|
||||||
|
reviewAsBlueLead(testId!, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
setReviewModal({ open: false, side: "blue" });
|
||||||
|
showToast("Blue review submitted", "success");
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||||
|
});
|
||||||
|
|
||||||
const reopenMutation = useMutation({
|
const reopenMutation = useMutation({
|
||||||
mutationFn: () => reopenTest(testId!),
|
mutationFn: () => reopenTest(testId!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -360,6 +390,22 @@ export default function TestDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReviewRedSubmit = (decision: "approve" | "reopen", notes: string) => {
|
||||||
|
reviewRedMutation.mutate({ decision, notes: notes || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReviewBlueSubmit = (
|
||||||
|
decision: "approve" | "reopen" | "gap",
|
||||||
|
notes: string,
|
||||||
|
systemGaps: string,
|
||||||
|
) => {
|
||||||
|
reviewBlueMutation.mutate({
|
||||||
|
decision,
|
||||||
|
notes: notes || undefined,
|
||||||
|
system_gaps: decision === "gap" ? systemGaps : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const isTransitioning =
|
const isTransitioning =
|
||||||
startExecMutation.isPending ||
|
startExecMutation.isPending ||
|
||||||
submitRedMutation.isPending ||
|
submitRedMutation.isPending ||
|
||||||
@@ -439,6 +485,7 @@ export default function TestDetailPage() {
|
|||||||
onSubmitBlue={() => submitBlueMutation.mutate()}
|
onSubmitBlue={() => submitBlueMutation.mutate()}
|
||||||
onStartBlueWork={() => startBlueWorkMutation.mutate()}
|
onStartBlueWork={() => startBlueWorkMutation.mutate()}
|
||||||
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
||||||
|
onOpenReviewModal={(side) => setReviewModal({ open: true, side })}
|
||||||
onReopen={() => setConfirmReopen(true)}
|
onReopen={() => setConfirmReopen(true)}
|
||||||
onPauseTimer={() => pauseTimerMutation.mutate()}
|
onPauseTimer={() => pauseTimerMutation.mutate()}
|
||||||
onResumeTimer={() => resumeTimerMutation.mutate()}
|
onResumeTimer={() => resumeTimerMutation.mutate()}
|
||||||
@@ -717,6 +764,22 @@ export default function TestDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Review Modal (red_review / blue_review lead gate) */}
|
||||||
|
{reviewModal.open && (
|
||||||
|
<ReviewModal
|
||||||
|
side={reviewModal.side}
|
||||||
|
test={test}
|
||||||
|
isSubmitting={
|
||||||
|
reviewModal.side === "red"
|
||||||
|
? reviewRedMutation.isPending
|
||||||
|
: reviewBlueMutation.isPending
|
||||||
|
}
|
||||||
|
onSubmitRed={reviewModal.side === "red" ? handleReviewRedSubmit : undefined}
|
||||||
|
onSubmitBlue={reviewModal.side === "blue" ? handleReviewBlueSubmit : undefined}
|
||||||
|
onClose={() => setReviewModal({ open: false, side: "red" })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Save as Template Modal */}
|
{/* Save as Template Modal */}
|
||||||
{showTemplateModal && (
|
{showTemplateModal && (
|
||||||
<SaveAsTemplateModal
|
<SaveAsTemplateModal
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
const testStateBadgeColors: Record<TestState, string> = {
|
const testStateBadgeColors: Record<TestState, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
|
||||||
|
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
|
||||||
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
|
||||||
|
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
|
||||||
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
|
||||||
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
validated: "bg-green-900/50 text-green-400 border-green-500/30",
|
||||||
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
|
||||||
@@ -39,7 +41,9 @@ const testStateBadgeColors: Record<TestState, string> = {
|
|||||||
const testStateLabels: Record<TestState, string> = {
|
const testStateLabels: Record<TestState, string> = {
|
||||||
draft: "Draft",
|
draft: "Draft",
|
||||||
red_executing: "Red Executing",
|
red_executing: "Red Executing",
|
||||||
|
red_review: "Red Review",
|
||||||
blue_evaluating: "Blue Evaluating",
|
blue_evaluating: "Blue Evaluating",
|
||||||
|
blue_review: "Blue Review",
|
||||||
in_review: "In Review",
|
in_review: "In Review",
|
||||||
validated: "Validated",
|
validated: "Validated",
|
||||||
rejected: "Rejected",
|
rejected: "Rejected",
|
||||||
@@ -49,7 +53,9 @@ const testStateLabels: Record<TestState, string> = {
|
|||||||
const ALL_STATES: TestState[] = [
|
const ALL_STATES: TestState[] = [
|
||||||
"draft",
|
"draft",
|
||||||
"red_executing",
|
"red_executing",
|
||||||
|
"red_review",
|
||||||
"blue_evaluating",
|
"blue_evaluating",
|
||||||
|
"blue_review",
|
||||||
"in_review",
|
"in_review",
|
||||||
"validated",
|
"validated",
|
||||||
"rejected",
|
"rejected",
|
||||||
@@ -63,8 +69,12 @@ function currentTeamForState(state: TestState): string {
|
|||||||
case "draft":
|
case "draft":
|
||||||
case "red_executing":
|
case "red_executing":
|
||||||
return "Red Team";
|
return "Red Team";
|
||||||
|
case "red_review":
|
||||||
|
return "Red Lead";
|
||||||
case "blue_evaluating":
|
case "blue_evaluating":
|
||||||
return "Blue Team";
|
return "Blue Team";
|
||||||
|
case "blue_review":
|
||||||
|
return "Blue Lead";
|
||||||
case "in_review":
|
case "in_review":
|
||||||
return "Managers";
|
return "Managers";
|
||||||
case "validated":
|
case "validated":
|
||||||
@@ -429,7 +439,9 @@ export default function TestsPage() {
|
|||||||
const icons: Record<TestState, React.ReactNode> = {
|
const icons: Record<TestState, React.ReactNode> = {
|
||||||
draft: <Clock className="h-5 w-5 text-gray-400" />,
|
draft: <Clock className="h-5 w-5 text-gray-400" />,
|
||||||
red_executing: <Play className="h-5 w-5 text-orange-400" />,
|
red_executing: <Play className="h-5 w-5 text-orange-400" />,
|
||||||
|
red_review: <Shield className="h-5 w-5 text-amber-400" />,
|
||||||
blue_evaluating: <Shield className="h-5 w-5 text-indigo-400" />,
|
blue_evaluating: <Shield className="h-5 w-5 text-indigo-400" />,
|
||||||
|
blue_review: <Shield className="h-5 w-5 text-purple-400" />,
|
||||||
in_review: <Eye className="h-5 w-5 text-blue-400" />,
|
in_review: <Eye className="h-5 w-5 text-blue-400" />,
|
||||||
validated: <CheckCircle className="h-5 w-5 text-green-400" />,
|
validated: <CheckCircle className="h-5 w-5 text-green-400" />,
|
||||||
rejected: <XCircle className="h-5 w-5 text-red-400" />,
|
rejected: <XCircle className="h-5 w-5 text-red-400" />,
|
||||||
@@ -438,7 +450,9 @@ export default function TestsPage() {
|
|||||||
const colorMap: Record<TestState, string> = {
|
const colorMap: Record<TestState, string> = {
|
||||||
draft: "text-gray-400",
|
draft: "text-gray-400",
|
||||||
red_executing: "text-orange-400",
|
red_executing: "text-orange-400",
|
||||||
|
red_review: "text-amber-400",
|
||||||
blue_evaluating: "text-indigo-400",
|
blue_evaluating: "text-indigo-400",
|
||||||
|
blue_review: "text-purple-400",
|
||||||
in_review: "text-blue-400",
|
in_review: "text-blue-400",
|
||||||
validated: "text-green-400",
|
validated: "text-green-400",
|
||||||
rejected: "text-red-400",
|
rejected: "text-red-400",
|
||||||
@@ -708,7 +722,9 @@ function TestTable({
|
|||||||
testStateBadgeColors[test.state]
|
testStateBadgeColors[test.state]
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{testStateLabels[test.state]}
|
{test.state === "blue_evaluating" && !test.blue_work_started_at
|
||||||
|
? "Queued Blue Team"
|
||||||
|
: testStateLabels[test.state]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ export type TechniqueStatus =
|
|||||||
export type TestState =
|
export type TestState =
|
||||||
| "draft"
|
| "draft"
|
||||||
| "red_executing"
|
| "red_executing"
|
||||||
|
| "red_review" // Red Lead reviews the operator's work before it queues for Blue Team
|
||||||
| "blue_evaluating"
|
| "blue_evaluating"
|
||||||
|
| "blue_review" // Blue Lead reviews the operator's work before cross-validation
|
||||||
| "in_review"
|
| "in_review"
|
||||||
| "validated"
|
| "validated"
|
||||||
| "rejected"
|
| "rejected"
|
||||||
@@ -121,6 +123,19 @@ export interface Test {
|
|||||||
red_tech_assignee: string | null;
|
red_tech_assignee: string | null;
|
||||||
blue_tech_assignee: string | null;
|
blue_tech_assignee: string | null;
|
||||||
|
|
||||||
|
// Red Team review fields
|
||||||
|
red_reviewer_assignee: string | null;
|
||||||
|
red_review_by: string | null;
|
||||||
|
red_review_at: string | null;
|
||||||
|
red_review_notes: string | null;
|
||||||
|
|
||||||
|
// Blue Team review fields
|
||||||
|
blue_reviewer_assignee: string | null;
|
||||||
|
blue_review_by: string | null;
|
||||||
|
blue_review_at: string | null;
|
||||||
|
blue_review_notes: string | null;
|
||||||
|
system_gaps: string | null;
|
||||||
|
|
||||||
// On-hold fields
|
// On-hold fields
|
||||||
is_on_hold: boolean;
|
is_on_hold: boolean;
|
||||||
hold_reason: string | null;
|
hold_reason: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user