From 6d6f87b968f494a8444226c8f89788e3cd33e321 Mon Sep 17 00:00:00 2001 From: kitos Date: Mon, 13 Jul 2026 16:53:31 +0200 Subject: [PATCH] fix(jira,tests): send real datetime to RT/BT date fields, resolve assignee usernames for operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RT/BT Start/End Date are genuine Jira "datetime" custom fields (confirmed via issue_editmeta), not plain dates. Pushing a bare "YYYY-MM-DD" string made Jira default the time-of-day to midnight, so RT Start Date and RT End Date ended up showing the same meaningless midnight timestamp instead of the actual execution window. Now sends a full ISO datetime. - The test detail page showed "RT: Unassigned" for the operator who WAS correctly assigned, because GET /users/operators (the only source AssigneeControl used to resolve an assignee ID into a username) is restricted to leads/managers — a plain operator has no other way to resolve their own ID. TestOut now resolves and includes the assignee's username directly (red/blue tech + reviewer), so the badge no longer depends on a permission-gated list the viewer might not have access to. --- backend/app/schemas/test.py | 25 ++++++++ backend/app/services/jira_service.py | 22 +++++-- backend/app/services/test_crud_service.py | 2 + backend/tests/test_jira_service.py | 8 +-- backend/tests/test_test_assignee_username.py | 63 +++++++++++++++++++ .../test-detail/AssigneeControl.tsx | 9 ++- .../test-detail/TestDetailHeader.tsx | 4 ++ frontend/src/types/models.ts | 4 ++ 8 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 backend/tests/test_test_assignee_username.py diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index 4fedd24..6beac4a 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -330,6 +330,12 @@ class TestOut(BaseModel): # Assignment fields red_tech_assignee: uuid.UUID | None = None blue_tech_assignee: uuid.UUID | None = None + # Resolved usernames — the operator (or lead) viewing a test they're not + # a lead/manager on can't call GET /users/operators (403), so they have + # no other way to resolve who an assignee ID actually is. Populated + # from the ORM relationship, same pattern as technique_name below. + red_tech_assignee_username: str | None = None + blue_tech_assignee_username: str | None = None # Review assignment fields red_reviewer_assignee: uuid.UUID | None = None @@ -340,6 +346,8 @@ class TestOut(BaseModel): blue_review_by: uuid.UUID | None = None blue_review_at: datetime | None = None blue_review_notes: str | None = None + red_reviewer_assignee_username: str | None = None + blue_reviewer_assignee_username: str | None = None system_gaps: str | None = None # On-hold fields @@ -398,6 +406,23 @@ class TestOut(BaseModel): except Exception: # nosec B110 pass # DetachedInstanceError or similar — leave technique fields None + # Resolved assignee usernames (lazy-load, same as technique above). + # A plain operator/tech viewing their own test can't call + # GET /users/operators (lead/manager-only) to resolve an assignee ID + # into a name, so the API needs to hand it over pre-resolved. + for attr, field in ( + ("red_tech_assigned_user", "red_tech_assignee_username"), + ("blue_tech_assigned_user", "blue_tech_assignee_username"), + ("red_reviewer", "red_reviewer_assignee_username"), + ("blue_reviewer", "blue_reviewer_assignee_username"), + ): + try: + user = getattr(obj, attr, None) + if user is not None: + obj.__dict__[field] = user.username + except Exception: # nosec B110 + pass # DetachedInstanceError or similar — leave username None + # Only split evidences when they are already in memory (loaded via joinedload) raw_evs = obj.__dict__.get("evidences") if raw_evs is not None: diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index e07b79d..a1e6459 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -1202,6 +1202,20 @@ def _select_field(value: str) -> dict: return {"value": value} +def _jira_datetime(dt: datetime) -> str: + """Format a naive-UTC datetime for a Jira ``datetime``-type custom field. + + RT/BT Start/End Date are genuine Jira datetime fields (confirmed via + issue_editmeta — schema type "datetime"), not plain dates. Sending a + bare "YYYY-MM-DD" string makes Jira default the time-of-day to + midnight, so RT Start Date and RT End Date ended up showing the same + meaningless midnight timestamp regardless of when the operator + actually started/finished. Jira's REST API expects + ``yyyy-MM-dd'T'HH:mm:ss.SSSZ`` for this field type. + """ + return dt.strftime("%Y-%m-%dT%H:%M:%S.000+0000") + + def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]: """Return the elapsed time between two timestamps in hours, or None if unavailable.""" if not start or not end: @@ -1272,10 +1286,10 @@ def push_rt_submitted(db: Session, test: Test) -> None: else test.execution_start_time ) if first_start: - fields[JIRA_FIELD_RT_START_DATE] = first_start.strftime("%Y-%m-%d") + fields[JIRA_FIELD_RT_START_DATE] = _jira_datetime(first_start) if test.execution_end_time: - fields[JIRA_FIELD_RT_END_DATE] = test.execution_end_time.strftime("%Y-%m-%d") + fields[JIRA_FIELD_RT_END_DATE] = _jira_datetime(test.execution_end_time) attack_success = _enum_value(test.attack_success) if attack_success in _ATTACK_SUCCESS_TO_JIRA: @@ -1287,13 +1301,13 @@ def push_rt_submitted(db: Session, test: Test) -> None: def push_bt_started(db: Session, test: Test) -> None: """Set the BT Start Date field — called when Blue picks up the test to evaluate.""" _update_test_fields(db, test, { - JIRA_FIELD_BT_START_DATE: datetime.utcnow().strftime("%Y-%m-%d"), + JIRA_FIELD_BT_START_DATE: _jira_datetime(datetime.utcnow()), }) def push_bt_submitted(db: Session, test: Test) -> None: """Set BT End Date, Attack Detected/Contained, and time-to-detect/contain — Blue submits for review.""" - fields: dict = {JIRA_FIELD_BT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")} + fields: dict = {JIRA_FIELD_BT_END_DATE: _jira_datetime(datetime.utcnow())} detection_result = _enum_value(test.detection_result) if detection_result in _DETECTION_TO_JIRA: diff --git a/backend/app/services/test_crud_service.py b/backend/app/services/test_crud_service.py index e0b2690..2ebbf8b 100644 --- a/backend/app/services/test_crud_service.py +++ b/backend/app/services/test_crud_service.py @@ -396,6 +396,8 @@ def get_test_detail(db: Session, test_id: uuid.UUID) -> Test: .options( joinedload(Test.evidences), joinedload(Test.technique), joinedload(Test.round_history), + joinedload(Test.red_tech_assigned_user), joinedload(Test.blue_tech_assigned_user), + joinedload(Test.red_reviewer), joinedload(Test.blue_reviewer), ) .filter(Test.id == test_id) # Chain .first() call diff --git a/backend/tests/test_jira_service.py b/backend/tests/test_jira_service.py index d9b1fe8..c9bb717 100644 --- a/backend/tests/test_jira_service.py +++ b/backend/tests/test_jira_service.py @@ -397,8 +397,8 @@ def test_push_rt_submitted_uses_execution_times_not_click_time(mock_get_client, jira_service.push_rt_submitted(db, test) fields = mock_jira.update_issue_field.call_args[1]["fields"] - assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01" - assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-01" + assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01T09:00:00.000+0000" + assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-01T11:00:00.000+0000" @patch("app.services.jira_service.has_admin_jira_configured", return_value=True) @@ -439,8 +439,8 @@ def test_push_rt_submitted_rt_start_date_survives_reopen(mock_get_client, mock_c jira_service.push_rt_submitted(db, test) fields = mock_jira.update_issue_field.call_args[1]["fields"] - assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01" - assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-05" + assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01T09:00:00.000+0000" + assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-05T10:00:00.000+0000" @patch("app.services.jira_service.has_admin_jira_configured", return_value=True) diff --git a/backend/tests/test_test_assignee_username.py b/backend/tests/test_test_assignee_username.py new file mode 100644 index 0000000..42f4105 --- /dev/null +++ b/backend/tests/test_test_assignee_username.py @@ -0,0 +1,63 @@ +"""GET /tests/{id} must resolve assignee IDs into usernames directly, since +GET /users/operators (the only other source of that mapping) is restricted +to leads/managers — a plain operator viewing their own assigned test has no +other way to find out who they're looking at. Regression: the detail page +badge showed "RT: Unassigned" for the assigned operator themselves.""" + +from app.models.test import Test +from app.models.technique import Technique + + +def _seed_technique(db) -> Technique: + technique = Technique( + mitre_id="T9996", name="Assignee Username Test Technique", + tactic="execution", platforms=["linux"], + ) + db.add(technique) + db.commit() + db.refresh(technique) + return technique + + +def _seed_test(db, technique, created_by, **overrides) -> Test: + test = Test(technique_id=technique.id, name="Assignee username test", created_by=created_by, **overrides) + db.add(test) + db.commit() + db.refresh(test) + return test + + +def test_red_tech_assignee_username_resolved_for_the_assigned_operator( + client, db, red_tech_headers, red_tech_user, red_lead_user, +): + technique = _seed_technique(db) + test = _seed_test(db, technique, red_lead_user.id, red_tech_assignee=red_tech_user.id) + + resp = client.get(f"/api/v1/tests/{test.id}", headers=red_tech_headers) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["red_tech_assignee"] == str(red_tech_user.id) + assert body["red_tech_assignee_username"] == red_tech_user.username + + +def test_blue_tech_assignee_username_resolved(client, db, red_lead_headers, red_lead_user, blue_tech_user): + technique = _seed_technique(db) + test = _seed_test(db, technique, red_lead_user.id, blue_tech_assignee=blue_tech_user.id) + + resp = client.get(f"/api/v1/tests/{test.id}", headers=red_lead_headers) + + assert resp.status_code == 200, resp.text + assert resp.json()["blue_tech_assignee_username"] == blue_tech_user.username + + +def test_assignee_username_is_none_when_unassigned(client, db, red_lead_headers, red_lead_user): + technique = _seed_technique(db) + test = _seed_test(db, technique, red_lead_user.id) + + resp = client.get(f"/api/v1/tests/{test.id}", headers=red_lead_headers) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["red_tech_assignee"] is None + assert body["red_tech_assignee_username"] is None diff --git a/frontend/src/components/test-detail/AssigneeControl.tsx b/frontend/src/components/test-detail/AssigneeControl.tsx index ee528aa..284d3c5 100644 --- a/frontend/src/components/test-detail/AssigneeControl.tsx +++ b/frontend/src/components/test-detail/AssigneeControl.tsx @@ -15,6 +15,12 @@ interface Props { * techs+leads. "reviewer" = red_reviewer_assignee/blue_reviewer_assignee, * picking from leads only — for handing a review off to a peer lead. */ kind?: "operator" | "reviewer"; + /** Resolved username from the API. `operators` is only fetched for + * leads/managers (GET /users/operators is permission-gated), so a plain + * operator viewing their own test can't resolve assigneeId -> name from + * that list — this is the fallback so the badge doesn't misreport + * "Unassigned" just because the picker data isn't available to them. */ + assigneeUsername?: string | null; } const SIDE_STYLE = { @@ -41,10 +47,11 @@ const LABEL_PREFIX: Record<"operator" | "reviewer", Record<"red" | "blue", strin /** Lead/manager picker for operator assignment or reviewer hand-off. */ export default function AssigneeControl({ side, assigneeId, operators, canEdit, isSaving, onAssign, size = "sm", kind = "operator", + assigneeUsername, }: Props) { const [expanded, setExpanded] = useState(false); const current = operators.find((o) => o.id === assigneeId); - const label = current ? current.username : "Unassigned"; + const label = current?.username ?? (assigneeId ? assigneeUsername ?? "Unassigned" : "Unassigned"); const eligible = operators.filter((o) => ELIGIBLE_ROLES[kind][side].includes(o.role)); const sideLabel = LABEL_PREFIX[kind][side]; diff --git a/frontend/src/components/test-detail/TestDetailHeader.tsx b/frontend/src/components/test-detail/TestDetailHeader.tsx index 5e4d76b..763d357 100644 --- a/frontend/src/components/test-detail/TestDetailHeader.tsx +++ b/frontend/src/components/test-detail/TestDetailHeader.tsx @@ -559,6 +559,7 @@ export default function TestDetailHeader({ side="red" kind="reviewer" assigneeId={test.red_reviewer_assignee} + assigneeUsername={test.red_reviewer_assignee_username} operators={operators} canEdit={role === "red_lead" || role === "manager"} isSaving={isAssigningOperator} @@ -571,6 +572,7 @@ export default function TestDetailHeader({ side="blue" kind="reviewer" assigneeId={test.blue_reviewer_assignee} + assigneeUsername={test.blue_reviewer_assignee_username} operators={operators} canEdit={role === "blue_lead" || role === "manager"} isSaving={isAssigningOperator} @@ -581,6 +583,7 @@ export default function TestDetailHeader({