diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 3fc689b..f6c2bc5 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -745,6 +745,97 @@ def sync_tempo( return {"results": results} +# --------------------------------------------------------------------------- +# POST /tests/{id}/request-discussion — disputed: confirm vote + notify other lead +# --------------------------------------------------------------------------- + + +@router.post("/{test_id}/request-discussion") +def request_discussion( + test_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role("red_lead", "blue_lead")), +): + """Called when the approving lead confirms their vote in a disputed test. + + Sends a notification to the other lead (who rejected) asking them to + discuss and resolve the conflict. The test remains in 'disputed' state. + """ + from app.models.enums import TestState as ModelTestState + from app.models.user import User as UserModel + from app.services.notification_service import create_notification + + test = crud_get_test_or_raise(db, test_id) + + if test.state.value != "disputed": + from app.domain.errors import BusinessRuleViolation + raise BusinessRuleViolation("Test is not in disputed state") + + role = current_user.role + + # Identify who the "other lead" is (the one who rejected) + if role == "red_lead" and test.red_validation_status == "approved": + # Red approved, Blue rejected → notify Blue Lead who rejected + rejector_id = test.blue_validated_by + rejector_label = "Blue Lead" + requester_label = "Red Lead" + elif role == "blue_lead" and test.blue_validation_status == "approved": + # Blue approved, Red rejected → notify Red Lead who rejected + rejector_id = test.red_validated_by + rejector_label = "Red Lead" + requester_label = "Blue Lead" + else: + from app.domain.errors import BusinessRuleViolation + raise BusinessRuleViolation( + "You are not the approving lead in this conflict or the state is inconsistent" + ) + + # Look up the rejecting lead's username for the message + rejector = ( + db.query(UserModel).filter(UserModel.id == rejector_id).first() + if rejector_id else None + ) + rejector_name = rejector.username if rejector else rejector_label + + # Notify the rejecting lead + if rejector_id: + try: + create_notification( + db, + user_id=rejector_id, + type="validation_conflict", + title="Discussion requested on disputed test", + message=( + f"{requester_label} ({current_user.username}) is confirming their approval " + f"of test '{test.name}' and wants to discuss your rejection with you. " + f"Please reach out to resolve the disagreement." + ), + entity_type="test", + entity_id=str(test.id), + ) + except Exception as e: + import logging + logging.getLogger(__name__).warning( + "Failed to send discussion notification: %s", e + ) + + log_action( + db, + user_id=current_user.id, + action="request_dispute_discussion", + entity_type="test", + entity_id=test.id, + details={"test_name": test.name, "rejector": rejector_name}, + ) + db.commit() + + return { + "status": "notification_sent", + "message": f"Discussion request sent to {rejector_name}", + "rejector_username": rejector_name, + } + + # --------------------------------------------------------------------------- # POST /tests/import-rt — bulk import from a real Red Team engagement # --------------------------------------------------------------------------- diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 9fcd6c1..f40716a 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -308,6 +308,16 @@ export interface RTImportResult { engagement: string; } +/** Confirm approval in a disputed test and notify the rejecting lead to discuss. */ +export async function requestDiscussion(testId: string): Promise<{ + status: string; + message: string; + rejector_username: string; +}> { + const { data } = await client.post(`/tests/${testId}/request-discussion`); + return data; +} + /** Import results from a real Red Team engagement. */ export async function importRT(payload: RTImportPayload): Promise { const { data } = await client.post("/tests/import-rt", payload); diff --git a/frontend/src/components/test-detail/TestDetailHeader.tsx b/frontend/src/components/test-detail/TestDetailHeader.tsx index e751064..4d233c0 100644 --- a/frontend/src/components/test-detail/TestDetailHeader.tsx +++ b/frontend/src/components/test-detail/TestDetailHeader.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { FlaskConical, Play, @@ -10,7 +11,11 @@ import { ShieldCheck, AlertTriangle, MessageSquare, + X, + UserCheck, } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { requestDiscussion } from "../../api/tests"; import type { Test, TestState, User } from "../../types/models"; import LiveTimer from "./LiveTimer"; @@ -81,6 +86,13 @@ export default function TestDetailHeader({ }: TestDetailHeaderProps) { const role = user?.role ?? ""; const currentIdx = STATE_INDEX[test.state]; + const [showDiscussModal, setShowDiscussModal] = useState(false); + const [discussResult, setDiscussResult] = useState(null); + + const discussMutation = useMutation({ + mutationFn: () => requestDiscussion(test.id), + onSuccess: (data) => setDiscussResult(data.rejector_username), + }); const formatDate = (d: string | null) => { if (!d) return null; @@ -227,14 +239,24 @@ export default function TestDetailHeader({ const side = role === "red_lead" ? "red" : role === "blue_lead" ? "blue" : null; if (side || role === "admin") { buttons.push( - , +
+ {/* Confirm keeping approval → open discussion modal */} + + {/* Change vote to rejected */} + +
, ); } } @@ -458,6 +480,108 @@ export default function TestDetailHeader({ This test was rejected and needs to be reopened to restart the workflow. )} + + {/* Confirm My Validation — discussion request modal */} + {showDiscussModal && ( +
+
+ {/* Header */} +
+
+ +

Confirm Your Validation

+
+ +
+ + {!discussResult ? ( +
+ {/* Conflict summary */} +
+

+ ⚠ Both leads must agree before a test can be finalised +

+

+ You are confirming that your approval is correct and + that the other lead's rejection should be reviewed. +

+
+ + {/* Other lead's rejection reason */} + {(test.red_validation_status === "rejected" && test.red_validation_notes) && ( +
+

Red Lead's rejection reason

+

+ "{test.red_validation_notes}" +

+
+ )} + {(test.blue_validation_status === "rejected" && test.blue_validation_notes) && ( +
+

Blue Lead's rejection reason

+

+ "{test.blue_validation_notes}" +

+
+ )} + + {/* What happens next */} +
+

What happens when you send the discussion request:

+
    +
  • The other lead receives a notification to contact you
  • +
  • The test remains in Disputed state
  • +
  • Either lead can change their vote at any time to resolve it
  • +
  • If both reject → test becomes Rejected; if both approve → Validated
  • +
+
+ + {discussMutation.isError && ( +

Failed to send notification. Try again.

+ )} +
+ ) : ( + /* Success state */ +
+ +

Discussion request sent

+

+ {discussResult} has been notified + that you are standing by your approval and want to discuss their rejection. +

+

+ Reach out directly via your team's communication channels to resolve this. +

+
+ )} + + {/* Footer */} +
+ + {!discussResult && ( + + )} +
+
+
+ )} ); }