fix(tests): fix veto bug preventing disputed state, add manager notification and dispute-resolution routing
This commit is contained in:
@@ -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():
|
||||
"""Red already approved; Blue then rejects — a genuine conflict, not a veto."""
|
||||
e = _entity("in_review")
|
||||
e.validate_red("approved", 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
|
||||
|
||||
|
||||
@@ -561,3 +584,52 @@ def test_is_terminal():
|
||||
assert _entity("validated").is_terminal is True
|
||||
assert _entity("rejected").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")
|
||||
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)
|
||||
red_lead = _make_user("red_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
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 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
|
||||
# ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user