Compare commits
10 Commits
af65681179
...
388c9773ab
| 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"
|
||||
# Assign 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"
|
||||
blue_evaluating = "blue_evaluating"
|
||||
# Blue Lead reviews the operator's work before cross-validation
|
||||
blue_review = "blue_review"
|
||||
# Assign in_review = "in_review"
|
||||
in_review = "in_review"
|
||||
# Assign validated = "validated"
|
||||
|
||||
@@ -60,8 +60,12 @@ class TestState(str, enum.Enum):
|
||||
draft = "draft"
|
||||
# Assign 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"
|
||||
blue_evaluating = "blue_evaluating"
|
||||
# Blue Lead reviews the operator's work before cross-validation
|
||||
blue_review = "blue_review"
|
||||
# Assign in_review = "in_review"
|
||||
in_review = "in_review"
|
||||
# Assign validated = "validated"
|
||||
@@ -74,8 +78,10 @@ class TestState(str, enum.Enum):
|
||||
# Assign VALID_TRANSITIONS = {
|
||||
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
||||
TestState.draft: [TestState.red_executing],
|
||||
TestState.red_executing: [TestState.blue_evaluating],
|
||||
TestState.blue_evaluating: [TestState.in_review],
|
||||
TestState.red_executing: [TestState.red_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.disputed: [TestState.validated, TestState.rejected],
|
||||
TestState.rejected: [TestState.draft],
|
||||
@@ -363,10 +369,12 @@ class TestEntity:
|
||||
|
||||
# Define function submit_red_evidence
|
||||
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
|
||||
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:
|
||||
int: Total seconds the red phase was paused.
|
||||
@@ -374,13 +382,9 @@ class TestEntity:
|
||||
# Assign paused_extra = self._auto_resume()
|
||||
paused_extra = self._auto_resume()
|
||||
# Call self._transition()
|
||||
self._transition(TestState.blue_evaluating)
|
||||
self._transition(TestState.red_review)
|
||||
# Assign 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()
|
||||
self._events.append(DomainEvent(
|
||||
# Literal argument value
|
||||
@@ -390,9 +394,32 @@ class TestEntity:
|
||||
# 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
|
||||
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
|
||||
during this phase (for worklog calculation).
|
||||
@@ -403,7 +430,7 @@ class TestEntity:
|
||||
# Assign paused_extra = self._auto_resume()
|
||||
paused_extra = self._auto_resume()
|
||||
# Call self._transition()
|
||||
self._transition(TestState.in_review)
|
||||
self._transition(TestState.blue_review)
|
||||
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
||||
total_paused = self.blue_paused_seconds + paused_extra
|
||||
# Call self._events.append()
|
||||
@@ -415,6 +442,39 @@ class TestEntity:
|
||||
# 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
|
||||
def pause_timer(self) -> None:
|
||||
"""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)
|
||||
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 ─────────────────────────────────────────────
|
||||
is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||
hold_reason = Column(Text, nullable=True)
|
||||
@@ -147,6 +159,11 @@ class Test(Base):
|
||||
red_tech_assigned_user = relationship("User", foreign_keys=[red_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])
|
||||
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||
# 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}/blue — Blue Team updates (blue_evaluating)
|
||||
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}/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-blue — Blue Lead validates
|
||||
POST /tests/{id}/reopen — rejected → draft
|
||||
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
|
||||
@@ -52,12 +58,14 @@ from app.models.user import User
|
||||
# Import from app.schemas.test
|
||||
from app.schemas.test import (
|
||||
TestAssign,
|
||||
TestBlueReview,
|
||||
TestBlueUpdate,
|
||||
TestBlueValidate,
|
||||
TestClassificationUpdate,
|
||||
TestCreate,
|
||||
TestHold,
|
||||
TestOut,
|
||||
TestRedReview,
|
||||
TestRedUpdate,
|
||||
TestRedValidate,
|
||||
TestRemediationUpdate,
|
||||
@@ -126,7 +134,12 @@ from app.services.test_crud_service import (
|
||||
from app.services.test_workflow_service import (
|
||||
start_execution as wf_start_execution,
|
||||
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,
|
||||
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,
|
||||
validate_as_red_lead as wf_validate_red,
|
||||
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"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -406,9 +459,12 @@ def get_test(
|
||||
|
||||
Returns:
|
||||
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)
|
||||
return crud_get_test_detail(db, test_id)
|
||||
test = 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -125,6 +125,27 @@ class TestBlueValidate(BaseModel):
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -248,6 +269,17 @@ class TestOut(BaseModel):
|
||||
red_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
|
||||
is_on_hold: bool = False
|
||||
hold_reason: str | None = None
|
||||
|
||||
@@ -307,7 +307,9 @@ _SEVERITY_TO_PRIORITY: dict[str, str] = {
|
||||
_STATE_EMOJI: dict[str, str] = {
|
||||
"draft": "📝 Draft",
|
||||
"red_executing": "🔴 Red Team Executing",
|
||||
"red_review": "🔎 Red Lead Review",
|
||||
"blue_evaluating": "🔵 Blue Team Evaluating",
|
||||
"blue_review": "🔎 Blue Lead Review",
|
||||
"in_review": "📋 In Review",
|
||||
"validated": "✅ Validated",
|
||||
"rejected": "❌ Rejected",
|
||||
@@ -370,6 +372,18 @@ def _build_state_comment(
|
||||
"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":
|
||||
lines += [
|
||||
"Red Team has finished execution and submitted evidence for Blue Team evaluation.",
|
||||
@@ -667,6 +681,7 @@ def push_test_event(
|
||||
new_state: str,
|
||||
*,
|
||||
extra: dict | None = None,
|
||||
assignee: User | None = None,
|
||||
) -> None:
|
||||
"""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,
|
||||
)
|
||||
|
||||
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()
|
||||
db.flush()
|
||||
logger.info(
|
||||
|
||||
@@ -26,7 +26,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
# Import settings from app.config
|
||||
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.models.enums import TestState, TeamSide
|
||||
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(
|
||||
test = transition_state(
|
||||
db, test, TestState.blue_evaluating, user,
|
||||
db, test, TestState.red_review, user,
|
||||
# Keyword argument: action_name
|
||||
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}",
|
||||
)
|
||||
|
||||
# Start Blue Team timer
|
||||
test.blue_started_at = now
|
||||
# Assign test.blue_paused_seconds = 0
|
||||
test.blue_paused_seconds = 0
|
||||
reviewer = select_reviewer(
|
||||
db, role="red_lead",
|
||||
exclude_user_id=user.id if user.role == "red_lead" else None,
|
||||
)
|
||||
test.red_reviewer_assignee = reviewer.id
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
create_notification(
|
||||
db, user_id=reviewer.id, type="review_assigned",
|
||||
title="Test awaiting your review",
|
||||
message=f'Test "{test.name}" is waiting for your red-team review.',
|
||||
entity_type="test", entity_id=test.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
push_test_event(db, test, user, "red_review", assignee=reviewer)
|
||||
except Exception as e:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
def approve_red_review(db: Session, test: Test, user: User, notes: str | None = None) -> Test:
|
||||
"""Red Lead approves the operator's work — moves red_review to blue_evaluating."""
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.approve_red_review()
|
||||
entity.apply_to(test)
|
||||
test.red_review_by = user.id
|
||||
test.red_review_at = datetime.utcnow()
|
||||
test.red_review_notes = notes
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="approve_red_review",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"notes": notes, "test_name": test.name},
|
||||
)
|
||||
|
||||
try:
|
||||
notify_test_state_change(db, test, "blue_evaluating")
|
||||
except Exception as e:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
@@ -301,6 +343,39 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> 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:
|
||||
"""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(
|
||||
test = transition_state(
|
||||
db, test, TestState.in_review, user,
|
||||
db, test, TestState.blue_review, user,
|
||||
# Keyword argument: action_name
|
||||
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}",
|
||||
)
|
||||
|
||||
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:
|
||||
from app.services.jira_service import push_test_event
|
||||
push_test_event(db, test, user, "in_review")
|
||||
@@ -502,6 +692,52 @@ def resume_timer(db: Session, test: Test, user: User) -> Test:
|
||||
return test
|
||||
|
||||
|
||||
def select_reviewer(
|
||||
db: Session,
|
||||
*,
|
||||
role: str,
|
||||
exclude_user_id: uuid.UUID | None = None,
|
||||
) -> User:
|
||||
"""Pick the least-loaded active user with *role* to review a test.
|
||||
|
||||
Load is measured as the count of tests currently sitting in the
|
||||
matching review state (``red_review`` for role ``red_lead``,
|
||||
``blue_review`` for role ``blue_lead``) with that user set as the
|
||||
reviewer. Ties broken by username for determinism.
|
||||
|
||||
Raises BusinessRuleViolation if no eligible reviewer exists (e.g. the
|
||||
only lead is the person who executed the test, or there are no leads
|
||||
with this role at all).
|
||||
"""
|
||||
review_state = TestState.red_review if role == "red_lead" else TestState.blue_review
|
||||
reviewer_field = Test.red_reviewer_assignee if role == "red_lead" else Test.blue_reviewer_assignee
|
||||
|
||||
candidates_query = db.query(User).filter(User.role == role, User.is_active == True) # noqa: E712
|
||||
if exclude_user_id is not None:
|
||||
candidates_query = candidates_query.filter(User.id != exclude_user_id)
|
||||
candidates = candidates_query.order_by(User.username).all()
|
||||
|
||||
if not candidates:
|
||||
raise BusinessRuleViolation(
|
||||
f"No available {role} to review this test (cannot self-review, "
|
||||
f"and no other {role} is active)"
|
||||
)
|
||||
|
||||
best_user = None
|
||||
best_count = None
|
||||
for candidate in candidates:
|
||||
count = (
|
||||
db.query(Test)
|
||||
.filter(Test.state == review_state, reviewer_field == candidate.id)
|
||||
.count()
|
||||
)
|
||||
if best_count is None or count < best_count:
|
||||
best_user = candidate
|
||||
best_count = count
|
||||
|
||||
return best_user
|
||||
|
||||
|
||||
# Define function _create_phase_worklog
|
||||
def _create_phase_worklog(
|
||||
# Entry: db
|
||||
|
||||
@@ -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.models.enums import TestState
|
||||
from app.services.test_workflow_service import (
|
||||
approve_red_review,
|
||||
can_transition,
|
||||
reopen_test,
|
||||
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")
|
||||
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)
|
||||
user = _make_user("red_tech")
|
||||
db = _make_db()
|
||||
mock_select_reviewer.return_value = _make_user("red_lead")
|
||||
|
||||
result = submit_red_evidence(db, test, user)
|
||||
|
||||
assert result.state == TestState.blue_evaluating
|
||||
assert result.state == TestState.red_review
|
||||
db.flush.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")
|
||||
def test_transitions_generate_audit_logs(mock_log):
|
||||
def test_transitions_generate_audit_logs(mock_log, mock_select_reviewer):
|
||||
test = _make_test(TestState.draft)
|
||||
user = _make_user("red_tech")
|
||||
db = _make_db()
|
||||
mock_select_reviewer.return_value = _make_user("red_lead")
|
||||
|
||||
start_execution(db, test, user)
|
||||
assert mock_log.call_count >= 1
|
||||
@@ -207,8 +212,12 @@ def test_transitions_generate_audit_logs(mock_log):
|
||||
assert mock_log.call_count > c1
|
||||
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
|
||||
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")
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ from app.domain.test_entity import (
|
||||
VALID_TRANSITIONS,
|
||||
DomainEvent,
|
||||
)
|
||||
from app.domain.errors import BusinessRuleViolation, InvalidStateTransition
|
||||
from app.domain.errors import (
|
||||
BusinessRuleViolation,
|
||||
InvalidOperationError,
|
||||
InvalidStateTransition,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
@@ -97,9 +101,15 @@ def test_validated_is_terminal():
|
||||
("draft", "red_executing", True),
|
||||
("draft", "validated", False),
|
||||
("draft", "blue_evaluating", False),
|
||||
("red_executing", "blue_evaluating", True),
|
||||
("red_executing", "red_review", True),
|
||||
("red_executing", "blue_evaluating", 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", "rejected", True),
|
||||
("in_review", "draft", False),
|
||||
@@ -187,10 +197,9 @@ def test_start_execution_from_wrong_state():
|
||||
def test_submit_red_evidence():
|
||||
e = _entity("red_executing", red_started_at=datetime.utcnow())
|
||||
total_paused = e.submit_red_evidence()
|
||||
assert e.state == TestState.blue_evaluating
|
||||
assert e.state == TestState.red_review
|
||||
assert total_paused == 0
|
||||
assert e.blue_started_at is not None
|
||||
assert e.blue_paused_seconds == 0
|
||||
assert e.blue_started_at is None
|
||||
|
||||
|
||||
def test_submit_red_evidence_auto_resumes():
|
||||
@@ -201,13 +210,55 @@ def test_submit_red_evidence_auto_resumes():
|
||||
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 ───────────────────────────────
|
||||
|
||||
|
||||
def test_submit_blue_evidence():
|
||||
e = _entity("blue_evaluating", blue_started_at=datetime.utcnow())
|
||||
total_paused = e.submit_blue_evidence()
|
||||
assert e.state == TestState.in_review
|
||||
assert e.state == TestState.blue_review
|
||||
assert total_paused == 0
|
||||
|
||||
|
||||
@@ -219,6 +270,62 @@ def test_submit_blue_evidence_auto_resumes():
|
||||
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 ────────────────────────────────────
|
||||
|
||||
|
||||
@@ -408,9 +515,15 @@ def test_full_lifecycle_happy_path():
|
||||
assert e.state == TestState.red_executing
|
||||
|
||||
e.submit_red_evidence()
|
||||
assert e.state == TestState.red_review
|
||||
|
||||
e.approve_red_review()
|
||||
assert e.state == TestState.blue_evaluating
|
||||
|
||||
e.submit_blue_evidence()
|
||||
assert e.state == TestState.blue_review
|
||||
|
||||
e.approve_blue_review()
|
||||
assert e.state == TestState.in_review
|
||||
|
||||
e.validate_red("approved", by=uid_red)
|
||||
@@ -428,7 +541,9 @@ def test_full_lifecycle_rejection_reopen():
|
||||
e = _entity("draft")
|
||||
e.start_execution()
|
||||
e.submit_red_evidence()
|
||||
e.approve_red_review()
|
||||
e.submit_blue_evidence()
|
||||
e.approve_blue_review()
|
||||
e.validate_red("rejected", by=uuid.uuid4())
|
||||
assert e.state == TestState.rejected
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ without requiring a running database.
|
||||
import sys
|
||||
import os
|
||||
import uuid
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from types import ModuleType
|
||||
from datetime import datetime
|
||||
@@ -100,13 +101,16 @@ for _mod in [
|
||||
from fastapi import HTTPException
|
||||
from app.domain.exceptions import InvalidOperationError, InvalidTransitionError
|
||||
from app.models.enums import TestState, TestResult
|
||||
from app.models.test import Test
|
||||
from app.services.test_workflow_service import (
|
||||
VALID_TRANSITIONS,
|
||||
can_transition,
|
||||
transition_state,
|
||||
start_execution,
|
||||
submit_red_evidence,
|
||||
approve_red_review,
|
||||
submit_blue_evidence,
|
||||
approve_blue_review,
|
||||
validate_as_red_lead,
|
||||
validate_as_blue_lead,
|
||||
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")
|
||||
def test_full_happy_path(mock_log):
|
||||
"""draft -> red_executing -> blue_evaluating -> in_review -> validated"""
|
||||
def test_full_happy_path(mock_log, mock_select_reviewer):
|
||||
"""draft -> red_executing -> red_review -> blue_evaluating -> blue_review -> in_review -> validated"""
|
||||
test = _make_test(TestState.draft)
|
||||
red_tech = _make_user("red_tech")
|
||||
blue_tech = _make_user("blue_tech")
|
||||
red_lead = _make_user("red_lead")
|
||||
blue_lead = _make_user("blue_lead")
|
||||
db = _make_db()
|
||||
mock_select_reviewer.side_effect = [red_lead, blue_lead]
|
||||
|
||||
# Step 1: draft -> red_executing
|
||||
result = start_execution(db, test, red_tech)
|
||||
assert result.state == TestState.red_executing
|
||||
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)
|
||||
assert result.state == TestState.red_review
|
||||
result = approve_red_review(db, result, red_lead)
|
||||
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)
|
||||
assert result.state == TestState.blue_review
|
||||
result = approve_blue_review(db, result, blue_lead)
|
||||
assert result.state == TestState.in_review
|
||||
|
||||
# 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")
|
||||
def test_rejection_and_reopen(mock_log):
|
||||
def test_rejection_and_reopen(mock_log, mock_select_reviewer):
|
||||
"""in_review -> rejected -> draft -> red_executing -> ..."""
|
||||
test = _make_test(TestState.draft)
|
||||
red_tech = _make_user("red_tech")
|
||||
blue_tech = _make_user("blue_tech")
|
||||
red_lead = _make_user("red_lead")
|
||||
blue_lead = _make_user("blue_lead")
|
||||
db = _make_db()
|
||||
mock_select_reviewer.side_effect = [red_lead, blue_lead]
|
||||
|
||||
# Advance to in_review
|
||||
start_execution(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)
|
||||
approve_blue_review(db, test, blue_lead)
|
||||
assert test.state == TestState.in_review
|
||||
|
||||
# Red Lead rejects -> rejected
|
||||
@@ -577,6 +592,169 @@ def test_cannot_reopen_non_rejected_test(mock_log):
|
||||
# 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__":
|
||||
print("T-125 Validation: Workflow Tests")
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 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 ────────────────────────────────────────────────
|
||||
|
||||
/** Red Lead approves/rejects the red side. */
|
||||
|
||||
@@ -22,7 +22,9 @@ import type { Test, TestState, TestTemplateSummary } from "../types/models";
|
||||
const stateBadge: Record<TestState, string> = {
|
||||
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_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_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",
|
||||
validated: "bg-green-900/50 text-green-400 border-green-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 }> = {
|
||||
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_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_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" },
|
||||
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" },
|
||||
|
||||
@@ -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_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)
|
||||
const isDetected = canEditBlue
|
||||
? blueDraft.detection_result === "detected" || blueDraft.detection_result === "partially_detected"
|
||||
@@ -168,7 +175,19 @@ export default function TeamTabs({
|
||||
|
||||
// ── 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">
|
||||
{/* Locked hint for red_tech in draft state */}
|
||||
{redLockedHint && (
|
||||
@@ -343,11 +362,24 @@ export default function TeamTabs({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// ── 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">
|
||||
{/* Locked hint for blue_tech before Start Evaluation */}
|
||||
{blueLockedHint && (
|
||||
@@ -610,7 +642,8 @@ export default function TeamTabs({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// ── Summary Tab ──────────────────────────────────────────────────
|
||||
|
||||
@@ -713,6 +746,12 @@ export default function TeamTabs({
|
||||
</dd>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,9 @@ import LiveTimer from "./LiveTimer";
|
||||
const PROGRESS_STEPS: { key: TestState; label: string }[] = [
|
||||
{ key: "draft", label: "Draft" },
|
||||
{ key: "red_executing", label: "Red Exec" },
|
||||
{ key: "red_review", label: "Red Review" },
|
||||
{ key: "blue_evaluating", label: "Blue Eval" },
|
||||
{ key: "blue_review", label: "Blue Review" },
|
||||
{ key: "in_review", label: "Review" },
|
||||
{ key: "validated", label: "Validated" },
|
||||
];
|
||||
@@ -34,11 +36,13 @@ const PROGRESS_STEPS: { key: TestState; label: string }[] = [
|
||||
const STATE_INDEX: Record<TestState, number> = {
|
||||
draft: 0,
|
||||
red_executing: 1,
|
||||
blue_evaluating: 2,
|
||||
in_review: 3,
|
||||
validated: 4,
|
||||
red_review: 2,
|
||||
blue_evaluating: 3,
|
||||
blue_review: 4,
|
||||
in_review: 5,
|
||||
validated: 6,
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -46,13 +50,23 @@ const STATE_INDEX: Record<TestState, number> = {
|
||||
const STATE_BADGE: Record<TestState, string> = {
|
||||
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_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_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",
|
||||
validated: "bg-green-900/50 text-green-400 border-green-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",
|
||||
};
|
||||
|
||||
/** "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 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface TestDetailHeaderProps {
|
||||
@@ -64,6 +78,7 @@ interface TestDetailHeaderProps {
|
||||
onSubmitBlue: () => void;
|
||||
onStartBlueWork: () => void;
|
||||
onOpenValidateModal: (side: "red" | "blue") => void;
|
||||
onOpenReviewModal: (side: "red" | "blue") => void;
|
||||
onReopen: () => void;
|
||||
onPauseTimer: () => void;
|
||||
onResumeTimer: () => void;
|
||||
@@ -84,6 +99,7 @@ export default function TestDetailHeader({
|
||||
onSubmitBlue,
|
||||
onStartBlueWork,
|
||||
onOpenValidateModal,
|
||||
onOpenReviewModal,
|
||||
onReopen,
|
||||
onPauseTimer,
|
||||
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:
|
||||
// - if not picked up yet: show "Start Evaluation" button
|
||||
// - if already picked up: show "Submit for Review" button
|
||||
@@ -472,7 +522,7 @@ export default function TestDetailHeader({
|
||||
STATE_BADGE[test.state]
|
||||
}`}
|
||||
>
|
||||
{test.state.replace(/_/g, " ")}
|
||||
{getStateLabel(test)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
|
||||
@@ -59,7 +59,9 @@ const typeLabels: Record<string, string> = {
|
||||
const testStateColors: Record<string, string> = {
|
||||
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_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_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",
|
||||
validated: "bg-green-900/50 text-green-400 border-green-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> = {
|
||||
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_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_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",
|
||||
validated: "bg-green-900/50 text-green-400 border-green-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> = {
|
||||
draft: "Draft",
|
||||
red_executing: "Red Executing",
|
||||
red_review: "Red Review",
|
||||
blue_evaluating: "Blue Evaluating",
|
||||
blue_review: "Blue Review",
|
||||
in_review: "In Review",
|
||||
validated: "Validated",
|
||||
rejected: "Rejected",
|
||||
|
||||
@@ -213,7 +213,9 @@ export default function ReportsPage() {
|
||||
<option value="">All states</option>
|
||||
<option value="draft">Draft</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_review">Blue Review</option>
|
||||
<option value="in_review">In Review</option>
|
||||
<option value="validated">Validated</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",
|
||||
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_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_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",
|
||||
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> = {
|
||||
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_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_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",
|
||||
validated: "bg-green-900/50 text-green-400 border-green-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
|
||||
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(
|
||||
(t: { state: TestState }) => ACTIVE_STATES.includes(t.state)
|
||||
@@ -520,7 +522,9 @@ export default function TechniqueDetailPage() {
|
||||
const ACTIVE_LABEL: Partial<Record<TestState, string>> = {
|
||||
draft: "Draft",
|
||||
red_executing: "Executing",
|
||||
red_review: "Red Review",
|
||||
blue_evaluating: "Evaluating",
|
||||
blue_review: "Blue Review",
|
||||
in_review: "In Review",
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
startBlueWork,
|
||||
validateAsRedLead,
|
||||
validateAsBlueLead,
|
||||
reviewAsRedLead,
|
||||
reviewAsBlueLead,
|
||||
reopenTest,
|
||||
pauseTimer,
|
||||
resumeTimer,
|
||||
@@ -29,6 +31,7 @@ import type { TestResult, ContainmentResult, TeamSide, TestTimelineEntry } from
|
||||
import TestDetailHeader from "../components/test-detail/TestDetailHeader";
|
||||
import TeamTabs from "../components/test-detail/TeamTabs";
|
||||
import ValidationModal from "../components/test-detail/ValidationModal";
|
||||
import ReviewModal from "../components/test-detail/ReviewModal";
|
||||
import ConfirmDialog from "../components/ConfirmDialog";
|
||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
||||
@@ -56,6 +59,11 @@ export default function TestDetailPage() {
|
||||
side: "red" | "blue";
|
||||
}>({ open: false, side: "red" });
|
||||
|
||||
const [reviewModal, setReviewModal] = useState<{
|
||||
open: boolean;
|
||||
side: "red" | "blue";
|
||||
}>({ open: false, side: "red" });
|
||||
|
||||
const [confirmReopen, setConfirmReopen] = useState(false);
|
||||
const [holdModal, setHoldModal] = useState(false);
|
||||
const [holdReason, setHoldReason] = useState("");
|
||||
@@ -254,6 +262,28 @@ export default function TestDetailPage() {
|
||||
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({
|
||||
mutationFn: () => reopenTest(testId!),
|
||||
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 =
|
||||
startExecMutation.isPending ||
|
||||
submitRedMutation.isPending ||
|
||||
@@ -439,6 +485,7 @@ export default function TestDetailPage() {
|
||||
onSubmitBlue={() => submitBlueMutation.mutate()}
|
||||
onStartBlueWork={() => startBlueWorkMutation.mutate()}
|
||||
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
||||
onOpenReviewModal={(side) => setReviewModal({ open: true, side })}
|
||||
onReopen={() => setConfirmReopen(true)}
|
||||
onPauseTimer={() => pauseTimerMutation.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 */}
|
||||
{showTemplateModal && (
|
||||
<SaveAsTemplateModal
|
||||
|
||||
@@ -29,7 +29,9 @@ import { useAuth } from "../context/AuthContext";
|
||||
const testStateBadgeColors: Record<TestState, string> = {
|
||||
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_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_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",
|
||||
validated: "bg-green-900/50 text-green-400 border-green-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> = {
|
||||
draft: "Draft",
|
||||
red_executing: "Red Executing",
|
||||
red_review: "Red Review",
|
||||
blue_evaluating: "Blue Evaluating",
|
||||
blue_review: "Blue Review",
|
||||
in_review: "In Review",
|
||||
validated: "Validated",
|
||||
rejected: "Rejected",
|
||||
@@ -49,7 +53,9 @@ const testStateLabels: Record<TestState, string> = {
|
||||
const ALL_STATES: TestState[] = [
|
||||
"draft",
|
||||
"red_executing",
|
||||
"red_review",
|
||||
"blue_evaluating",
|
||||
"blue_review",
|
||||
"in_review",
|
||||
"validated",
|
||||
"rejected",
|
||||
@@ -63,8 +69,12 @@ function currentTeamForState(state: TestState): string {
|
||||
case "draft":
|
||||
case "red_executing":
|
||||
return "Red Team";
|
||||
case "red_review":
|
||||
return "Red Lead";
|
||||
case "blue_evaluating":
|
||||
return "Blue Team";
|
||||
case "blue_review":
|
||||
return "Blue Lead";
|
||||
case "in_review":
|
||||
return "Managers";
|
||||
case "validated":
|
||||
@@ -429,7 +439,9 @@ export default function TestsPage() {
|
||||
const icons: Record<TestState, React.ReactNode> = {
|
||||
draft: <Clock className="h-5 w-5 text-gray-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_review: <Shield className="h-5 w-5 text-purple-400" />,
|
||||
in_review: <Eye className="h-5 w-5 text-blue-400" />,
|
||||
validated: <CheckCircle className="h-5 w-5 text-green-400" />,
|
||||
rejected: <XCircle className="h-5 w-5 text-red-400" />,
|
||||
@@ -438,7 +450,9 @@ export default function TestsPage() {
|
||||
const colorMap: Record<TestState, string> = {
|
||||
draft: "text-gray-400",
|
||||
red_executing: "text-orange-400",
|
||||
red_review: "text-amber-400",
|
||||
blue_evaluating: "text-indigo-400",
|
||||
blue_review: "text-purple-400",
|
||||
in_review: "text-blue-400",
|
||||
validated: "text-green-400",
|
||||
rejected: "text-red-400",
|
||||
@@ -708,7 +722,9 @@ function TestTable({
|
||||
testStateBadgeColors[test.state]
|
||||
}`}
|
||||
>
|
||||
{testStateLabels[test.state]}
|
||||
{test.state === "blue_evaluating" && !test.blue_work_started_at
|
||||
? "Queued Blue Team"
|
||||
: testStateLabels[test.state]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-400 text-xs">
|
||||
|
||||
@@ -48,7 +48,9 @@ export type TechniqueStatus =
|
||||
export type TestState =
|
||||
| "draft"
|
||||
| "red_executing"
|
||||
| "red_review" // Red Lead reviews the operator's work before it queues for Blue Team
|
||||
| "blue_evaluating"
|
||||
| "blue_review" // Blue Lead reviews the operator's work before cross-validation
|
||||
| "in_review"
|
||||
| "validated"
|
||||
| "rejected"
|
||||
@@ -121,6 +123,19 @@ export interface Test {
|
||||
red_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
|
||||
is_on_hold: boolean;
|
||||
hold_reason: string | null;
|
||||
|
||||
Reference in New Issue
Block a user