Compare commits
2 Commits
2afb886cd9
...
58c0143304
| Author | SHA1 | Date | |
|---|---|---|---|
| 58c0143304 | |||
| f32f636f05 |
@@ -253,6 +253,30 @@ def require_any_role(*roles: str) -> Callable[..., object]:
|
||||
return role_checker
|
||||
|
||||
|
||||
def require_any_role_strict(*roles: str) -> Callable[..., object]:
|
||||
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
|
||||
|
||||
Unlike ``require_any_role``, admins do **not** automatically pass — use
|
||||
this for actions that belong exclusively to Red/Blue operators, leads,
|
||||
or managers (e.g. executing, reviewing, validating, or resolving a
|
||||
disputed test), where "admin" must mean site administration only, not
|
||||
a backdoor into the test workflow itself.
|
||||
"""
|
||||
|
||||
async def role_checker(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if current_user.role not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions",
|
||||
)
|
||||
_check_api_key_scope(current_user, "write")
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
|
||||
def require_scope(scope: str):
|
||||
"""Return a dependency that enforces the API key carries *scope*.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.dependencies.auth import require_role
|
||||
from app.dependencies.auth import require_role, get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.mitre_sync_service import sync_mitre
|
||||
from app.services.intel_service import scan_intel
|
||||
@@ -285,11 +285,13 @@ _JIRA_KEYS = {
|
||||
@router.get("/jira-config", response_model=JiraConfigOut)
|
||||
def get_jira_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return current Jira configuration (merged DB + env).
|
||||
|
||||
**Requires** the ``admin`` role. Credential values are never returned.
|
||||
Open to any authenticated user — the frontend needs ``url`` to build
|
||||
correct ``/browse/{key}`` links for non-admin users too. Credential
|
||||
values (tokens, admin email) are never returned, only booleans.
|
||||
"""
|
||||
from app.services.jira_service import (
|
||||
get_jira_url, get_jira_project_key, is_jira_enabled,
|
||||
|
||||
@@ -42,7 +42,7 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
# Import get_current_user, require_any_role from app.dependencies.auth
|
||||
from app.dependencies.auth import get_current_user, require_any_role
|
||||
from app.dependencies.auth import get_current_user, require_any_role, require_any_role_strict
|
||||
|
||||
# Import UnitOfWork from app.domain.unit_of_work
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
@@ -595,8 +595,8 @@ def update_test_classification(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role(
|
||||
"admin", "manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
|
||||
current_user: User = Depends(require_any_role_strict(
|
||||
"manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
|
||||
)),
|
||||
) -> TestOut:
|
||||
"""Update the data classification label for a test.
|
||||
@@ -964,12 +964,12 @@ def review_red(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestRedReview,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_lead", "admin")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead")),
|
||||
) -> TestOut:
|
||||
"""Assigned Red Lead approves or reopens a test sitting in red_review."""
|
||||
test = crud_get_test_or_raise(db, test_id)
|
||||
|
||||
if current_user.role != "admin" and test.red_reviewer_assignee != current_user.id:
|
||||
if test.red_reviewer_assignee != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
||||
|
||||
with UnitOfWork(db) as uow:
|
||||
@@ -994,12 +994,12 @@ def review_blue(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestBlueReview,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("blue_lead", "admin")),
|
||||
current_user: User = Depends(require_any_role_strict("blue_lead")),
|
||||
) -> TestOut:
|
||||
"""Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review."""
|
||||
test = crud_get_test_or_raise(db, test_id)
|
||||
|
||||
if current_user.role != "admin" and test.blue_reviewer_assignee != current_user.id:
|
||||
if test.blue_reviewer_assignee != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
||||
|
||||
with UnitOfWork(db) as uow:
|
||||
@@ -1109,7 +1109,7 @@ def validate_red(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead")),
|
||||
) -> TestOut:
|
||||
"""Red Lead approves or rejects the red side of a test.
|
||||
|
||||
@@ -1166,7 +1166,7 @@ def validate_blue(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("blue_lead")),
|
||||
) -> TestOut:
|
||||
"""Blue Lead approves or rejects the blue side of a test.
|
||||
|
||||
@@ -1218,7 +1218,7 @@ 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")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
) -> 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)
|
||||
@@ -1242,7 +1242,7 @@ def reopen(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
) -> TestOut:
|
||||
"""Reopen a rejected test, moving it back to ``draft``.
|
||||
|
||||
@@ -1269,7 +1269,7 @@ def reopen(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /tests/{id}/assign — assign red_tech / blue_tech operators (leads + admin)
|
||||
# POST /tests/{id}/assign — assign red_tech / blue_tech operators (leads + managers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1278,9 +1278,9 @@ def assign_test_operators(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestAssign,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
|
||||
):
|
||||
"""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. Leads/managers only — not admin, who administers the site rather than coordinating operators."""
|
||||
test = crud_get_test_or_raise(db, test_id)
|
||||
newly_assigned: User | None = None
|
||||
|
||||
@@ -1332,7 +1332,7 @@ def hold_test(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestHold,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "admin")),
|
||||
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
|
||||
):
|
||||
"""Place a test on hold with a mandatory reason. Posts comment + transitions Jira."""
|
||||
from datetime import datetime as _dt
|
||||
@@ -1372,7 +1372,7 @@ def hold_test(
|
||||
def resume_test(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "admin")),
|
||||
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
|
||||
):
|
||||
"""Resume a test that was placed on hold."""
|
||||
from app.services.jira_service import push_hold_event
|
||||
@@ -1644,7 +1644,7 @@ def sync_tempo(
|
||||
def request_discussion(
|
||||
test_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "admin")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
):
|
||||
"""Called when the approving lead confirms their vote in a disputed test.
|
||||
|
||||
@@ -1663,12 +1663,12 @@ def request_discussion(
|
||||
role = current_user.role
|
||||
|
||||
# Identify who the "other lead" is (the one who rejected)
|
||||
if (role in ("red_lead", "admin")) and test.red_validation_status == "approved":
|
||||
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 in ("blue_lead", "admin")) and test.blue_validation_status == "approved":
|
||||
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"
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
# Import require_role from app.dependencies.auth
|
||||
from app.dependencies.auth import require_role, require_any_role
|
||||
from app.dependencies.auth import require_role, require_any_role_strict
|
||||
|
||||
# Import UnitOfWork from app.domain.unit_of_work
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
@@ -156,13 +156,13 @@ def create_user_route(
|
||||
@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")),
|
||||
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
|
||||
) -> list[UserOperatorOut]:
|
||||
"""Return active red/blue operators and leads, for lead/admin assignment pickers.
|
||||
"""Return active red/blue operators and leads, for lead/manager 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.
|
||||
Not reachable by admin — admin administers the site, leads/managers
|
||||
coordinate operators. Returns only id/username/role — no emails or
|
||||
tokens — since this is reachable by non-admin leads.
|
||||
"""
|
||||
return (
|
||||
db.query(User)
|
||||
|
||||
@@ -700,7 +700,7 @@ def auto_create_test_issue(
|
||||
|
||||
poc = test.procedure_text or "N/A"
|
||||
severity = _technique_severity(technique).capitalize()
|
||||
labels = ["aegis", "security-test", mitre_id.replace(".", "-")]
|
||||
labels = ["aegis", 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("-"))
|
||||
|
||||
@@ -1228,13 +1228,12 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes
|
||||
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"
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Admin must be locked out of every Test-workflow *action* endpoint.
|
||||
|
||||
Admin administers the site; leads/managers/operators run the test workflow.
|
||||
The permission dependency runs before any DB lookup, so a random UUID is
|
||||
enough to prove the 403 — the request never reaches the handler body.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
DUMMY_ID = str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,payload",
|
||||
[
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/review-red", {"decision": "approve"}),
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/review-blue", {"decision": "approve"}),
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/validate-red", {"red_validation_status": "approved"}),
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/validate-blue", {"blue_validation_status": "approved"}),
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/resolve-dispute", {"target_team": "red"}),
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/request-discussion", None),
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/reopen", None),
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/hold", {"reason": "x"}),
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/resume", None),
|
||||
("post", f"/api/v1/tests/{DUMMY_ID}/assign", {"red_tech_assignee": DUMMY_ID}),
|
||||
("patch", f"/api/v1/tests/{DUMMY_ID}/classification", {"data_classification": "pii"}),
|
||||
],
|
||||
)
|
||||
def test_admin_forbidden(client, auth_headers, method, path, payload):
|
||||
resp = getattr(client, method)(path, json=payload, headers=auth_headers)
|
||||
assert resp.status_code == 403, f"{method.upper()} {path} -> {resp.status_code}: {resp.text}"
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for POST /tests/{id}/assign — lead/admin manual operator assignment."""
|
||||
"""Tests for POST /tests/{id}/assign — lead/manager manual operator assignment."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -67,6 +67,32 @@ def test_red_tech_cannot_assign(client, db, red_tech_headers, red_tech_user):
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_admin_cannot_assign(client, db, auth_headers, admin_user, red_tech_user):
|
||||
"""Admin administers the site — coordinating operators is a lead/manager call."""
|
||||
technique = _seed_technique(db)
|
||||
test = _seed_test(db, technique, admin_user.id)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tests/{test.id}/assign",
|
||||
json={"red_tech_assignee": str(red_tech_user.id)},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_manager_can_assign(client, db, manager_headers, manager_user, red_tech_user):
|
||||
technique = _seed_technique(db)
|
||||
test = _seed_test(db, technique, manager_user.id)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tests/{test.id}/assign",
|
||||
json={"red_tech_assignee": str(red_tech_user.id)},
|
||||
headers=manager_headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["red_tech_assignee"] == str(red_tech_user.id)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -61,7 +61,9 @@ def test_create_test_endpoint_uses_tactic_heuristic(client, db, api, auth_header
|
||||
assert resp.json()["data_classification"] == "internal_use_only"
|
||||
|
||||
|
||||
def test_admin_can_update_classification(client, db, admin_user, admin_token, red_lead_user):
|
||||
def test_admin_cannot_update_classification(client, db, admin_user, admin_token, red_lead_user):
|
||||
"""Admin administers the site, not test content — classification is a
|
||||
lead/manager/operator call."""
|
||||
technique = _seed_technique(db)
|
||||
test = Test(
|
||||
technique_id=technique.id,
|
||||
@@ -77,6 +79,25 @@ def test_admin_can_update_classification(client, db, admin_user, admin_token, re
|
||||
json={"data_classification": "pii"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_manager_can_update_classification(client, db, manager_user, manager_headers, red_lead_user):
|
||||
technique = _seed_technique(db)
|
||||
test = Test(
|
||||
technique_id=technique.id,
|
||||
name="Classify me",
|
||||
created_by=red_lead_user.id,
|
||||
state=TestState.draft,
|
||||
)
|
||||
db.add(test)
|
||||
db.commit()
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/tests/{test.id}/classification",
|
||||
json={"data_classification": "pii"},
|
||||
headers=manager_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data_classification"] == "pii"
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""GET /system/jira-config must be readable by any authenticated user.
|
||||
|
||||
Regression: it used to be admin-only, so non-admin users (e.g. red_lead)
|
||||
got a 403 and the frontend silently fell back to a hardcoded
|
||||
"https://jira.atlassian.com" base URL when building ticket links.
|
||||
"""
|
||||
|
||||
|
||||
def test_non_admin_can_read_jira_config(client, red_lead_headers):
|
||||
resp = client.get("/api/v1/system/jira-config", headers=red_lead_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "url" in resp.json()
|
||||
|
||||
|
||||
def test_non_admin_cannot_update_jira_config(client, red_lead_headers):
|
||||
resp = client.patch(
|
||||
"/api/v1/system/jira-config",
|
||||
json={"url": "https://evil.example.com"},
|
||||
headers=red_lead_headers,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -414,11 +414,11 @@ def test_auto_create_test_issue_adds_platform_label(
|
||||
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]
|
||||
assert ["aegis", "T1003-006"] == labels[:2]
|
||||
if expected_label:
|
||||
assert expected_label in labels
|
||||
else:
|
||||
assert len(labels) == 3
|
||||
assert len(labels) == 2
|
||||
|
||||
|
||||
def _make_user(**overrides):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for GET /users/operators — lead/admin-reachable operator picker list."""
|
||||
"""Tests for GET /users/operators — lead/manager-reachable operator picker list."""
|
||||
|
||||
|
||||
def test_red_lead_can_list_operators(client, red_lead_headers, red_tech_user, blue_tech_user, red_lead_user):
|
||||
@@ -31,3 +31,16 @@ def test_operators_list_excludes_viewers(client, red_lead_headers, db):
|
||||
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
|
||||
|
||||
|
||||
def test_admin_cannot_list_operators(client, auth_headers):
|
||||
"""Admin administers the site — coordinating operators is a lead/manager call."""
|
||||
resp = client.get("/api/v1/users/operators", headers=auth_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_manager_can_list_operators(client, manager_headers, red_tech_user):
|
||||
resp = client.get("/api/v1/users/operators", headers=manager_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
usernames = {u["username"] for u in resp.json()}
|
||||
assert "redtech" in usernames
|
||||
|
||||
@@ -143,14 +143,15 @@ def test_review_red_reopen_moves_to_red_executing(
|
||||
assert body["red_review_notes"] == "add more detail"
|
||||
|
||||
|
||||
def test_review_red_admin_can_always_act(
|
||||
def test_review_red_forbidden_for_admin(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_user, technique
|
||||
):
|
||||
"""Admin administers the site, not the test workflow — reviewing is a
|
||||
red_lead-only action now, with no admin override."""
|
||||
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-red", auth_headers, json={"decision": "approve"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["state"] == "blue_evaluating"
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -829,7 +829,9 @@ class TestResolveDispute:
|
||||
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):
|
||||
def test_admin_cannot_resolve_dispute(self, mock_log):
|
||||
"""Admin administers the site — only the lead who approved may flip
|
||||
their vote, with no admin override."""
|
||||
test = _make_test(
|
||||
TestState.disputed,
|
||||
red_validation_status="approved",
|
||||
@@ -839,8 +841,8 @@ class TestResolveDispute:
|
||||
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
|
||||
with pytest.raises(InvalidOperationError):
|
||||
resolve_dispute(db, test, admin, "red")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
@@ -179,7 +179,7 @@ export async function updateTestClassification(
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Assign red_tech/blue_tech operators to a test (leads + admin only). */
|
||||
/** Assign red_tech/blue_tech operators to a test (leads + managers only). */
|
||||
export async function assignTestOperators(
|
||||
testId: string,
|
||||
payload: { red_tech_assignee?: string | null; blue_tech_assignee?: string | null },
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface OperatorOut {
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** Fetch red/blue operators + leads for assignment pickers (leads + admin). */
|
||||
/** Fetch red/blue operators + leads for assignment pickers (leads + managers). */
|
||||
export async function getOperators(): Promise<OperatorOut[]> {
|
||||
const { data } = await client.get<OperatorOut[]>("/users/operators");
|
||||
return data;
|
||||
|
||||
@@ -9,11 +9,13 @@ interface Props {
|
||||
canEdit: boolean;
|
||||
isSaving: boolean;
|
||||
onAssign: (userId: string | null) => void;
|
||||
/** "sm" = compact pill (default), "lg" = full-size button matching the action bar. */
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
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",
|
||||
red: "border-orange-500/40 bg-orange-900/20 text-orange-400 hover:bg-orange-900/40",
|
||||
blue: "border-indigo-500/40 bg-indigo-900/20 text-indigo-400 hover:bg-indigo-900/40",
|
||||
};
|
||||
|
||||
const SIDE_ROLES: Record<"red" | "blue", string[]> = {
|
||||
@@ -21,20 +23,28 @@ const SIDE_ROLES: Record<"red" | "blue", string[]> = {
|
||||
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) {
|
||||
/** Lead/manager picker for the red_tech_assignee / blue_tech_assignee fields. */
|
||||
export default function AssigneeControl({
|
||||
side, assigneeId, operators, canEdit, isSaving, onAssign, size = "sm",
|
||||
}: 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 sideLabel = side === "red" ? "RT" : "BT";
|
||||
|
||||
const badgeClass = `inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SIDE_STYLE[side]}`;
|
||||
const badgeClass =
|
||||
size === "lg"
|
||||
? `flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium ${SIDE_STYLE[side]}`
|
||||
: `inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SIDE_STYLE[side]}`;
|
||||
const iconSize = size === "lg" ? "h-4 w-4" : "h-2.5 w-2.5";
|
||||
const chevronSize = size === "lg" ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<span className={badgeClass}>
|
||||
<UserCircle2 className="h-2.5 w-2.5" />
|
||||
{side === "red" ? "RT" : "BT"}: {label}
|
||||
<UserCircle2 className={iconSize} />
|
||||
{sideLabel}: {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -43,15 +53,15 @@ export default function AssigneeControl({ side, assigneeId, operators, canEdit,
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className={`${badgeClass} transition-colors hover:opacity-80`}
|
||||
className={`${badgeClass} transition-colors`}
|
||||
>
|
||||
<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" />}
|
||||
<UserCircle2 className={iconSize} />
|
||||
{sideLabel}: {label}
|
||||
{expanded ? <ChevronUp className={chevronSize} /> : <ChevronDown className={chevronSize} />}
|
||||
</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">
|
||||
<div className="absolute right-0 top-full z-20 mt-1 w-52 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" />
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function TestDetailHeader({
|
||||
const HOLDABLE_STATES: TestState[] = ["draft", "red_executing", "blue_evaluating"];
|
||||
const canHold =
|
||||
HOLDABLE_STATES.includes(test.state) &&
|
||||
(role === "red_tech" || role === "blue_tech" || role === "admin");
|
||||
(role === "red_tech" || role === "blue_tech");
|
||||
|
||||
const renderActions = () => {
|
||||
const buttons: React.ReactNode[] = [];
|
||||
@@ -224,7 +224,7 @@ export default function TestDetailHeader({
|
||||
// Red Lead assigned to review this submission -> Review Red Submission
|
||||
if (
|
||||
test.state === "red_review" &&
|
||||
(role === "admin" || (role === "red_lead" && test.red_reviewer_assignee === user?.id))
|
||||
role === "red_lead" && test.red_reviewer_assignee === user?.id
|
||||
) {
|
||||
buttons.push(
|
||||
<button
|
||||
@@ -241,7 +241,7 @@ export default function TestDetailHeader({
|
||||
// Blue Lead assigned to review this submission -> Review Blue Submission
|
||||
if (
|
||||
test.state === "blue_review" &&
|
||||
(role === "admin" || (role === "blue_lead" && test.blue_reviewer_assignee === user?.id))
|
||||
role === "blue_lead" && test.blue_reviewer_assignee === user?.id
|
||||
) {
|
||||
buttons.push(
|
||||
<button
|
||||
@@ -299,7 +299,7 @@ export default function TestDetailHeader({
|
||||
// Red Lead in in_review -> Validate Red
|
||||
if (
|
||||
test.state === "in_review" &&
|
||||
(role === "red_lead" || role === "admin") &&
|
||||
role === "red_lead" &&
|
||||
!test.red_validation_status
|
||||
) {
|
||||
buttons.push(
|
||||
@@ -317,7 +317,7 @@ export default function TestDetailHeader({
|
||||
// Blue Lead in in_review -> Validate Blue
|
||||
if (
|
||||
test.state === "in_review" &&
|
||||
(role === "blue_lead" || role === "admin") &&
|
||||
role === "blue_lead" &&
|
||||
!test.blue_validation_status
|
||||
) {
|
||||
buttons.push(
|
||||
@@ -336,18 +336,16 @@ export default function TestDetailHeader({
|
||||
if (test.state === "disputed") {
|
||||
const isApproving =
|
||||
(role === "red_lead" && test.red_validation_status === "approved") ||
|
||||
(role === "blue_lead" && test.blue_validation_status === "approved") ||
|
||||
(role === "admin" && test.red_validation_status === "approved");
|
||||
(role === "blue_lead" && test.blue_validation_status === "approved");
|
||||
|
||||
const isRejecting =
|
||||
(role === "red_lead" && test.red_validation_status === "rejected") ||
|
||||
(role === "blue_lead" && test.blue_validation_status === "rejected") ||
|
||||
(role === "admin" && test.blue_validation_status === "rejected");
|
||||
(role === "blue_lead" && test.blue_validation_status === "rejected");
|
||||
|
||||
const rejectingSide: "red" | "blue" =
|
||||
test.red_validation_status === "rejected" ? "red" : "blue";
|
||||
|
||||
if (isApproving || role === "admin") {
|
||||
if (isApproving) {
|
||||
buttons.push(
|
||||
<div key="disputed-approver" className="flex flex-col items-end gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -401,10 +399,10 @@ export default function TestDetailHeader({
|
||||
}
|
||||
}
|
||||
|
||||
// Leads/admin on rejected -> Reopen
|
||||
// Leads on rejected -> Reopen
|
||||
if (
|
||||
test.state === "rejected" &&
|
||||
(role === "red_lead" || role === "blue_lead" || role === "admin")
|
||||
(role === "red_lead" || role === "blue_lead")
|
||||
) {
|
||||
buttons.push(
|
||||
<button
|
||||
@@ -539,7 +537,7 @@ export default function TestDetailHeader({
|
||||
</span>
|
||||
<DataClassificationBadge
|
||||
value={test.data_classification}
|
||||
canEdit={["admin", "manager", "red_tech", "red_lead", "blue_tech", "blue_lead"].includes(role)}
|
||||
canEdit={["manager", "red_tech", "red_lead", "blue_tech", "blue_lead"].includes(role)}
|
||||
isSaving={isUpdatingClassification}
|
||||
onChange={onUpdateClassification}
|
||||
/>
|
||||
@@ -547,29 +545,31 @@ export default function TestDetailHeader({
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Created {formatDate(test.created_at)}
|
||||
</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()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssigneeControl
|
||||
side="red"
|
||||
assigneeId={test.red_tech_assignee}
|
||||
operators={operators}
|
||||
canEdit={role === "red_lead" || role === "manager"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("red", userId)}
|
||||
size="lg"
|
||||
/>
|
||||
<AssigneeControl
|
||||
side="blue"
|
||||
assigneeId={test.blue_tech_assignee}
|
||||
operators={operators}
|
||||
canEdit={role === "blue_lead" || role === "manager"}
|
||||
isSaving={isAssigningOperator}
|
||||
onAssign={(userId) => onAssignOperator("blue", userId)}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
{renderLiveTimer()}
|
||||
{renderActions()}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,8 @@ import { useAuth } from "../context/AuthContext";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: "", label: "All Sources" },
|
||||
@@ -89,7 +90,9 @@ export default function TestCatalogPage() {
|
||||
const [source, setSource] = useState(searchParams.get("source") || "");
|
||||
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
|
||||
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
||||
const [hideCovered, setHideCovered] = useState(searchParams.get("hide_covered") === "1");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
// Build filters
|
||||
const filters = {
|
||||
@@ -97,15 +100,21 @@ export default function TestCatalogPage() {
|
||||
source: source || undefined,
|
||||
platform: platform || undefined,
|
||||
severity: severity || undefined,
|
||||
offset: page * PAGE_SIZE,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * pageSize,
|
||||
limit: pageSize,
|
||||
};
|
||||
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
const { data: allTemplates = [], isLoading } = useQuery({
|
||||
queryKey: ["test-templates", filters],
|
||||
queryFn: () => getTemplates(filters),
|
||||
});
|
||||
|
||||
// "No tests yet" is a client-side filter — existing_test_count is already
|
||||
// returned per-template, no need for a separate backend round-trip.
|
||||
const templates = hideCovered
|
||||
? allTemplates.filter((t) => t.existing_test_count === 0)
|
||||
: allTemplates;
|
||||
|
||||
// ── Filter handlers ──────────────────────────────────────────────
|
||||
|
||||
const applyFilters = () => {
|
||||
@@ -115,6 +124,7 @@ export default function TestCatalogPage() {
|
||||
if (source) params.set("source", source);
|
||||
if (platform) params.set("platform", platform);
|
||||
if (severity) params.set("severity", severity);
|
||||
if (hideCovered) params.set("hide_covered", "1");
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
@@ -123,11 +133,12 @@ export default function TestCatalogPage() {
|
||||
setSource("");
|
||||
setPlatform("");
|
||||
setSeverity("");
|
||||
setHideCovered(false);
|
||||
setPage(0);
|
||||
setSearchParams({});
|
||||
};
|
||||
|
||||
const hasActiveFilters = search || source || platform || severity;
|
||||
const hasActiveFilters = search || source || platform || severity || hideCovered;
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -220,6 +231,17 @@ export default function TestCatalogPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No-coverage-yet toggle */}
|
||||
<label className="mt-3 flex w-fit items-center gap-2 text-sm text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideCovered}
|
||||
onChange={(e) => { setHideCovered(e.target.checked); setPage(0); }}
|
||||
className="h-4 w-4 rounded border-gray-600 bg-gray-800 text-cyan-500 focus:ring-cyan-500"
|
||||
/>
|
||||
Only show techniques with no existing test
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
@@ -253,11 +275,26 @@ export default function TestCatalogPage() {
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {page * PAGE_SIZE + 1}
|
||||
{" - "}
|
||||
{page * PAGE_SIZE + templates.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {page * pageSize + 1}
|
||||
{" - "}
|
||||
{page * pageSize + allTemplates.length}
|
||||
{hideCovered && templates.length !== allTemplates.length && ` (${templates.length} without existing tests)`}
|
||||
</p>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-500">
|
||||
Show
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(0); }}
|
||||
className="rounded-lg border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
@@ -270,7 +307,7 @@ export default function TestCatalogPage() {
|
||||
<span className="text-sm text-gray-400">Page {page + 1}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={templates.length < PAGE_SIZE}
|
||||
disabled={allTemplates.length < pageSize}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Next
|
||||
|
||||
@@ -135,7 +135,7 @@ export default function TestDetailPage() {
|
||||
enabled: !!testId && !!test && (test.retest_of !== null || test.retest_count > 0),
|
||||
});
|
||||
|
||||
const canAssignOperators = ["red_lead", "blue_lead", "admin"].includes(user?.role ?? "");
|
||||
const canAssignOperators = ["red_lead", "blue_lead", "manager"].includes(user?.role ?? "");
|
||||
const { data: operators = [] } = useQuery({
|
||||
queryKey: ["operators"],
|
||||
queryFn: getOperators,
|
||||
@@ -629,7 +629,7 @@ export default function TestDetailPage() {
|
||||
</span>
|
||||
{test.technique_name && (
|
||||
<span className="text-xs text-gray-400 group-hover:text-gray-300 transition-colors">
|
||||
\u2014 {test.technique_name}
|
||||
\— {test.technique_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user