Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cf8001de5 | |||
| 1c8fa436ab |
@@ -218,6 +218,10 @@ def list_tests(
|
|||||||
pending_validation_side: Optional[str] = Query(
|
pending_validation_side: Optional[str] = Query(
|
||||||
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
|
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
|
||||||
),
|
),
|
||||||
|
reviewer_id: Optional[uuid.UUID] = Query(
|
||||||
|
None,
|
||||||
|
description="\"My reviews\" queue — filter red_review/blue_review tests assigned to this reviewer",
|
||||||
|
),
|
||||||
not_in_any_campaign: bool = Query(
|
not_in_any_campaign: bool = Query(
|
||||||
False, description="Only return tests not linked to any campaign"
|
False, description="Only return tests not linked to any campaign"
|
||||||
),
|
),
|
||||||
@@ -259,6 +263,7 @@ def list_tests(
|
|||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
# Keyword argument: pending_validation_side
|
# Keyword argument: pending_validation_side
|
||||||
pending_validation_side=pending_validation_side,
|
pending_validation_side=pending_validation_side,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
not_in_any_campaign=not_in_any_campaign,
|
not_in_any_campaign=not_in_any_campaign,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
# Keyword argument: limit
|
# Keyword argument: limit
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ class TestTemplateSummary(BaseModel):
|
|||||||
severity: str | None = None
|
severity: str | None = None
|
||||||
# Assign is_active = True
|
# Assign is_active = True
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
# Number of existing Test rows for this template's technique — lets the
|
||||||
|
# catalog UI warn before creating a likely-duplicate test.
|
||||||
|
existing_test_count: int = 0
|
||||||
|
|
||||||
# Assign model_config = ConfigDict(from_attributes=True)
|
# Assign model_config = ConfigDict(from_attributes=True)
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ from app.schemas.metrics import (
|
|||||||
ValidationRate,
|
ValidationRate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Canonical MITRE ATT&CK kill-chain order — used to sort "coverage by
|
||||||
|
# tactic" consistently everywhere it's rendered (dashboard table, executive
|
||||||
|
# bar chart), instead of each consumer inventing its own order/subset.
|
||||||
|
MITRE_TACTIC_ORDER: list[str] = [
|
||||||
|
"reconnaissance",
|
||||||
|
"resource-development",
|
||||||
|
"initial-access",
|
||||||
|
"execution",
|
||||||
|
"persistence",
|
||||||
|
"privilege-escalation",
|
||||||
|
"defense-evasion",
|
||||||
|
"credential-access",
|
||||||
|
"discovery",
|
||||||
|
"lateral-movement",
|
||||||
|
"collection",
|
||||||
|
"command-and-control",
|
||||||
|
"exfiltration",
|
||||||
|
"impact",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# Define function get_coverage_summary
|
# Define function get_coverage_summary
|
||||||
def get_coverage_summary(db: Session) -> CoverageSummary:
|
def get_coverage_summary(db: Session) -> CoverageSummary:
|
||||||
@@ -112,6 +132,12 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
|
|||||||
lambda: {s.value: 0 for s in TechniqueStatus}
|
lambda: {s.value: 0 for s in TechniqueStatus}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Materialize every canonical tactic up front so the response always
|
||||||
|
# includes all 14, zero-filled, rather than only whichever tactics
|
||||||
|
# happen to have techniques seeded.
|
||||||
|
for tactic in MITRE_TACTIC_ORDER:
|
||||||
|
tactic_data[tactic] # noqa: B018 — touch to create the defaultdict entry
|
||||||
|
|
||||||
# Iterate over techniques
|
# Iterate over techniques
|
||||||
for tactic_str, status in techniques:
|
for tactic_str, status in techniques:
|
||||||
# Check: not tactic_str
|
# Check: not tactic_str
|
||||||
@@ -128,10 +154,17 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
|
|||||||
# Assign tactic_data[tactic][status.value] = 1
|
# Assign tactic_data[tactic][status.value] = 1
|
||||||
tactic_data[tactic][status.value] += 1
|
tactic_data[tactic][status.value] += 1
|
||||||
|
|
||||||
|
def _tactic_sort_key(tactic: str) -> tuple[int, str]:
|
||||||
|
try:
|
||||||
|
return (MITRE_TACTIC_ORDER.index(tactic), tactic)
|
||||||
|
except ValueError:
|
||||||
|
# Anything outside the canonical 14 (e.g. "unknown") sorts last.
|
||||||
|
return (len(MITRE_TACTIC_ORDER), tactic)
|
||||||
|
|
||||||
# Assign result = []
|
# Assign result = []
|
||||||
result = []
|
result = []
|
||||||
# Iterate over sorted(tactic_data)
|
# Iterate over sorted(tactic_data)
|
||||||
for tactic in sorted(tactic_data):
|
for tactic in sorted(tactic_data, key=_tactic_sort_key):
|
||||||
# Assign counts = tactic_data[tactic]
|
# Assign counts = tactic_data[tactic]
|
||||||
counts = tactic_data[tactic]
|
counts = tactic_data[tactic]
|
||||||
# Assign total = sum(counts.values())
|
# Assign total = sum(counts.values())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# Import Session, joinedload from sqlalchemy.orm
|
# Import Session, joinedload from sqlalchemy.orm
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
# Import from app.domain.errors
|
# Import from app.domain.errors
|
||||||
@@ -63,6 +64,8 @@ def list_tests(
|
|||||||
created_by: uuid.UUID | None = None,
|
created_by: uuid.UUID | None = None,
|
||||||
# Entry: pending_validation_side
|
# Entry: pending_validation_side
|
||||||
pending_validation_side: str | None = None,
|
pending_validation_side: str | None = None,
|
||||||
|
# Entry: reviewer_id — "my reviews" queue for the red_review/blue_review gate stage
|
||||||
|
reviewer_id: uuid.UUID | None = None,
|
||||||
not_in_any_campaign: bool = False,
|
not_in_any_campaign: bool = False,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
# Entry: limit
|
# Entry: limit
|
||||||
@@ -101,6 +104,23 @@ def list_tests(
|
|||||||
Test.state == TestState.in_review,
|
Test.state == TestState.in_review,
|
||||||
Test.blue_validation_status.in_(["pending", None]),
|
Test.blue_validation_status.in_(["pending", None]),
|
||||||
)
|
)
|
||||||
|
# "My reviews" — tests currently assigned to *reviewer_id* at the
|
||||||
|
# red_review/blue_review lead-review gate (distinct from
|
||||||
|
# pending_validation_side, which covers the later in_review stage).
|
||||||
|
if reviewer_id:
|
||||||
|
if state == TestState.red_review.value:
|
||||||
|
query = query.filter(Test.red_reviewer_assignee == reviewer_id)
|
||||||
|
elif state == TestState.blue_review.value:
|
||||||
|
query = query.filter(Test.blue_reviewer_assignee == reviewer_id)
|
||||||
|
else:
|
||||||
|
query = query.filter(
|
||||||
|
Test.state.in_([TestState.red_review, TestState.blue_review]),
|
||||||
|
or_(
|
||||||
|
Test.red_reviewer_assignee == reviewer_id,
|
||||||
|
Test.blue_reviewer_assignee == reviewer_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if not_in_any_campaign:
|
if not_in_any_campaign:
|
||||||
linked = db.query(CampaignTest.test_id).distinct().subquery()
|
linked = db.query(CampaignTest.test_id).distinct().subquery()
|
||||||
query = query.filter(~Test.id.in_(linked))
|
query = query.filter(~Test.id.in_(linked))
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from app.domain.errors import EntityNotFoundError
|
|||||||
|
|
||||||
# Import TestTemplate from app.models.test_template
|
# Import TestTemplate from app.models.test_template
|
||||||
from app.models.test_template import TestTemplate
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
|
||||||
# Import escape_like from app.utils
|
# Import escape_like from app.utils
|
||||||
from app.utils import escape_like
|
from app.utils import escape_like
|
||||||
@@ -91,6 +93,21 @@ def list_templates(
|
|||||||
# Chain .all() call
|
# Chain .all() call
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Attach existing_test_count per template — lets the catalog warn before
|
||||||
|
# creating a likely-duplicate test for a technique that already has one.
|
||||||
|
if templates:
|
||||||
|
mitre_ids = {t.mitre_technique_id for t in templates}
|
||||||
|
counts = dict(
|
||||||
|
db.query(Technique.mitre_id, func.count(Test.id))
|
||||||
|
.join(Test, Test.technique_id == Technique.id)
|
||||||
|
.filter(Technique.mitre_id.in_(mitre_ids))
|
||||||
|
.group_by(Technique.mitre_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for t in templates:
|
||||||
|
t.existing_test_count = counts.get(t.mitre_technique_id, 0)
|
||||||
|
|
||||||
# Return templates
|
# Return templates
|
||||||
return templates
|
return templates
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.domain.exceptions import BusinessRuleViolation, InvalidOperationError
|
from app.domain.exceptions import BusinessRuleViolation, InvalidOperationError
|
||||||
from app.domain.test_entity import TestEntity
|
from app.domain.test_entity import TestEntity
|
||||||
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
from app.models.enums import TestState, TeamSide
|
from app.models.enums import TestState, TeamSide
|
||||||
from app.models.evidence import Evidence
|
from app.models.evidence import Evidence
|
||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
@@ -174,8 +175,26 @@ def transition_state(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_campaign_start_date(db: Session, test_id) -> datetime | None:
|
||||||
|
"""Return the scheduled start_date of the campaign *test_id* belongs to, if any."""
|
||||||
|
campaign = (
|
||||||
|
db.query(Campaign)
|
||||||
|
.join(CampaignTest, CampaignTest.campaign_id == Campaign.id)
|
||||||
|
.filter(CampaignTest.test_id == test_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return campaign.start_date if campaign else None
|
||||||
|
|
||||||
|
|
||||||
def start_execution(db: Session, test: Test, user: User) -> Test:
|
def start_execution(db: Session, test: Test, user: User) -> Test:
|
||||||
"""Move from ``draft`` → ``red_executing``."""
|
"""Move from ``draft`` → ``red_executing``."""
|
||||||
|
campaign_start_date = _get_campaign_start_date(db, test.id)
|
||||||
|
if campaign_start_date and campaign_start_date > datetime.utcnow():
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
"This test's campaign is scheduled to start on "
|
||||||
|
f"{campaign_start_date.date().isoformat()} — it cannot be started before then."
|
||||||
|
)
|
||||||
|
|
||||||
entity = TestEntity.from_orm(test)
|
entity = TestEntity.from_orm(test)
|
||||||
# Call entity.start_execution()
|
# Call entity.start_execution()
|
||||||
entity.start_execution()
|
entity.start_execution()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Tests for the campaign start_date scheduling gate on test execution.
|
||||||
|
|
||||||
|
Block 3 fix: `Campaign.start_date` was persisted and displayed but never
|
||||||
|
actually enforced — a test belonging to a campaign scheduled for the future
|
||||||
|
could still be started immediately. `start_execution` now blocks this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.errors import BusinessRuleViolation
|
||||||
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
|
from app.models.enums import TestState
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.services.test_workflow_service import start_execution
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_test_in_campaign(db, owner_id, start_date):
|
||||||
|
tech = Technique(mitre_id="T1059.003", name="Windows Command Shell", tactic="execution", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
campaign = Campaign(name="Scheduled Campaign", type="custom", status="active", created_by=owner_id, start_date=start_date)
|
||||||
|
db.add(campaign)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
test = Test(technique_id=tech.id, name="Scheduled test", state=TestState.draft, created_by=owner_id)
|
||||||
|
db.add(test)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_execution_blocked_before_campaign_start_date(db, red_tech_user):
|
||||||
|
future = datetime.utcnow() + timedelta(days=7)
|
||||||
|
test = _seed_test_in_campaign(db, red_tech_user.id, future)
|
||||||
|
|
||||||
|
with pytest.raises(BusinessRuleViolation):
|
||||||
|
start_execution(db, test, red_tech_user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_execution_allowed_after_campaign_start_date(db, red_tech_user):
|
||||||
|
past = datetime.utcnow() - timedelta(days=1)
|
||||||
|
test = _seed_test_in_campaign(db, red_tech_user.id, past)
|
||||||
|
|
||||||
|
result = start_execution(db, test, red_tech_user)
|
||||||
|
|
||||||
|
assert result.state == TestState.red_executing
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_execution_allowed_for_standalone_test(db, red_tech_user):
|
||||||
|
"""A test with no campaign at all is never gated by this check."""
|
||||||
|
tech = Technique(mitre_id="T1059.004", name="Unix Shell", tactic="execution", platforms=["linux"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
test = Test(technique_id=tech.id, name="Standalone test", state=TestState.draft, created_by=red_tech_user.id)
|
||||||
|
db.add(test)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
|
||||||
|
result = start_execution(db, test, red_tech_user)
|
||||||
|
|
||||||
|
assert result.state == TestState.red_executing
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_execution_allowed_when_campaign_has_no_start_date(db, red_tech_user):
|
||||||
|
test = _seed_test_in_campaign(db, red_tech_user.id, None)
|
||||||
|
|
||||||
|
result = start_execution(db, test, red_tech_user)
|
||||||
|
|
||||||
|
assert result.state == TestState.red_executing
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Tests for canonical MITRE kill-chain ordering in coverage-by-tactic.
|
||||||
|
|
||||||
|
Block 3 fix: TacticCoverageChart.tsx (dashboard) rendered whatever order
|
||||||
|
the backend happened to return (alphabetical, and only tactics with
|
||||||
|
seeded techniques), while ExecutiveDashboardPage.tsx re-sorted client-side
|
||||||
|
into the correct kill-chain order. get_coverage_by_tactic now returns the
|
||||||
|
canonical order — and all 14 tactics, zero-filled — so both consumers get
|
||||||
|
consistent results without needing their own reordering logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.services.metrics_query_service import MITRE_TACTIC_ORDER, get_coverage_by_tactic
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_by_tactic_returns_canonical_kill_chain_order(db):
|
||||||
|
# Seed a few tactics out of order to prove the response isn't alphabetical.
|
||||||
|
db.add(Technique(mitre_id="T3001", name="A", tactic="impact"))
|
||||||
|
db.add(Technique(mitre_id="T3002", name="B", tactic="execution"))
|
||||||
|
db.add(Technique(mitre_id="T3003", name="C", tactic="reconnaissance"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = get_coverage_by_tactic(db)
|
||||||
|
tactics = [r.tactic for r in rows]
|
||||||
|
|
||||||
|
canonical_present = [t for t in tactics if t in MITRE_TACTIC_ORDER]
|
||||||
|
assert canonical_present == MITRE_TACTIC_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_by_tactic_zero_fills_tactics_with_no_techniques(db):
|
||||||
|
db.add(Technique(mitre_id="T3004", name="D", tactic="execution"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = get_coverage_by_tactic(db)
|
||||||
|
by_tactic = {r.tactic: r for r in rows}
|
||||||
|
|
||||||
|
assert "persistence" in by_tactic
|
||||||
|
assert by_tactic["persistence"].total == 0
|
||||||
|
assert by_tactic["execution"].total == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_by_tactic_unknown_tactic_sorts_last(db):
|
||||||
|
db.add(Technique(mitre_id="T3005", name="E", tactic=None))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = get_coverage_by_tactic(db)
|
||||||
|
tactics = [r.tactic for r in rows]
|
||||||
|
|
||||||
|
assert tactics[-1] == "unknown"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tests for the `reviewer_id` "my reviews" queue filter on GET /tests.
|
||||||
|
|
||||||
|
Block 3 fix: red_lead/blue_lead had no way to see tests specifically
|
||||||
|
assigned to THEM at the red_review/blue_review lead-review gate — the
|
||||||
|
existing `pending_validation_side` filter only covers the later, unrelated
|
||||||
|
in_review manager-validation stage and ignores assignment entirely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models.enums import TestState
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.services.test_crud_service import list_tests
|
||||||
|
|
||||||
|
|
||||||
|
def _make_test(db, owner_id, state, name, red_reviewer=None, blue_reviewer=None):
|
||||||
|
tech = Technique(mitre_id=f"T9{uuid.uuid4().hex[:6]}", name="X", tactic="execution", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
test = Test(
|
||||||
|
technique_id=tech.id,
|
||||||
|
name=name,
|
||||||
|
state=state,
|
||||||
|
created_by=owner_id,
|
||||||
|
red_reviewer_assignee=red_reviewer,
|
||||||
|
blue_reviewer_assignee=blue_reviewer,
|
||||||
|
)
|
||||||
|
db.add(test)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_id_with_explicit_state_filters_by_matching_assignee(db, red_lead_user, blue_lead_user):
|
||||||
|
mine = _make_test(db, red_lead_user.id, TestState.red_review, "Mine", red_reviewer=red_lead_user.id)
|
||||||
|
_make_test(db, red_lead_user.id, TestState.red_review, "Someone else's", red_reviewer=blue_lead_user.id)
|
||||||
|
|
||||||
|
results = list_tests(db, state="red_review", reviewer_id=red_lead_user.id)
|
||||||
|
|
||||||
|
assert [t.id for t in results] == [mine.id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_id_without_state_covers_both_red_and_blue_review(db, red_lead_user, blue_lead_user):
|
||||||
|
red = _make_test(db, red_lead_user.id, TestState.red_review, "Red review mine", red_reviewer=red_lead_user.id)
|
||||||
|
blue = _make_test(db, red_lead_user.id, TestState.blue_review, "Blue review mine", blue_reviewer=red_lead_user.id)
|
||||||
|
_make_test(db, red_lead_user.id, TestState.red_review, "Not mine", red_reviewer=blue_lead_user.id)
|
||||||
|
_make_test(db, red_lead_user.id, TestState.draft, "Unrelated state")
|
||||||
|
|
||||||
|
results = list_tests(db, reviewer_id=red_lead_user.id)
|
||||||
|
|
||||||
|
assert {t.id for t in results} == {red.id, blue.id}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_id_excludes_unassigned_tests_in_that_state(db, red_lead_user):
|
||||||
|
_make_test(db, red_lead_user.id, TestState.red_review, "Unassigned", red_reviewer=None)
|
||||||
|
|
||||||
|
results = list_tests(db, state="red_review", reviewer_id=red_lead_user.id)
|
||||||
|
|
||||||
|
assert results == []
|
||||||
@@ -60,7 +60,11 @@ def _make_user(role: str = "red_tech") -> MagicMock:
|
|||||||
|
|
||||||
|
|
||||||
def _make_db() -> MagicMock:
|
def _make_db() -> MagicMock:
|
||||||
return MagicMock()
|
db = MagicMock()
|
||||||
|
# start_execution's campaign-schedule lookup (.query(Campaign).join(...).filter(...).first())
|
||||||
|
# defaults to "no campaign found" so unrelated tests aren't gated by a MagicMock date.
|
||||||
|
db.query.return_value.join.return_value.filter.return_value.first.return_value = None
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tests for the `existing_test_count` field on TestTemplateSummary.
|
||||||
|
|
||||||
|
Block 3 fix: the Test Catalog page had no way to know a technique already
|
||||||
|
had one or more tests, so operators could unknowingly instantiate a
|
||||||
|
duplicate from a template. list_templates now attaches a per-technique
|
||||||
|
test count so the frontend can show a warning badge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.enums import TestState
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.services.test_template_service import list_templates
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_test_count_reflects_tests_for_that_technique(db, red_lead_user):
|
||||||
|
tech = Technique(mitre_id="T1055", name="Process Injection", tactic="defense-evasion", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
db.add(TestTemplate(mitre_technique_id="T1055", name="Injection template", source="custom"))
|
||||||
|
db.add(Test(technique_id=tech.id, name="Existing test 1", state=TestState.draft, created_by=red_lead_user.id))
|
||||||
|
db.add(Test(technique_id=tech.id, name="Existing test 2", state=TestState.validated, created_by=red_lead_user.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
templates = list_templates(db, mitre_technique_id="T1055")
|
||||||
|
|
||||||
|
assert len(templates) == 1
|
||||||
|
assert templates[0].existing_test_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_test_count_zero_when_no_tests(db):
|
||||||
|
tech = Technique(mitre_id="T1056", name="Input Capture", tactic="collection", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
db.add(TestTemplate(mitre_technique_id="T1056", name="Capture template", source="custom"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
templates = list_templates(db, mitre_technique_id="T1056")
|
||||||
|
|
||||||
|
assert len(templates) == 1
|
||||||
|
assert templates[0].existing_test_count == 0
|
||||||
@@ -153,7 +153,11 @@ def _make_user(role: str = "red_tech") -> MagicMock:
|
|||||||
|
|
||||||
|
|
||||||
def _make_db() -> MagicMock:
|
def _make_db() -> MagicMock:
|
||||||
return MagicMock()
|
db = MagicMock()
|
||||||
|
# start_execution's campaign-schedule lookup (.query(Campaign).join(...).filter(...).first())
|
||||||
|
# defaults to "no campaign found" so unrelated tests aren't gated by a MagicMock date.
|
||||||
|
db.query.return_value.join.return_value.filter.return_value.first.return_value = None
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export interface TestListFilters {
|
|||||||
platform?: string;
|
platform?: string;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
pending_validation_side?: "red" | "blue";
|
pending_validation_side?: "red" | "blue";
|
||||||
|
/** "My reviews" queue — tests assigned to this reviewer at the red_review/blue_review gate. */
|
||||||
|
reviewer_id?: string;
|
||||||
not_in_any_campaign?: boolean;
|
not_in_any_campaign?: boolean;
|
||||||
assigned_to_me?: boolean;
|
assigned_to_me?: boolean;
|
||||||
unassigned_red?: boolean;
|
unassigned_red?: boolean;
|
||||||
@@ -88,6 +90,7 @@ export async function getTests(filters?: TestListFilters): Promise<Test[]> {
|
|||||||
if (filters?.platform) params.append("platform", filters.platform);
|
if (filters?.platform) params.append("platform", filters.platform);
|
||||||
if (filters?.created_by) params.append("created_by", filters.created_by);
|
if (filters?.created_by) params.append("created_by", filters.created_by);
|
||||||
if (filters?.pending_validation_side) params.append("pending_validation_side", filters.pending_validation_side);
|
if (filters?.pending_validation_side) params.append("pending_validation_side", filters.pending_validation_side);
|
||||||
|
if (filters?.reviewer_id) params.append("reviewer_id", filters.reviewer_id);
|
||||||
if (filters?.not_in_any_campaign) params.append("not_in_any_campaign", "true");
|
if (filters?.not_in_any_campaign) params.append("not_in_any_campaign", "true");
|
||||||
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
|
if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
|
||||||
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
|
if (filters?.limit !== undefined) params.append("limit", String(filters.limit));
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const BADGE_LABELS: Record<TechniqueStatus, string> = {
|
|||||||
|
|
||||||
interface TooltipLine { label: string; text: string }
|
interface TooltipLine { label: string; text: string }
|
||||||
|
|
||||||
const TOOLTIPS: Record<TechniqueStatus, { heading: string; lines: TooltipLine[] }> = {
|
export const TOOLTIPS: Record<TechniqueStatus, { heading: string; lines: TooltipLine[] }> = {
|
||||||
validated: {
|
validated: {
|
||||||
heading: "✅ Validated",
|
heading: "✅ Validated",
|
||||||
lines: [
|
lines: [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { Fragment, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ChevronDown, ChevronRight, Search, Filter, ExternalLink, Info, ShieldAlert } from "lucide-react";
|
import { ChevronDown, ChevronRight, Search, Filter, ExternalLink, Info, ShieldAlert } from "lucide-react";
|
||||||
import type { ComplianceControlStatus } from "../../api/compliance";
|
import type { ComplianceControlStatus } from "../../api/compliance";
|
||||||
@@ -139,10 +139,9 @@ export default function ControlsTable({ controls }: ControlsTableProps) {
|
|||||||
const statusStyle = STATUS_COLORS[control.status] || STATUS_COLORS.not_evaluated;
|
const statusStyle = STATUS_COLORS[control.status] || STATUS_COLORS.not_evaluated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Fragment key={control.control_id}>
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<tr
|
<tr
|
||||||
key={control.control_id}
|
|
||||||
className={`cursor-pointer transition-colors hover:bg-gray-800/40 ${
|
className={`cursor-pointer transition-colors hover:bg-gray-800/40 ${
|
||||||
isExpanded ? "bg-gray-800/20" : ""
|
isExpanded ? "bg-gray-800/20" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -256,7 +255,7 @@ export default function ControlsTable({ controls }: ControlsTableProps) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
|
import type { TechniqueStatus } from "../../types/models";
|
||||||
|
import { TOOLTIPS } from "../StatusBadge";
|
||||||
|
|
||||||
interface HeatmapLegendProps {
|
interface HeatmapLegendProps {
|
||||||
layerType: "coverage" | "threat-actor" | "detection-rules" | "campaign";
|
layerType: "coverage" | "threat-actor" | "detection-rules" | "campaign";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tooltipText(status: TechniqueStatus): string {
|
||||||
|
const t = TOOLTIPS[status];
|
||||||
|
return `${t.heading} — ${t.lines.map((l) => `${l.label}: ${l.text}`).join(" ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
const LEGENDS: Record<
|
const LEGENDS: Record<
|
||||||
string,
|
string,
|
||||||
{ label: string; colors: { color: string; label: string }[] }
|
{ label: string; colors: { color: string; label: string; status?: TechniqueStatus }[] }
|
||||||
> = {
|
> = {
|
||||||
coverage: {
|
coverage: {
|
||||||
label: "Coverage Status",
|
label: "Coverage Status",
|
||||||
colors: [
|
colors: [
|
||||||
{ color: "#d3d3d3", label: "Not Evaluated (0)" },
|
{ color: "#d3d3d3", label: "Not Evaluated (0)", status: "not_evaluated" },
|
||||||
{ color: "#ff6666", label: "Not Covered (10)" },
|
{ color: "#ff6666", label: "Not Covered (10)", status: "not_covered" },
|
||||||
{ color: "#ff9933", label: "In Progress (30)" },
|
{ color: "#ff9933", label: "In Progress (30)", status: "in_progress" },
|
||||||
{ color: "#ffff66", label: "Partial (60)" },
|
{ color: "#ffff66", label: "Partial (60)", status: "partial" },
|
||||||
{ color: "#66ff66", label: "Validated (100)" },
|
{ color: "#66ff66", label: "Validated (100)", status: "validated" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"threat-actor": {
|
"threat-actor": {
|
||||||
@@ -66,7 +74,11 @@ export default function HeatmapLegend({ layerType }: HeatmapLegendProps) {
|
|||||||
|
|
||||||
{/* Individual labels */}
|
{/* Individual labels */}
|
||||||
{legend.colors.map((item) => (
|
{legend.colors.map((item) => (
|
||||||
<div key={item.label} className="flex items-center gap-1.5">
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
title={item.status ? tooltipText(item.status) : undefined}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="h-3 w-3 rounded border border-gray-700"
|
className="h-3 w-3 rounded border border-gray-700"
|
||||||
style={{ backgroundColor: item.color }}
|
style={{ backgroundColor: item.color }}
|
||||||
@@ -74,6 +86,15 @@ export default function HeatmapLegend({ layerType }: HeatmapLegendProps) {
|
|||||||
<span className="text-xs text-gray-400">{item.label}</span>
|
<span className="text-xs text-gray-400">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Review Required — an overlay indicator (amber ring + ⚠️), not a
|
||||||
|
distinct score tier, so it's shown separately from the gradient. */}
|
||||||
|
{layerType === "coverage" && (
|
||||||
|
<div className="flex items-center gap-1.5" title={tooltipText("review_required")}>
|
||||||
|
<div className="h-3 w-3 rounded border border-gray-700 ring-1 ring-amber-400/60" />
|
||||||
|
<span className="text-xs text-gray-400">⚠️ Review Required (any status)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export default function CompliancePage() {
|
|||||||
|
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
{/* Gauge */}
|
{/* Gauge */}
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4 flex flex-col items-center justify-center">
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4 flex flex-col items-center justify-center">
|
||||||
<ComplianceGauge percentage={summary.compliance_percentage} size="md" />
|
<ComplianceGauge percentage={summary.compliance_percentage} size="md" />
|
||||||
|
|||||||
@@ -260,8 +260,11 @@ export default function ExecutiveDashboardPage() {
|
|||||||
})
|
})
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
// Official MITRE ATT&CK tactic order (slug → display label)
|
// Display labels for MITRE ATT&CK tactic slugs. Ordering itself comes
|
||||||
const MITRE_TACTIC_ORDER: Record<string, string> = {
|
// pre-sorted from the backend (metrics_query_service.MITRE_TACTIC_ORDER),
|
||||||
|
// shared with the regular Dashboard's TacticCoverageChart so both views
|
||||||
|
// agree on tactic order without each reimplementing it.
|
||||||
|
const TACTIC_LABELS: Record<string, string> = {
|
||||||
"reconnaissance": "Reconnaissance",
|
"reconnaissance": "Reconnaissance",
|
||||||
"resource-development": "Resource Development",
|
"resource-development": "Resource Development",
|
||||||
"initial-access": "Initial Access",
|
"initial-access": "Initial Access",
|
||||||
@@ -278,22 +281,17 @@ export default function ExecutiveDashboardPage() {
|
|||||||
"impact": "Impact",
|
"impact": "Impact",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tacticDataRaw = (tacticCoverage || []).map((tc) => {
|
const tacticData = (tacticCoverage || []).map((tc) => {
|
||||||
const slug = tc.tactic.toLowerCase();
|
const slug = tc.tactic.toLowerCase();
|
||||||
const label = MITRE_TACTIC_ORDER[slug] ||
|
const label = TACTIC_LABELS[slug] ||
|
||||||
tc.tactic.split("-").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
tc.tactic.split("-").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||||
const order = Object.keys(MITRE_TACTIC_ORDER).indexOf(slug);
|
|
||||||
return {
|
return {
|
||||||
name: label,
|
name: label,
|
||||||
slug,
|
slug,
|
||||||
order: order === -1 ? 99 : order,
|
|
||||||
coverage: tc.total > 0 ? Math.round(((tc.validated + tc.partial) / tc.total) * 100) : 0,
|
coverage: tc.total > 0 ? Math.round(((tc.validated + tc.partial) / tc.total) * 100) : 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by official MITRE order; include any unknown tactics at end
|
|
||||||
const tacticData = tacticDataRaw.sort((a, b) => a.order - b.order);
|
|
||||||
|
|
||||||
const getBarColor = (coverage: number) => {
|
const getBarColor = (coverage: number) => {
|
||||||
if (coverage < 30) return "#ef4444";
|
if (coverage < 30) return "#ef4444";
|
||||||
if (coverage < 50) return "#f97316";
|
if (coverage < 50) return "#f97316";
|
||||||
@@ -313,6 +311,7 @@ export default function ExecutiveDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time range filter */}
|
{/* Time range filter */}
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
<div className="flex items-center gap-1.5 rounded-xl border border-gray-800 bg-gray-900 p-1">
|
<div className="flex items-center gap-1.5 rounded-xl border border-gray-800 bg-gray-900 p-1">
|
||||||
{TIME_RANGE_OPTIONS.map((opt) => (
|
{TIME_RANGE_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -328,6 +327,12 @@ export default function ExecutiveDashboardPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{timeRange !== "all" && (
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
Applies to Team Performance & Operational KPIs only — other sections always show all-time data.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 1: Score Card + Sub-scores */}
|
{/* Section 1: Score Card + Sub-scores */}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
X,
|
X,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTemplates } from "../api/test-templates";
|
import { getTemplates } from "../api/test-templates";
|
||||||
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
||||||
@@ -349,6 +350,17 @@ function TemplateCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Duplicate-test warning */}
|
||||||
|
{template.existing_test_count > 0 && (
|
||||||
|
<div
|
||||||
|
className="mt-3 flex items-center gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 px-2.5 py-1.5 text-xs text-amber-400"
|
||||||
|
title={`${template.existing_test_count} existing test(s) already cover ${template.mitre_technique_id}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{template.existing_test_count} existing test{template.existing_test_count !== 1 ? "s" : ""} for this technique
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,8 @@ export default function TestsPage() {
|
|||||||
const [platformFilter, setPlatformFilter] = useState("");
|
const [platformFilter, setPlatformFilter] = useState("");
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [showMyTasks, setShowMyTasks] = useState(false);
|
const [showMyTasks, setShowMyTasks] = useState(false);
|
||||||
|
const [showMyReviews, setShowMyReviews] = useState(false);
|
||||||
|
const isReviewLead = user?.role === "red_lead" || user?.role === "blue_lead";
|
||||||
|
|
||||||
// ── Sort state ────────────────────────────────────────────────────
|
// ── Sort state ────────────────────────────────────────────────────
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
||||||
@@ -167,7 +169,13 @@ export default function TestsPage() {
|
|||||||
const filters = useMemo<TestListFilters>(() => {
|
const filters = useMemo<TestListFilters>(() => {
|
||||||
const f: TestListFilters = { limit: 200 };
|
const f: TestListFilters = { limit: 200 };
|
||||||
|
|
||||||
if (showMyTasks && user) {
|
if (showMyReviews && user && isReviewLead) {
|
||||||
|
// "My reviews" — tests assigned to ME at the red_review/blue_review
|
||||||
|
// gate. Distinct from "My Tasks" below, which covers the later
|
||||||
|
// in_review manager-validation stage regardless of assignment.
|
||||||
|
f.state = user.role === "red_lead" ? "red_review" : "blue_review";
|
||||||
|
f.reviewer_id = user.id;
|
||||||
|
} else if (showMyTasks && user) {
|
||||||
switch (user.role) {
|
switch (user.role) {
|
||||||
case "red_tech":
|
case "red_tech":
|
||||||
f.created_by = user.id;
|
f.created_by = user.id;
|
||||||
@@ -192,7 +200,7 @@ export default function TestsPage() {
|
|||||||
|
|
||||||
if (platformFilter) f.platform = platformFilter;
|
if (platformFilter) f.platform = platformFilter;
|
||||||
return f;
|
return f;
|
||||||
}, [stateFilter, platformFilter, showMyTasks, user]);
|
}, [stateFilter, platformFilter, showMyTasks, showMyReviews, isReviewLead, user]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: allTests,
|
data: allTests,
|
||||||
@@ -497,7 +505,10 @@ export default function TestsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowMyTasks(!showMyTasks);
|
setShowMyTasks(!showMyTasks);
|
||||||
if (!showMyTasks) setStateFilter("");
|
if (!showMyTasks) {
|
||||||
|
setShowMyReviews(false);
|
||||||
|
setStateFilter("");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
showMyTasks
|
showMyTasks
|
||||||
@@ -510,6 +521,29 @@ export default function TestsPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* My reviews toggle — red_lead/blue_lead only, the red_review/blue_review
|
||||||
|
lead-review gate assignment queue (distinct from "My Tasks" above,
|
||||||
|
which covers the later in_review manager-validation stage). */}
|
||||||
|
{isReviewLead && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowMyReviews(!showMyReviews);
|
||||||
|
if (!showMyReviews) {
|
||||||
|
setShowMyTasks(false);
|
||||||
|
setStateFilter("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
showMyReviews
|
||||||
|
? "border-cyan-500/50 bg-cyan-500/20 text-cyan-400"
|
||||||
|
: "border-gray-700 bg-gray-800 text-gray-300 hover:border-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
My Reviews
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* State filter */}
|
{/* State filter */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Filter className="h-4 w-4 text-gray-500" />
|
<Filter className="h-4 w-4 text-gray-500" />
|
||||||
@@ -556,13 +590,14 @@ export default function TestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clear filters */}
|
{/* Clear filters */}
|
||||||
{(stateFilter || platformFilter || searchText || showMyTasks) && (
|
{(stateFilter || platformFilter || searchText || showMyTasks || showMyReviews) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStateFilter("");
|
setStateFilter("");
|
||||||
setPlatformFilter("");
|
setPlatformFilter("");
|
||||||
setSearchText("");
|
setSearchText("");
|
||||||
setShowMyTasks(false);
|
setShowMyTasks(false);
|
||||||
|
setShowMyReviews(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-gray-400 hover:text-white transition-colors"
|
className="text-xs text-gray-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
@@ -572,9 +607,14 @@ export default function TestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active filter summary */}
|
{/* Active filter summary */}
|
||||||
{(stateFilter || showMyTasks) && (
|
{(stateFilter || showMyTasks || showMyReviews) && (
|
||||||
<div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
|
<div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
|
||||||
<span>Showing:</span>
|
<span>Showing:</span>
|
||||||
|
{showMyReviews && (
|
||||||
|
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
|
||||||
|
My Reviews
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{showMyTasks && (
|
{showMyTasks && (
|
||||||
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
|
<span className="rounded-full border border-cyan-500/30 bg-cyan-500/10 px-2 py-0.5 text-cyan-400">
|
||||||
{myTasksLabel}
|
{myTasksLabel}
|
||||||
@@ -629,11 +669,11 @@ export default function TestsPage() {
|
|||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-white">
|
<h2 className="text-lg font-semibold text-white">
|
||||||
{showMyTasks ? myTasksLabel : "All Tests"}
|
{showMyReviews ? "My Reviews" : showMyTasks ? myTasksLabel : "All Tests"}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-sm text-gray-400">{tests.length} tests</span>
|
<span className="text-sm text-gray-400">{tests.length} tests</span>
|
||||||
</div>
|
</div>
|
||||||
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
|
<TestTable tests={tests} columns={mainTableColumns} sortKey={sortKey} sortDir={sortDir} handleSort={handleSort} navigate={navigate} formatDate={formatDate} emptyMessage={showMyReviews ? "No tests currently assigned to you for review." : showMyTasks ? "No pending tasks for your role." : "No tests found matching your filters."} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ export interface TestTemplateSummary {
|
|||||||
source: string;
|
source: string;
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
severity: string | null;
|
severity: string | null;
|
||||||
|
/** Number of existing tests for this template's technique — used to warn before creating a duplicate. */
|
||||||
|
existing_test_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Timeline ───────────────────────────────────────────────────────
|
// ── Timeline ───────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user