Compare commits

...

10 Commits

Author SHA1 Message Date
kitos 388c9773ab docs(tests): implementation plan for red/blue review workflow
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
2026-07-06 12:08:18 +02:00
kitos d5e3df00f9 feat(tests): review panels, blind visibility UX, red_review/blue_review badges 2026-07-06 12:02:14 +02:00
kitos 82033b5037 feat(tests): types and API client for red/blue review workflow 2026-07-06 12:02:07 +02:00
kitos 8a028bf0ed feat(tests): blind red/blue visibility until both reviews are approved 2026-07-06 11:42:35 +02:00
kitos b527eeac7d feat(tests): review-red/review-blue router endpoints 2026-07-06 11:42:34 +02:00
kitos e3cd75bb56 feat(tests): retarget submit-red/submit-blue through review gates, add review decisions 2026-07-06 10:56:41 +02:00
kitos 22be620665 feat(tests): load-balanced reviewer selection and Jira reviewer sync 2026-07-06 10:56:40 +02:00
kitos c41876b62f feat(tests): migration for red_review/blue_review states and review columns 2026-07-06 10:40:39 +02:00
kitos 2d98ed5ec6 feat(tests): add review assignment columns to Test model 2026-07-06 10:39:54 +02:00
kitos 58d1da93ce feat(tests): add red_review/blue_review states to test state machine 2026-07-06 10:38:15 +02:00
27 changed files with 3054 additions and 50 deletions
@@ -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.
+4
View File
@@ -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"
+71 -11
View File
@@ -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.
+17
View File
@@ -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...
+122 -4
View File
@@ -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
# ---------------------------------------------------------------------------
+32
View File
@@ -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
+30
View File
@@ -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(
+243 -7
View File
@@ -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
+129
View File
@@ -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"
+242
View File
@@ -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"
+14 -5
View File
@@ -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")
+122 -7
View File
@@ -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
+183 -5
View File
@@ -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
+25
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
};
+5 -1
View File
@@ -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",
};
+63
View File
@@ -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
+17 -1
View File
@@ -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">
+15
View File
@@ -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;