Compare commits
2 Commits
388c9773ab
...
19c4866103
| Author | SHA1 | Date | |
|---|---|---|---|
| 19c4866103 | |||
| f53e124c50 |
@@ -83,7 +83,10 @@ VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
|||||||
TestState.blue_evaluating: [TestState.blue_review],
|
TestState.blue_evaluating: [TestState.blue_review],
|
||||||
TestState.blue_review: [TestState.in_review, TestState.blue_evaluating],
|
TestState.blue_review: [TestState.in_review, TestState.blue_evaluating],
|
||||||
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
||||||
TestState.disputed: [TestState.validated, TestState.rejected],
|
TestState.disputed: [
|
||||||
|
TestState.validated, TestState.rejected,
|
||||||
|
TestState.red_executing, TestState.blue_evaluating,
|
||||||
|
],
|
||||||
TestState.rejected: [TestState.draft],
|
TestState.rejected: [TestState.draft],
|
||||||
TestState.validated: [],
|
TestState.validated: [],
|
||||||
}
|
}
|
||||||
@@ -627,6 +630,44 @@ class TestEntity:
|
|||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent("test_reopened"))
|
self._events.append(DomainEvent("test_reopened"))
|
||||||
|
|
||||||
|
def resolve_dispute_reject(self, target: str) -> None:
|
||||||
|
"""Resolve a ``disputed`` test by routing rework to the team at fault.
|
||||||
|
|
||||||
|
Called when the lead who originally approved flips their vote to
|
||||||
|
agree with the rejection. Rather than the generic terminal
|
||||||
|
``rejected`` state (which forces a full draft restart for both
|
||||||
|
teams), the flipping lead identifies WHICH side's work needs
|
||||||
|
redoing — the test goes straight back to that team's active queue
|
||||||
|
so the other team's work is left untouched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target (str): ``"red"`` or ``"blue"`` — which team must redo work.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
target_state = TestState.red_executing if target == "red" else TestState.blue_evaluating
|
||||||
|
self._transition(target_state)
|
||||||
|
|
||||||
|
# Both leads must re-vote once the test reaches in_review again —
|
||||||
|
# clear decisions but keep notes (context for the team doing rework).
|
||||||
|
self.red_validation_status = None
|
||||||
|
self.red_validated_by = None
|
||||||
|
self.red_validated_at = None
|
||||||
|
self.blue_validation_status = None
|
||||||
|
self.blue_validated_by = None
|
||||||
|
self.blue_validated_at = None
|
||||||
|
self.paused_at = None
|
||||||
|
|
||||||
|
if target == "red":
|
||||||
|
self.red_started_at = datetime.utcnow()
|
||||||
|
self.red_paused_seconds = 0
|
||||||
|
else:
|
||||||
|
self.blue_started_at = datetime.utcnow()
|
||||||
|
self.blue_paused_seconds = 0
|
||||||
|
|
||||||
|
self._events.append(DomainEvent("dispute_resolved_to_rework", {"target": target}))
|
||||||
|
|
||||||
# -- Private -------------------------------------------------------
|
# -- Private -------------------------------------------------------
|
||||||
|
|
||||||
def _auto_resume(self) -> int:
|
def _auto_resume(self) -> int:
|
||||||
@@ -694,7 +735,14 @@ class TestEntity:
|
|||||||
|
|
||||||
# Define function _check_dual_validation
|
# Define function _check_dual_validation
|
||||||
def _check_dual_validation(self) -> None:
|
def _check_dual_validation(self) -> None:
|
||||||
"""Advance the test state once both leads have voted."""
|
"""Advance the test state once enough leads have voted.
|
||||||
|
|
||||||
|
A genuine conflict (one lead's *recorded* decision disagreeing with
|
||||||
|
the other's) routes to ``disputed`` — there are two opinions to
|
||||||
|
reconcile. A lone rejection while the other side hasn't voted yet
|
||||||
|
isn't a conflict (nothing to disagree with), so it still vetoes
|
||||||
|
straight to ``rejected`` without waiting for the second vote.
|
||||||
|
"""
|
||||||
r, b = self.red_validation_status, self.blue_validation_status
|
r, b = self.red_validation_status, self.blue_validation_status
|
||||||
|
|
||||||
if r == "approved" and b == "approved":
|
if r == "approved" and b == "approved":
|
||||||
@@ -702,7 +750,16 @@ class TestEntity:
|
|||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent("dual_validation_approved"))
|
self._events.append(DomainEvent("dual_validation_approved"))
|
||||||
|
|
||||||
elif r == "rejected" or b == "rejected":
|
elif r == "rejected" and b == "rejected":
|
||||||
# Any rejection is a veto — one lead can reject without waiting for the other
|
self.state = TestState.rejected
|
||||||
|
self._events.append(DomainEvent("dual_validation_rejected"))
|
||||||
|
|
||||||
|
elif (r == "approved" and b == "rejected") or (r == "rejected" and b == "approved"):
|
||||||
|
self.state = TestState.disputed
|
||||||
|
self._events.append(DomainEvent("dual_validation_disputed"))
|
||||||
|
|
||||||
|
elif r == "rejected" or b == "rejected":
|
||||||
|
# One side rejected while the other hasn't voted yet — no
|
||||||
|
# disagreement to dispute yet, just a straightforward veto.
|
||||||
self.state = TestState.rejected
|
self.state = TestState.rejected
|
||||||
self._events.append(DomainEvent("dual_validation_rejected"))
|
self._events.append(DomainEvent("dual_validation_rejected"))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ POST /tests/{id}/submit-blue — blue_evaluating → blue_review
|
|||||||
POST /tests/{id}/review-blue — assigned Blue Lead approves/reopens/flags gap
|
POST /tests/{id}/review-blue — assigned Blue Lead approves/reopens/flags gap
|
||||||
POST /tests/{id}/validate-red — Red Lead validates
|
POST /tests/{id}/validate-red — Red Lead validates
|
||||||
POST /tests/{id}/validate-blue — Blue Lead validates
|
POST /tests/{id}/validate-blue — Blue Lead validates
|
||||||
|
POST /tests/{id}/resolve-dispute — approver flips to reject, routes to red/blue queue
|
||||||
POST /tests/{id}/reopen — rejected → draft
|
POST /tests/{id}/reopen — rejected → draft
|
||||||
GET /tests/{id}/timeline — audit-log history for this test
|
GET /tests/{id}/timeline — audit-log history for this test
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ from app.schemas.test import (
|
|||||||
TestRedUpdate,
|
TestRedUpdate,
|
||||||
TestRedValidate,
|
TestRedValidate,
|
||||||
TestRemediationUpdate,
|
TestRemediationUpdate,
|
||||||
|
TestResolveDispute,
|
||||||
TestUpdate,
|
TestUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,6 +145,7 @@ from app.services.test_workflow_service import (
|
|||||||
start_blue_work as wf_start_blue_work,
|
start_blue_work as wf_start_blue_work,
|
||||||
validate_as_red_lead as wf_validate_red,
|
validate_as_red_lead as wf_validate_red,
|
||||||
validate_as_blue_lead as wf_validate_blue,
|
validate_as_blue_lead as wf_validate_blue,
|
||||||
|
resolve_dispute as wf_resolve_dispute,
|
||||||
reopen_test as wf_reopen,
|
reopen_test as wf_reopen,
|
||||||
handle_remediation_completed as wf_handle_remediation,
|
handle_remediation_completed as wf_handle_remediation,
|
||||||
get_retest_chain as wf_get_retest_chain,
|
get_retest_chain as wf_get_retest_chain,
|
||||||
@@ -1158,6 +1161,27 @@ def validate_blue(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/resolve-dispute — approver flips to reject, picks a queue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/resolve-dispute", response_model=TestOut)
|
||||||
|
def resolve_dispute(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestResolveDispute,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "admin")),
|
||||||
|
) -> TestOut:
|
||||||
|
"""The lead who approved flips their vote to reject, choosing which team must redo the work."""
|
||||||
|
test = crud_get_test_with_technique(db, test_id)
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
test = wf_resolve_dispute(db, test, current_user, payload.target_team, notes=payload.notes)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /tests/{id}/reopen — rejected → draft
|
# POST /tests/{id}/reopen — rejected → draft
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ class TestBlueValidate(BaseModel):
|
|||||||
blue_validation_notes: str | None = None
|
blue_validation_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dispute resolution ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveDispute(BaseModel):
|
||||||
|
"""Payload sent by the approving lead flipping their vote to reject."""
|
||||||
|
|
||||||
|
target_team: str # "red" | "blue" — which team must redo the work
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ── Red Lead review gate (pre-Blue-Team) ────────────────────────────
|
# ── Red Lead review gate (pre-Blue-Team) ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ from app.models.evidence import Evidence
|
|||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
from app.services.notification_service import notify_test_state_change, create_notification
|
from app.services.notification_service import (
|
||||||
|
notify_test_state_change,
|
||||||
|
create_notification,
|
||||||
|
notify_role_with_email,
|
||||||
|
)
|
||||||
|
|
||||||
# Assign logger = logging.getLogger(__name__)
|
# Assign logger = logging.getLogger(__name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1063,6 +1067,22 @@ def _dispatch_dual_validation_effects(
|
|||||||
elif event.name == "dual_validation_disputed":
|
elif event.name == "dual_validation_disputed":
|
||||||
# Notify the lead who APPROVED asking them to review the rejection
|
# Notify the lead who APPROVED asking them to review the rejection
|
||||||
_notify_validation_conflict(db, test, actor)
|
_notify_validation_conflict(db, test, actor)
|
||||||
|
# Notify managers too, in case the dispute stalls and needs escalation
|
||||||
|
try:
|
||||||
|
notify_role_with_email(
|
||||||
|
db,
|
||||||
|
role="manager",
|
||||||
|
type="validation_disputed",
|
||||||
|
title="Validation dispute needs oversight",
|
||||||
|
message=(
|
||||||
|
f'Test "{test.name}" has a validation dispute — one lead approved, '
|
||||||
|
f'the other rejected. Escalate if it does not resolve.'
|
||||||
|
),
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Manager dispute notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _notify_validation_conflict(db: Session, test: Test, actor: User | None) -> None:
|
def _notify_validation_conflict(db: Session, test: Test, actor: User | None) -> None:
|
||||||
@@ -1105,7 +1125,7 @@ def _notify_validation_conflict(db: Session, test: Test, actor: User | None) ->
|
|||||||
f"or contact {rejector_role} to resolve the disagreement."
|
f"or contact {rejector_role} to resolve the disagreement."
|
||||||
),
|
),
|
||||||
entity_type="test",
|
entity_type="test",
|
||||||
entity_id=str(test.id),
|
entity_id=test.id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1114,6 +1134,70 @@ def _notify_validation_conflict(db: Session, test: Test, actor: User | None) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes: str | None = None) -> Test:
|
||||||
|
"""Resolve a disputed test by flipping the approving vote to reject.
|
||||||
|
|
||||||
|
Called by the lead who originally approved, now agreeing with the other
|
||||||
|
lead's rejection. Unlike a plain rejection, the flipping lead identifies
|
||||||
|
WHICH team's work needs redoing — the test routes straight back to that
|
||||||
|
team's active queue (red_executing or blue_evaluating) instead of the
|
||||||
|
generic 'rejected' state that would force a full draft restart for both
|
||||||
|
teams.
|
||||||
|
"""
|
||||||
|
if target_team not in ("red", "blue"):
|
||||||
|
raise InvalidOperationError("target_team must be 'red' or 'blue'")
|
||||||
|
|
||||||
|
if user.role != "admin":
|
||||||
|
is_red_approver = user.role == "red_lead" and test.red_validation_status == "approved"
|
||||||
|
is_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved"
|
||||||
|
if not (is_red_approver or is_blue_approver):
|
||||||
|
raise InvalidOperationError(
|
||||||
|
"Only the lead who approved can flip their vote to resolve this dispute"
|
||||||
|
)
|
||||||
|
|
||||||
|
entity = TestEntity.from_orm(test)
|
||||||
|
entity.resolve_dispute_reject(target_team)
|
||||||
|
entity.apply_to(test)
|
||||||
|
|
||||||
|
if target_team == "blue":
|
||||||
|
test.blue_work_started_at = None # split responsibility: entity doesn't own this field
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
new_state = "red_executing" if target_team == "red" else "blue_evaluating"
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="resolve_dispute",
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
details={"target_team": target_team, "notes": notes, "test_name": test.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
notify_test_state_change(db, test, new_state)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
operator_id = test.red_tech_assignee if target_team == "red" else test.blue_tech_assignee
|
||||||
|
if operator_id and notes:
|
||||||
|
try:
|
||||||
|
create_notification(
|
||||||
|
db, user_id=operator_id, type="test_reopened",
|
||||||
|
title="Test sent back for rework (validation dispute)",
|
||||||
|
message=f'Test "{test.name}" needs rework: {notes[:200]}',
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_test_event
|
||||||
|
push_test_event(db, test, user, new_state)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# Define function handle_remediation_completed
|
# Define function handle_remediation_completed
|
||||||
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
|
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
|
||||||
"""Create a re-test when remediation is completed.
|
"""Create a re-test when remediation is completed.
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""HTTP-level tests for dispute resolution: POST /tests/{id}/resolve-dispute
|
||||||
|
and the manager notification fired when a test enters 'disputed'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.evidence import Evidence
|
||||||
|
from app.models.enums import TeamSide
|
||||||
|
|
||||||
|
|
||||||
|
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.200", "name": "Command Line"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique):
|
||||||
|
"""Drive a fresh test all the way to disputed: red approves, blue rejects."""
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests", auth_headers,
|
||||||
|
json={"technique_id": technique, "name": "Dispute test"},
|
||||||
|
)
|
||||||
|
test_id = resp.json()["id"]
|
||||||
|
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||||
|
_add_evidence(db, test_id, TeamSide.red)
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve"})
|
||||||
|
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/start-blue-work", blue_tech_headers)
|
||||||
|
_add_evidence(db, test_id, TeamSide.blue)
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/submit-blue", blue_tech_headers)
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "approve"})
|
||||||
|
|
||||||
|
red_vote = api(
|
||||||
|
"post", f"/api/v1/tests/{test_id}/validate-red", red_lead_headers,
|
||||||
|
json={"red_validation_status": "approved"},
|
||||||
|
)
|
||||||
|
assert red_vote.status_code == 200, red_vote.text
|
||||||
|
|
||||||
|
blue_vote = api(
|
||||||
|
"post", f"/api/v1/tests/{test_id}/validate-blue", blue_lead_headers,
|
||||||
|
json={"blue_validation_status": "rejected", "blue_validation_notes": "Detection insufficient"},
|
||||||
|
)
|
||||||
|
assert blue_vote.status_code == 200, blue_vote.text
|
||||||
|
assert blue_vote.json()["state"] == "disputed"
|
||||||
|
|
||||||
|
return test_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_notified_on_dispute(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, manager_headers, manager_user, technique,
|
||||||
|
):
|
||||||
|
_reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique)
|
||||||
|
|
||||||
|
resp = api("get", "/api/v1/notifications", manager_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
notifications = resp.json()
|
||||||
|
assert any(n["type"] == "validation_disputed" for n in notifications)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_dispute_forbidden_for_non_approver(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique,
|
||||||
|
):
|
||||||
|
"""Blue Lead rejected (didn't approve) — they cannot flip the vote here."""
|
||||||
|
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/tests/{test_id}/resolve-dispute", blue_lead_headers,
|
||||||
|
json={"target_team": "blue"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_dispute_routes_to_red_queue(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique,
|
||||||
|
):
|
||||||
|
"""Red Lead approved; flips to reject and sends it to the Red queue."""
|
||||||
|
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, technique)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/tests/{test_id}/resolve-dispute", red_lead_headers,
|
||||||
|
json={"target_team": "red", "notes": "redo the attack with more detail"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["state"] == "red_executing"
|
||||||
|
assert body["red_validation_status"] is None
|
||||||
|
assert body["blue_validation_status"] is None
|
||||||
@@ -401,9 +401,32 @@ def test_dual_validation_red_rejects():
|
|||||||
|
|
||||||
|
|
||||||
def test_dual_validation_blue_rejects():
|
def test_dual_validation_blue_rejects():
|
||||||
|
"""Red already approved; Blue then rejects — a genuine conflict, not a veto."""
|
||||||
e = _entity("in_review")
|
e = _entity("in_review")
|
||||||
e.validate_red("approved", by=uuid.uuid4())
|
e.validate_red("approved", by=uuid.uuid4())
|
||||||
e.validate_blue("rejected", by=uuid.uuid4())
|
e.validate_blue("rejected", by=uuid.uuid4())
|
||||||
|
assert e.state == TestState.disputed
|
||||||
|
assert any(ev.name == "dual_validation_disputed" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_validation_blue_approved_then_red_rejects():
|
||||||
|
"""Blue already approved; Red then rejects — also a genuine conflict."""
|
||||||
|
e = _entity("in_review")
|
||||||
|
e.validate_blue("approved", by=uuid.uuid4())
|
||||||
|
e.validate_red("rejected", by=uuid.uuid4())
|
||||||
|
assert e.state == TestState.disputed
|
||||||
|
|
||||||
|
|
||||||
|
def test_dual_validation_both_rejected_from_disputed():
|
||||||
|
"""A disputed test where the approving lead flips to reject (via the
|
||||||
|
plain validate call, not resolve_dispute_reject) still lands on the
|
||||||
|
generic terminal 'rejected' state — both leads now agree it's bad."""
|
||||||
|
e = _entity(
|
||||||
|
"disputed",
|
||||||
|
red_validation_status="approved",
|
||||||
|
blue_validation_status="rejected",
|
||||||
|
)
|
||||||
|
e.validate_red("rejected", by=uuid.uuid4())
|
||||||
assert e.state == TestState.rejected
|
assert e.state == TestState.rejected
|
||||||
|
|
||||||
|
|
||||||
@@ -561,3 +584,52 @@ def test_is_terminal():
|
|||||||
assert _entity("validated").is_terminal is True
|
assert _entity("validated").is_terminal is True
|
||||||
assert _entity("rejected").is_terminal is False
|
assert _entity("rejected").is_terminal is False
|
||||||
assert _entity("draft").is_terminal is False
|
assert _entity("draft").is_terminal is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 13. resolve_dispute_reject ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_dispute_reject_to_red():
|
||||||
|
e = _entity(
|
||||||
|
"disputed",
|
||||||
|
red_validation_status="approved",
|
||||||
|
red_validated_by=uuid.uuid4(),
|
||||||
|
red_validated_at=datetime.utcnow(),
|
||||||
|
blue_validation_status="rejected",
|
||||||
|
blue_validated_by=uuid.uuid4(),
|
||||||
|
blue_validated_at=datetime.utcnow(),
|
||||||
|
red_paused_seconds=50,
|
||||||
|
)
|
||||||
|
e.resolve_dispute_reject("red")
|
||||||
|
assert e.state == TestState.red_executing
|
||||||
|
assert e.red_validation_status is None
|
||||||
|
assert e.red_validated_by is None
|
||||||
|
assert e.blue_validation_status is None
|
||||||
|
assert e.blue_validated_by is None
|
||||||
|
assert e.red_started_at is not None
|
||||||
|
assert e.red_paused_seconds == 0
|
||||||
|
assert any(ev.name == "dispute_resolved_to_rework" and ev.payload["target"] == "red" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_dispute_reject_to_blue():
|
||||||
|
e = _entity(
|
||||||
|
"disputed",
|
||||||
|
blue_validation_status="approved",
|
||||||
|
blue_validated_by=uuid.uuid4(),
|
||||||
|
red_validation_status="rejected",
|
||||||
|
red_validated_by=uuid.uuid4(),
|
||||||
|
blue_paused_seconds=20,
|
||||||
|
)
|
||||||
|
e.resolve_dispute_reject("blue")
|
||||||
|
assert e.state == TestState.blue_evaluating
|
||||||
|
assert e.red_validation_status is None
|
||||||
|
assert e.blue_validation_status is None
|
||||||
|
assert e.blue_started_at is not None
|
||||||
|
assert e.blue_paused_seconds == 0
|
||||||
|
assert any(ev.name == "dispute_resolved_to_rework" and ev.payload["target"] == "blue" for ev in e.events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_dispute_reject_wrong_state():
|
||||||
|
e = _entity("in_review")
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.resolve_dispute_reject("red")
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ def test_dual_validation_blue_rejects_first(mock_log):
|
|||||||
|
|
||||||
@patch("app.services.test_workflow_service.log_action")
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
def test_dual_validation_red_approves_blue_rejects(mock_log):
|
def test_dual_validation_red_approves_blue_rejects(mock_log):
|
||||||
"""Red approves, then blue rejects -> rejected."""
|
"""Red approves, then blue rejects -> genuine conflict -> disputed."""
|
||||||
test = _make_test(TestState.in_review)
|
test = _make_test(TestState.in_review)
|
||||||
red_lead = _make_user("red_lead")
|
red_lead = _make_user("red_lead")
|
||||||
blue_lead = _make_user("blue_lead")
|
blue_lead = _make_user("blue_lead")
|
||||||
@@ -424,7 +424,7 @@ def test_dual_validation_red_approves_blue_rejects(mock_log):
|
|||||||
assert test.state == TestState.in_review # waiting for blue
|
assert test.state == TestState.in_review # waiting for blue
|
||||||
|
|
||||||
validate_as_blue_lead(db, test, blue_lead, "rejected", "Bad detection")
|
validate_as_blue_lead(db, test, blue_lead, "rejected", "Bad detection")
|
||||||
assert test.state == TestState.rejected
|
assert test.state == TestState.disputed
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@@ -687,6 +687,85 @@ class TestReviewDecisions:
|
|||||||
assert result.system_gaps == "Missing EDR agent on host X"
|
assert result.system_gaps == "Missing EDR agent on host X"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 12c. resolve_dispute — flip approver's vote to reject, route to a team
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveDispute:
|
||||||
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
|
def test_red_lead_approver_flips_to_red_queue(self, mock_log):
|
||||||
|
test = _make_test(
|
||||||
|
TestState.disputed,
|
||||||
|
red_validation_status="approved",
|
||||||
|
blue_validation_status="rejected",
|
||||||
|
)
|
||||||
|
red_lead = _make_user("red_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import resolve_dispute
|
||||||
|
result = resolve_dispute(db, test, red_lead, "red", notes="redo the attack")
|
||||||
|
|
||||||
|
assert result.state == TestState.red_executing
|
||||||
|
assert result.red_validation_status is None
|
||||||
|
assert result.blue_validation_status is None
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
|
def test_blue_lead_approver_flips_to_blue_queue(self, mock_log):
|
||||||
|
test = _make_test(
|
||||||
|
TestState.disputed,
|
||||||
|
blue_validation_status="approved",
|
||||||
|
red_validation_status="rejected",
|
||||||
|
)
|
||||||
|
blue_lead = _make_user("blue_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import resolve_dispute
|
||||||
|
result = resolve_dispute(db, test, blue_lead, "blue")
|
||||||
|
|
||||||
|
assert result.state == TestState.blue_evaluating
|
||||||
|
|
||||||
|
def test_non_approver_cannot_resolve_dispute(self):
|
||||||
|
test = _make_test(
|
||||||
|
TestState.disputed,
|
||||||
|
red_validation_status="rejected",
|
||||||
|
blue_validation_status="approved",
|
||||||
|
)
|
||||||
|
red_lead = _make_user("red_lead") # red_lead REJECTED, didn't approve
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import resolve_dispute
|
||||||
|
with pytest.raises(InvalidOperationError):
|
||||||
|
resolve_dispute(db, test, red_lead, "red")
|
||||||
|
|
||||||
|
def test_invalid_target_team_rejected(self):
|
||||||
|
test = _make_test(
|
||||||
|
TestState.disputed,
|
||||||
|
red_validation_status="approved",
|
||||||
|
blue_validation_status="rejected",
|
||||||
|
)
|
||||||
|
red_lead = _make_user("red_lead")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import resolve_dispute
|
||||||
|
with pytest.raises(InvalidOperationError):
|
||||||
|
resolve_dispute(db, test, red_lead, "purple")
|
||||||
|
|
||||||
|
@patch("app.services.test_workflow_service.log_action")
|
||||||
|
def test_admin_can_resolve_dispute_regardless_of_vote(self, mock_log):
|
||||||
|
test = _make_test(
|
||||||
|
TestState.disputed,
|
||||||
|
red_validation_status="approved",
|
||||||
|
blue_validation_status="rejected",
|
||||||
|
)
|
||||||
|
admin = _make_user("admin")
|
||||||
|
db = _make_db()
|
||||||
|
|
||||||
|
from app.services.test_workflow_service import resolve_dispute
|
||||||
|
result = resolve_dispute(db, test, admin, "red")
|
||||||
|
assert result.state == TestState.red_executing
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# 13. select_reviewer — load-balanced reviewer assignment
|
# 13. select_reviewer — load-balanced reviewer assignment
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
@@ -248,6 +248,19 @@ export async function validateAsBlueLead(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dispute resolution ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ResolveDisputePayload {
|
||||||
|
target_team: "red" | "blue";
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The approving lead flips to reject, choosing which team must redo the work. */
|
||||||
|
export async function resolveDispute(testId: string, payload: ResolveDisputePayload): Promise<Test> {
|
||||||
|
const { data } = await client.post<Test>(`/tests/${testId}/resolve-dispute`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reopen ─────────────────────────────────────────────────────────
|
// ── Reopen ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Reopen a rejected test — moves back to draft. */
|
/** Reopen a rejected test — moves back to draft. */
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Shield, ShieldCheck, Loader2, X, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
// ── Props ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ResolveDisputeModalProps {
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onSubmit: (targetTeam: "red" | "blue", notes: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ResolveDisputeModal({
|
||||||
|
isSubmitting,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
}: ResolveDisputeModalProps) {
|
||||||
|
const [targetTeam, setTargetTeam] = useState<"red" | "blue" | null>(null);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const canSubmit = targetTeam !== null && !isSubmitting;
|
||||||
|
|
||||||
|
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">
|
||||||
|
<XCircle className="h-5 w-5 text-red-400" />
|
||||||
|
<h3 className="text-lg font-semibold text-white">Change Vote to Rejected</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">
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
You're agreeing that this test needs rework. Instead of restarting from scratch,
|
||||||
|
pick which team's work is actually the problem — the test goes straight back to
|
||||||
|
their queue and the other team's work is left untouched.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-sm font-medium text-gray-300">Send back to</h4>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setTargetTeam("red")}
|
||||||
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
|
||||||
|
targetTeam === "red"
|
||||||
|
? "border-orange-500 bg-orange-500/10 text-orange-400"
|
||||||
|
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Red Team Queue
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTargetTeam("blue")}
|
||||||
|
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
|
||||||
|
targetTeam === "blue"
|
||||||
|
? "border-indigo-500 bg-indigo-500/10 text-indigo-400"
|
||||||
|
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
Blue Team Queue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={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-red-500 focus:outline-none focus:ring-1 focus:ring-red-500"
|
||||||
|
placeholder="What needs to be fixed..."
|
||||||
|
/>
|
||||||
|
</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={() => targetTeam && onSubmit(targetTeam, notes)}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Confirm Rejection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ interface TestDetailHeaderProps {
|
|||||||
onStartBlueWork: () => void;
|
onStartBlueWork: () => void;
|
||||||
onOpenValidateModal: (side: "red" | "blue") => void;
|
onOpenValidateModal: (side: "red" | "blue") => void;
|
||||||
onOpenReviewModal: (side: "red" | "blue") => void;
|
onOpenReviewModal: (side: "red" | "blue") => void;
|
||||||
|
onOpenResolveDisputeModal: () => void;
|
||||||
onReopen: () => void;
|
onReopen: () => void;
|
||||||
onPauseTimer: () => void;
|
onPauseTimer: () => void;
|
||||||
onResumeTimer: () => void;
|
onResumeTimer: () => void;
|
||||||
@@ -100,6 +101,7 @@ export default function TestDetailHeader({
|
|||||||
onStartBlueWork,
|
onStartBlueWork,
|
||||||
onOpenValidateModal,
|
onOpenValidateModal,
|
||||||
onOpenReviewModal,
|
onOpenReviewModal,
|
||||||
|
onOpenResolveDisputeModal,
|
||||||
onReopen,
|
onReopen,
|
||||||
onPauseTimer,
|
onPauseTimer,
|
||||||
onResumeTimer,
|
onResumeTimer,
|
||||||
@@ -329,8 +331,6 @@ export default function TestDetailHeader({
|
|||||||
(role === "blue_lead" && test.blue_validation_status === "rejected") ||
|
(role === "blue_lead" && test.blue_validation_status === "rejected") ||
|
||||||
(role === "admin" && test.blue_validation_status === "rejected");
|
(role === "admin" && test.blue_validation_status === "rejected");
|
||||||
|
|
||||||
const approvingSide: "red" | "blue" =
|
|
||||||
test.red_validation_status === "approved" ? "red" : "blue";
|
|
||||||
const rejectingSide: "red" | "blue" =
|
const rejectingSide: "red" | "blue" =
|
||||||
test.red_validation_status === "rejected" ? "red" : "blue";
|
test.red_validation_status === "rejected" ? "red" : "blue";
|
||||||
|
|
||||||
@@ -353,9 +353,9 @@ export default function TestDetailHeader({
|
|||||||
Request Discussion
|
Request Discussion
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Change approving vote to rejected */}
|
{/* Change approving vote to rejected — routes to the team at fault */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onOpenValidateModal(approvingSide)}
|
onClick={onOpenResolveDisputeModal}
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-red-700/80 px-4 py-2 text-sm font-medium text-white hover:bg-red-600 transition-colors"
|
className="flex items-center gap-1.5 rounded-lg bg-red-700/80 px-4 py-2 text-sm font-medium text-white hover:bg-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
validateAsBlueLead,
|
validateAsBlueLead,
|
||||||
reviewAsRedLead,
|
reviewAsRedLead,
|
||||||
reviewAsBlueLead,
|
reviewAsBlueLead,
|
||||||
|
resolveDispute,
|
||||||
reopenTest,
|
reopenTest,
|
||||||
pauseTimer,
|
pauseTimer,
|
||||||
resumeTimer,
|
resumeTimer,
|
||||||
@@ -32,6 +33,7 @@ import TestDetailHeader from "../components/test-detail/TestDetailHeader";
|
|||||||
import TeamTabs from "../components/test-detail/TeamTabs";
|
import TeamTabs from "../components/test-detail/TeamTabs";
|
||||||
import ValidationModal from "../components/test-detail/ValidationModal";
|
import ValidationModal from "../components/test-detail/ValidationModal";
|
||||||
import ReviewModal from "../components/test-detail/ReviewModal";
|
import ReviewModal from "../components/test-detail/ReviewModal";
|
||||||
|
import ResolveDisputeModal from "../components/test-detail/ResolveDisputeModal";
|
||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
import JiraLinkPanel from "../components/JiraLinkPanel";
|
import JiraLinkPanel from "../components/JiraLinkPanel";
|
||||||
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
import TestPhaseTimeline from "../components/TestPhaseTimeline";
|
||||||
@@ -64,6 +66,8 @@ export default function TestDetailPage() {
|
|||||||
side: "red" | "blue";
|
side: "red" | "blue";
|
||||||
}>({ open: false, side: "red" });
|
}>({ open: false, side: "red" });
|
||||||
|
|
||||||
|
const [resolveDisputeModalOpen, setResolveDisputeModalOpen] = useState(false);
|
||||||
|
|
||||||
const [confirmReopen, setConfirmReopen] = useState(false);
|
const [confirmReopen, setConfirmReopen] = useState(false);
|
||||||
const [holdModal, setHoldModal] = useState(false);
|
const [holdModal, setHoldModal] = useState(false);
|
||||||
const [holdReason, setHoldReason] = useState("");
|
const [holdReason, setHoldReason] = useState("");
|
||||||
@@ -284,6 +288,17 @@ export default function TestDetailPage() {
|
|||||||
onError: (err: unknown) => showToast(extractError(err), "error"),
|
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resolveDisputeMutation = useMutation({
|
||||||
|
mutationFn: (payload: { target_team: "red" | "blue"; notes?: string }) =>
|
||||||
|
resolveDispute(testId!, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll();
|
||||||
|
setResolveDisputeModalOpen(false);
|
||||||
|
showToast("Dispute resolved — test sent back for rework", "success");
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => showToast(extractError(err), "error"),
|
||||||
|
});
|
||||||
|
|
||||||
const reopenMutation = useMutation({
|
const reopenMutation = useMutation({
|
||||||
mutationFn: () => reopenTest(testId!),
|
mutationFn: () => reopenTest(testId!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -406,6 +421,10 @@ export default function TestDetailPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResolveDisputeSubmit = (targetTeam: "red" | "blue", notes: string) => {
|
||||||
|
resolveDisputeMutation.mutate({ target_team: targetTeam, notes: notes || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
const isTransitioning =
|
const isTransitioning =
|
||||||
startExecMutation.isPending ||
|
startExecMutation.isPending ||
|
||||||
submitRedMutation.isPending ||
|
submitRedMutation.isPending ||
|
||||||
@@ -486,6 +505,7 @@ export default function TestDetailPage() {
|
|||||||
onStartBlueWork={() => startBlueWorkMutation.mutate()}
|
onStartBlueWork={() => startBlueWorkMutation.mutate()}
|
||||||
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
onOpenValidateModal={(side) => setValidationModal({ open: true, side })}
|
||||||
onOpenReviewModal={(side) => setReviewModal({ open: true, side })}
|
onOpenReviewModal={(side) => setReviewModal({ open: true, side })}
|
||||||
|
onOpenResolveDisputeModal={() => setResolveDisputeModalOpen(true)}
|
||||||
onReopen={() => setConfirmReopen(true)}
|
onReopen={() => setConfirmReopen(true)}
|
||||||
onPauseTimer={() => pauseTimerMutation.mutate()}
|
onPauseTimer={() => pauseTimerMutation.mutate()}
|
||||||
onResumeTimer={() => resumeTimerMutation.mutate()}
|
onResumeTimer={() => resumeTimerMutation.mutate()}
|
||||||
@@ -780,6 +800,15 @@ export default function TestDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Resolve Dispute Modal (approver flips vote to reject, picks a queue) */}
|
||||||
|
{resolveDisputeModalOpen && (
|
||||||
|
<ResolveDisputeModal
|
||||||
|
isSubmitting={resolveDisputeMutation.isPending}
|
||||||
|
onSubmit={handleResolveDisputeSubmit}
|
||||||
|
onClose={() => setResolveDisputeModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Save as Template Modal */}
|
{/* Save as Template Modal */}
|
||||||
{showTemplateModal && (
|
{showTemplateModal && (
|
||||||
<SaveAsTemplateModal
|
<SaveAsTemplateModal
|
||||||
|
|||||||
Reference in New Issue
Block a user