feat(assign): let leads manually assign operators + sync Jira; send platform as Jira label
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

Adds GET /users/operators (leads+admin) and wires a lead-only assign
control into the test detail header, calling the existing but
previously unreachable POST /tests/{id}/assign endpoint. Manual
assignment now also pushes the Jira ticket assignee immediately
instead of waiting for the operator to start execution.

Also adds test.platform as a kebab-case label on Jira ticket creation.
This commit is contained in:
kitos
2026-07-10 08:44:14 +02:00
parent 119db6f91d
commit 2afb886cd9
12 changed files with 487 additions and 4 deletions
+12
View File
@@ -1282,18 +1282,21 @@ def assign_test_operators(
): ):
"""Assign red_tech and/or blue_tech operators to a test. Admin/leads only.""" """Assign red_tech and/or blue_tech operators to a test. Admin/leads only."""
test = crud_get_test_or_raise(db, test_id) test = crud_get_test_or_raise(db, test_id)
newly_assigned: User | None = None
if payload.red_tech_assignee is not None: if payload.red_tech_assignee is not None:
u = db.query(User).filter(User.id == payload.red_tech_assignee).first() u = db.query(User).filter(User.id == payload.red_tech_assignee).first()
if not u or u.role not in ("red_tech", "red_lead", "admin"): if not u or u.role not in ("red_tech", "red_lead", "admin"):
raise HTTPException(status_code=400, detail="Invalid red tech assignee") raise HTTPException(status_code=400, detail="Invalid red tech assignee")
test.red_tech_assignee = payload.red_tech_assignee test.red_tech_assignee = payload.red_tech_assignee
newly_assigned = u
if payload.blue_tech_assignee is not None: if payload.blue_tech_assignee is not None:
u = db.query(User).filter(User.id == payload.blue_tech_assignee).first() u = db.query(User).filter(User.id == payload.blue_tech_assignee).first()
if not u or u.role not in ("blue_tech", "blue_lead", "admin"): if not u or u.role not in ("blue_tech", "blue_lead", "admin"):
raise HTTPException(status_code=400, detail="Invalid blue tech assignee") raise HTTPException(status_code=400, detail="Invalid blue tech assignee")
test.blue_tech_assignee = payload.blue_tech_assignee test.blue_tech_assignee = payload.blue_tech_assignee
newly_assigned = u
# Handle intentional null (clearing) — model_fields_set tracks which keys were sent # Handle intentional null (clearing) — model_fields_set tracks which keys were sent
if "red_tech_assignee" in payload.model_fields_set and payload.red_tech_assignee is None: if "red_tech_assignee" in payload.model_fields_set and payload.red_tech_assignee is None:
@@ -1307,6 +1310,15 @@ def assign_test_operators(
}) })
db.commit() db.commit()
db.refresh(test) db.refresh(test)
if newly_assigned is not None:
try:
from app.services.jira_service import push_assignee_update
push_assignee_update(db, test, newly_assigned)
db.commit()
except Exception: # nosec B110
pass # jira_service already logs warnings internally
return test return test
+29 -2
View File
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
# Import require_role from app.dependencies.auth # Import require_role from app.dependencies.auth
from app.dependencies.auth import require_role from app.dependencies.auth import require_role, require_any_role
# Import UnitOfWork from app.domain.unit_of_work # Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork from app.domain.unit_of_work import UnitOfWork
@@ -21,7 +21,7 @@ from app.domain.unit_of_work import UnitOfWork
# Import User from app.models.user # Import User from app.models.user
from app.models.user import User from app.models.user import User
from app.dependencies.auth import get_current_user from app.dependencies.auth import get_current_user
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate, UserOperatorOut
from app.services.audit_service import log_action from app.services.audit_service import log_action
# Import from app.services.user_service # Import from app.services.user_service
@@ -148,6 +148,33 @@ def create_user_route(
return user return user
# ---------------------------------------------------------------------------
# GET /users/operators — minimal operator/lead list for assignment pickers
# ---------------------------------------------------------------------------
@router.get("/operators", response_model=list[UserOperatorOut])
def list_operators_route(
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
) -> list[UserOperatorOut]:
"""Return active red/blue operators and leads, for lead/admin assignment pickers.
Deliberately excludes viewers and managers, and returns only
id/username/role — no emails or tokens — since this is reachable by
non-admin leads.
"""
return (
db.query(User)
.filter(
User.role.in_(["red_tech", "red_lead", "blue_tech", "blue_lead", "admin"]),
User.is_active.is_(True),
)
.order_by(User.username)
.all()
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /users/{id} — get a single user # GET /users/{id} — get a single user
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+10
View File
@@ -284,3 +284,13 @@ class UserOut(BaseModel):
self.jira_token_set = bool(self.jira_api_token) self.jira_token_set = bool(self.jira_api_token)
self.tempo_token_set = bool(self.tempo_api_token) self.tempo_token_set = bool(self.tempo_api_token)
return self return self
class UserOperatorOut(BaseModel):
"""Minimal user representation for lead/admin operator-picker dropdowns."""
id: uuid.UUID
username: str
role: str
model_config = ConfigDict(from_attributes=True)
+49 -1
View File
@@ -31,6 +31,9 @@ from __future__ import annotations
# Import logging # Import logging
import logging import logging
# Import re
import re
# Import datetime from datetime # Import datetime from datetime
from datetime import datetime from datetime import datetime
@@ -697,12 +700,16 @@ def auto_create_test_issue(
poc = test.procedure_text or "N/A" poc = test.procedure_text or "N/A"
severity = _technique_severity(technique).capitalize() severity = _technique_severity(technique).capitalize()
labels = ["aegis", "security-test", mitre_id.replace(".", "-")]
if test.platform:
# Jira labels can't contain whitespace — normalize to kebab-case.
labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-"))
fields: dict = { fields: dict = {
"project": {"key": project_key}, "project": {"key": project_key},
"summary": f"[Aegis] {mitre_id}{test.name}", "summary": f"[Aegis] {mitre_id}{test.name}",
"description": _build_test_description(test, technique), "description": _build_test_description(test, technique),
"issuetype": {"name": issue_type}, "issuetype": {"name": issue_type},
"labels": ["aegis", "security-test", mitre_id.replace(".", "-")], "labels": labels,
# customfield_10309 = Proof of Concept field (required by team's Jira config) # customfield_10309 = Proof of Concept field (required by team's Jira config)
"customfield_10309": f"{{code}}{poc}{{code}}", "customfield_10309": f"{{code}}{poc}{{code}}",
JIRA_FIELD_TTP: mitre_id, JIRA_FIELD_TTP: mitre_id,
@@ -862,6 +869,47 @@ def push_test_event(
) )
def push_assignee_update(db: Session, test: Test, assignee: User) -> None:
"""Sync a lead's manual operator assignment to the linked Jira ticket.
Called from ``POST /tests/{id}/assign`` so Jira reflects the assignment
immediately, instead of waiting for the operator to start execution
(the only other point that pushes a Jira assignee — see
``push_test_event``'s ``red_executing`` handling above).
"""
if not has_admin_jira_configured(db):
return
link = (
db.query(JiraLink)
.filter(
JiraLink.entity_type == JiraLinkEntityType.test,
JiraLink.entity_id == test.id,
)
.first()
)
if not link:
return
jira_account_id = getattr(assignee, "jira_account_id", None)
if not jira_account_id:
return
try:
jira = get_admin_jira_client(db)
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
link.last_synced_at = datetime.utcnow()
db.flush()
logger.info(
"Assigned Jira ticket %s to account %s (manual assignment)",
link.jira_issue_key, jira_account_id,
)
except Exception as exc:
logger.warning(
"Could not assign %s to %s: %s", link.jira_issue_key, jira_account_id, exc,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# On-hold Jira notification # On-hold Jira notification
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+111
View File
@@ -0,0 +1,111 @@
"""Tests for POST /tests/{id}/assign — lead/admin manual operator assignment."""
from unittest.mock import MagicMock, patch
from app.models.test import Test
from app.models.technique import Technique
def _seed_technique(db, tactic="execution") -> Technique:
technique = Technique(
mitre_id="T9999",
name="Test Technique",
tactic=tactic,
platforms=["linux"],
)
db.add(technique)
db.commit()
db.refresh(technique)
return technique
def _seed_test(db, technique, created_by) -> Test:
test = Test(technique_id=technique.id, name="Assignable test", created_by=created_by)
db.add(test)
db.commit()
db.refresh(test)
return test
def test_red_lead_can_assign_red_tech(client, db, red_lead_headers, red_lead_user, red_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(red_tech_user.id)},
headers=red_lead_headers,
)
assert resp.status_code == 200, resp.text
assert resp.json()["red_tech_assignee"] == str(red_tech_user.id)
db.refresh(test)
assert test.red_tech_assignee == red_tech_user.id
def test_assign_rejects_wrong_role_for_side(client, db, red_lead_headers, red_lead_user, blue_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(blue_tech_user.id)},
headers=red_lead_headers,
)
assert resp.status_code == 400
def test_red_tech_cannot_assign(client, db, red_tech_headers, red_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_tech_user.id)
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(red_tech_user.id)},
headers=red_tech_headers,
)
assert resp.status_code == 403
def test_clearing_assignee_does_not_touch_jira(client, db, red_lead_headers, red_lead_user, red_tech_user):
"""Sending null explicitly clears the assignee without a Jira sync call."""
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
test.red_tech_assignee = red_tech_user.id
db.commit()
with patch("app.services.jira_service.push_assignee_update") as mock_push:
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": None},
headers=red_lead_headers,
)
assert resp.status_code == 200
assert resp.json()["red_tech_assignee"] is None
mock_push.assert_not_called()
def test_assigning_operator_syncs_to_jira(client, db, red_lead_headers, red_lead_user, red_tech_user):
technique = _seed_technique(db)
test = _seed_test(db, technique, red_lead_user.id)
# Capture IDs *during* the call, while the endpoint's request-scoped
# session is still open — reading them afterward hits a
# DetachedInstanceError once that session closes.
captured = {}
def _capture(_db, test_arg, assignee_arg):
captured["test_id"] = test_arg.id
captured["assignee_id"] = assignee_arg.id
with patch("app.services.jira_service.push_assignee_update", side_effect=_capture) as mock_push:
resp = client.post(
f"/api/v1/tests/{test.id}/assign",
json={"red_tech_assignee": str(red_tech_user.id)},
headers=red_lead_headers,
)
assert resp.status_code == 200
mock_push.assert_called_once()
assert captured["test_id"] == test.id
assert captured["assignee_id"] == red_tech_user.id
+83 -1
View File
@@ -383,6 +383,44 @@ def test_auto_create_test_issue_maps_data_classification_to_real_jira_field(
assert fields["customfield_11814"] == {"value": expected_jira_value} assert fields["customfield_11814"] == {"value": expected_jira_value}
@pytest.mark.parametrize(
"platform,expected_label",
[
("Windows", "windows"),
("linux", "linux"),
("macOS", "macos"),
("Azure AD", "azure-ad"),
(None, None),
],
)
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
@patch("app.services.jira_service.get_admin_jira_client")
def test_auto_create_test_issue_adds_platform_label(
mock_get_client, mock_parent, mock_project_key, mock_configured, db,
platform, expected_label,
):
mock_jira = MagicMock()
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
mock_get_client.return_value = mock_jira
from app.models.user import User
test = _make_test_for_issue(platform=platform)
technique = _make_technique()
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
db.add(actor)
db.commit()
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
assert ["aegis", "security-test", "T1003-006"] == labels[:3]
if expected_label:
assert expected_label in labels
else:
assert len(labels) == 3
def _make_user(**overrides): def _make_user(**overrides):
from app.models.user import User from app.models.user import User
u = MagicMock(spec=User) u = MagicMock(spec=User)
@@ -428,4 +466,48 @@ def test_lookup_user_jira_account_id_no_match(mock_get_client, mock_configured,
updated = jira_service.lookup_user_jira_account_id(db, user) updated = jira_service.lookup_user_jira_account_id(db, user)
assert updated is False assert updated is False
assert user.jira_account_id is None
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_assignee_update_assigns_jira_issue(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
assignee = _make_user(jira_account_id="account-42")
jira_service.push_assignee_update(db, test, assignee)
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="account-42")
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_assignee_update_noop_without_jira_account_id(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
link = _make_link(test.id)
db.add(link)
db.commit()
assignee = _make_user(jira_account_id=None)
jira_service.push_assignee_update(db, test, assignee)
mock_jira.assign_issue.assert_not_called()
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
@patch("app.services.jira_service.get_admin_jira_client")
def test_push_assignee_update_noop_without_link(mock_get_client, mock_configured, db):
mock_jira = MagicMock()
mock_get_client.return_value = mock_jira
test = _make_test()
assignee = _make_user(jira_account_id="account-42")
jira_service.push_assignee_update(db, test, assignee)
mock_jira.assign_issue.assert_not_called()
+33
View File
@@ -0,0 +1,33 @@
"""Tests for GET /users/operators — lead/admin-reachable operator picker list."""
def test_red_lead_can_list_operators(client, red_lead_headers, red_tech_user, blue_tech_user, red_lead_user):
resp = client.get("/api/v1/users/operators", headers=red_lead_headers)
assert resp.status_code == 200, resp.text
usernames = {u["username"] for u in resp.json()}
assert "redtech" in usernames
assert "bluetech" in usernames
assert "redlead" in usernames
def test_operators_list_excludes_viewers(client, red_lead_headers, db):
from app.auth import hash_password
from app.models.user import User
viewer = User(
username="viewer_ops", email="viewer_ops@test.com",
hashed_password=hash_password("x"), role="viewer", is_active=True,
must_change_password=False,
)
db.add(viewer)
db.commit()
resp = client.get("/api/v1/users/operators", headers=red_lead_headers)
assert resp.status_code == 200
usernames = {u["username"] for u in resp.json()}
assert "viewer_ops" not in usernames
def test_red_tech_cannot_list_operators(client, red_tech_headers):
resp = client.get("/api/v1/users/operators", headers=red_tech_headers)
assert resp.status_code == 403
+9
View File
@@ -179,6 +179,15 @@ export async function updateTestClassification(
return data; return data;
} }
/** Assign red_tech/blue_tech operators to a test (leads + admin only). */
export async function assignTestOperators(
testId: string,
payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null },
): Promise<Test> {
const { data } = await client.post<Test>(`/tests/${testId}/assign`, payload);
return data;
}
// ── Red Team ─────────────────────────────────────────────────────── // ── Red Team ───────────────────────────────────────────────────────
/** Red Team updates their fields (draft, red_executing). */ /** Red Team updates their fields (draft, red_executing). */
+12
View File
@@ -30,6 +30,18 @@ export async function getUsers(): Promise<UserOut[]> {
return data; return data;
} }
export interface OperatorOut {
id: string;
username: string;
role: string;
}
/** Fetch red/blue operators + leads for assignment pickers (leads + admin). */
export async function getOperators(): Promise<OperatorOut[]> {
const { data } = await client.get<OperatorOut[]>("/users/operators");
return data;
}
/** Create a new user (admin only). */ /** Create a new user (admin only). */
export async function createUser(payload: UserCreatePayload): Promise<UserOut> { export async function createUser(payload: UserCreatePayload): Promise<UserOut> {
const { data } = await client.post<UserOut>("/users", payload); const { data } = await client.post<UserOut>("/users", payload);
@@ -0,0 +1,90 @@
import { useState } from "react";
import { ChevronDown, ChevronUp, Loader2, UserCircle2 } from "lucide-react";
import type { OperatorOut } from "../../api/users";
interface Props {
side: "red" | "blue";
assigneeId: string | null;
operators: OperatorOut[];
canEdit: boolean;
isSaving: boolean;
onAssign: (userId: string | null) => void;
}
const SIDE_STYLE = {
red: "border-orange-500/30 bg-orange-900/20 text-orange-400",
blue: "border-indigo-500/30 bg-indigo-900/20 text-indigo-400",
};
const SIDE_ROLES: Record<"red" | "blue", string[]> = {
red: ["red_tech", "red_lead", "admin"],
blue: ["blue_tech", "blue_lead", "admin"],
};
/** Lead/admin picker for the red_tech_assignee / blue_tech_assignee fields. */
export default function AssigneeControl({ side, assigneeId, operators, canEdit, isSaving, onAssign }: Props) {
const [expanded, setExpanded] = useState(false);
const current = operators.find((o) => o.id === assigneeId);
const label = current ? current.username : "Unassigned";
const eligible = operators.filter((o) => SIDE_ROLES[side].includes(o.role));
const badgeClass = `inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SIDE_STYLE[side]}`;
if (!canEdit) {
return (
<span className={badgeClass}>
<UserCircle2 className="h-2.5 w-2.5" />
{side === "red" ? "RT" : "BT"}: {label}
</span>
);
}
return (
<div className="relative inline-block">
<button
onClick={() => setExpanded((v) => !v)}
className={`${badgeClass} transition-colors hover:opacity-80`}
>
<UserCircle2 className="h-2.5 w-2.5" />
{side === "red" ? "RT" : "BT"}: {label}
{expanded ? <ChevronUp className="h-2.5 w-2.5" /> : <ChevronDown className="h-2.5 w-2.5" />}
</button>
{expanded && (
<div className="absolute left-0 top-full z-20 mt-1 w-48 rounded-lg border border-gray-700 bg-gray-900 p-1.5 shadow-xl">
{isSaving ? (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin text-gray-500" />
</div>
) : (
<>
<button
onClick={() => { onAssign(null); setExpanded(false); }}
className={`block w-full rounded px-2 py-1.5 text-left text-xs transition-colors ${
!assigneeId ? "bg-gray-800 text-white" : "text-gray-400 hover:bg-gray-800 hover:text-white"
}`}
>
Unassigned
</button>
{eligible.map((op) => (
<button
key={op.id}
onClick={() => { onAssign(op.id); setExpanded(false); }}
className={`block w-full rounded px-2 py-1.5 text-left text-xs transition-colors ${
op.id === assigneeId ? "bg-gray-800 text-white" : "text-gray-400 hover:bg-gray-800 hover:text-white"
}`}
>
{op.username}
<span className="ml-1.5 text-[10px] text-gray-500">({op.role.replace(/_/g, " ")})</span>
</button>
))}
{eligible.length === 0 && (
<p className="px-2 py-1.5 text-xs text-gray-500">No eligible operators</p>
)}
</>
)}
</div>
)}
</div>
);
}
@@ -19,8 +19,10 @@ import {
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { requestDiscussion } from "../../api/tests"; import { requestDiscussion } from "../../api/tests";
import type { Test, TestState, User, DataClassification } from "../../types/models"; import type { Test, TestState, User, DataClassification } from "../../types/models";
import type { OperatorOut } from "../../api/users";
import LiveTimer from "./LiveTimer"; import LiveTimer from "./LiveTimer";
import DataClassificationBadge from "./DataClassificationBadge"; import DataClassificationBadge from "./DataClassificationBadge";
import AssigneeControl from "./AssigneeControl";
// ── Progress steps ───────────────────────────────────────────────── // ── Progress steps ─────────────────────────────────────────────────
@@ -90,6 +92,9 @@ interface TestDetailHeaderProps {
isTogglingHold: boolean; isTogglingHold: boolean;
onUpdateClassification: (value: DataClassification) => void; onUpdateClassification: (value: DataClassification) => void;
isUpdatingClassification: boolean; isUpdatingClassification: boolean;
operators: OperatorOut[];
onAssignOperator: (side: "red" | "blue", userId: string | null) => void;
isAssigningOperator: boolean;
} }
// ── Component ────────────────────────────────────────────────────── // ── Component ──────────────────────────────────────────────────────
@@ -114,6 +119,9 @@ export default function TestDetailHeader({
isTogglingHold, isTogglingHold,
onUpdateClassification, onUpdateClassification,
isUpdatingClassification, isUpdatingClassification,
operators,
onAssignOperator,
isAssigningOperator,
}: TestDetailHeaderProps) { }: TestDetailHeaderProps) {
const role = user?.role ?? ""; const role = user?.role ?? "";
const currentIdx = STATE_INDEX[test.state]; const currentIdx = STATE_INDEX[test.state];
@@ -539,6 +547,24 @@ export default function TestDetailHeader({
<p className="mt-1 text-sm text-gray-400"> <p className="mt-1 text-sm text-gray-400">
Created {formatDate(test.created_at)} Created {formatDate(test.created_at)}
</p> </p>
<div className="mt-1.5 flex items-center gap-1.5">
<AssigneeControl
side="red"
assigneeId={test.red_tech_assignee}
operators={operators}
canEdit={role === "red_lead" || role === "admin"}
isSaving={isAssigningOperator}
onAssign={(userId) => onAssignOperator("red", userId)}
/>
<AssigneeControl
side="blue"
assigneeId={test.blue_tech_assignee}
operators={operators}
canEdit={role === "blue_lead" || role === "admin"}
isSaving={isAssigningOperator}
onAssign={(userId) => onAssignOperator("blue", userId)}
/>
</div>
{renderValidationIndicators()} {renderValidationIndicators()}
</div> </div>
</div> </div>
+23
View File
@@ -23,9 +23,11 @@ import {
holdTest, holdTest,
resumeTest, resumeTest,
updateTestClassification, updateTestClassification,
assignTestOperators,
getTestTimeline, getTestTimeline,
getRetestChain, getRetestChain,
} from "../api/tests"; } from "../api/tests";
import { getOperators } from "../api/users";
import { uploadEvidence, getEvidence } from "../api/evidence"; import { uploadEvidence, getEvidence } from "../api/evidence";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import type { TestResult, ContainmentResult, AttackSuccessResult, DataClassification, TeamSide, TestTimelineEntry } from "../types/models"; import type { TestResult, ContainmentResult, AttackSuccessResult, DataClassification, TeamSide, TestTimelineEntry } from "../types/models";
@@ -133,6 +135,14 @@ export default function TestDetailPage() {
enabled: !!testId && !!test && (test.retest_of !== null || test.retest_count > 0), enabled: !!testId && !!test && (test.retest_of !== null || test.retest_count > 0),
}); });
const canAssignOperators = ["red_lead", "blue_lead", "admin"].includes(user?.role ?? "");
const { data: operators = [] } = useQuery({
queryKey: ["operators"],
queryFn: getOperators,
enabled: canAssignOperators,
staleTime: 5 * 60 * 1000,
});
// Hydrate drafts from test data // Hydrate drafts from test data
useEffect(() => { useEffect(() => {
if (test) { if (test) {
@@ -368,6 +378,16 @@ export default function TestDetailPage() {
onError: (err: unknown) => showToast(extractError(err), "error"), onError: (err: unknown) => showToast(extractError(err), "error"),
}); });
const assignOperatorMutation = useMutation({
mutationFn: ({ side, userId }: { side: "red" | "blue"; userId: string | null }) =>
assignTestOperators(testId!, side === "red" ? { red_tech_assignee: userId } : { blue_tech_assignee: userId }),
onSuccess: () => {
invalidateAll();
showToast("Assignment updated", "success");
},
onError: (err: unknown) => showToast(extractError(err), "error"),
});
// Evidence upload // Evidence upload
const uploadMutation = useMutation({ const uploadMutation = useMutation({
mutationFn: ({ file, team }: { file: File; team: TeamSide }) => mutationFn: ({ file, team }: { file: File; team: TeamSide }) =>
@@ -532,6 +552,9 @@ export default function TestDetailPage() {
isTogglingHold={holdMutation.isPending || resumeHoldMutation.isPending} isTogglingHold={holdMutation.isPending || resumeHoldMutation.isPending}
onUpdateClassification={(value) => updateClassificationMutation.mutate(value)} onUpdateClassification={(value) => updateClassificationMutation.mutate(value)}
isUpdatingClassification={updateClassificationMutation.isPending} isUpdatingClassification={updateClassificationMutation.isPending}
operators={operators}
onAssignOperator={(side, userId) => assignOperatorMutation.mutate({ side, userId })}
isAssigningOperator={assignOperatorMutation.isPending}
/> />
{/* Content: Tabs + Sidebar */} {/* Content: Tabs + Sidebar */}