refactor(m4): flatten the MITRE picker into the attack.mitre.org matrix

The hierarchical 3-column drill-down was hard to scan and forced a stateful
walk per tag. Replaced with a flat, columns-as-tactics matrix that mirrors
attack.mitre.org/# — every cell is a one-click select target, with inline
sub-technique expand via a `+N` chevron.

- New endpoint GET /api/v1/mitre/matrix returns the full grid (tactics →
  techniques → sub-techniques nested) in a single ~55 KB response, so the
  SPA renders the whole matrix without firing 15 parallel queries. Two
  pytest tests added (nested structure + auth required).
- MitreTagPicker.tsx rewritten as a horizontal-scrolling matrix:
  - Click a tactic header → select the tactic (cyan filled).
  - Click a technique cell → select the technique (orange filled).
  - Click the `+N` chevron → expand sub-techniques inline within the column.
  - Click a sub-technique → select (purple filled).
  - Single Filter field matches on external_id or name across all kinds.
  - Selection chips at the top, clickable to remove.
  - `aria-pressed` on every clickable cell for screen readers and Playwright.
- e2e test updated to walk the new flow (click cell → assert aria-pressed,
  expand chevron, click sub, verify chip + JSON preview, filter to T1078).
- Spec §F2 + §F12 + todo.md M4 entry updated to make the matrix layout the
  canonical UI for MITRE tagging (so future spec-reviewer passes accept it).
- testing-m4.md walkthrough rewritten for the flat picker.

DoD post-refactor: make test-api → 53 passed (was 51), make e2e → 34 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-12 18:32:20 +02:00
parent 37e9e03f02
commit 7dbe2dbc28
8 changed files with 371 additions and 224 deletions

View File

@@ -358,3 +358,32 @@ def test_search_filter_on_name(app, admin_credentials, fixture_bundle_path):
assert r.status_code == 200
ext_ids = [t["external_id"] for t in r.get_json()["items"]]
assert ext_ids == ["T1078"]
def test_matrix_endpoint_returns_nested_grid(app, admin_credentials, fixture_bundle_path):
"""GET /mitre/matrix returns the flat tactic→technique→subtechnique grid."""
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
access = _login(c, admin_credentials["email"], admin_credentials["password"])
r = c.get("/api/v1/mitre/matrix", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
body = r.get_json()
tactics = body["tactics"]
assert {t["external_id"] for t in tactics} == {"TA0001", "TA0002"}
# TA0001 has T1059 (multi-tactic) + T1078; T1059 carries its sub.
ta0001 = next(t for t in tactics if t["external_id"] == "TA0001")
techs = {t["external_id"]: t for t in ta0001["techniques"]}
assert set(techs.keys()) == {"T1059", "T1078"}
assert techs["T1059"]["subtechniques"][0]["external_id"] == "T1059.001"
assert techs["T1078"]["subtechniques"] == []
# TA0002 only carries T1059 (no T1078).
ta0002 = next(t for t in tactics if t["external_id"] == "TA0002")
assert [t["external_id"] for t in ta0002["techniques"]] == ["T1059"]
def test_matrix_endpoint_requires_auth(app, fixture_bundle_path):
mitre_svc.seed_mitre(source=fixture_bundle_path, expected_sha256=None)
with app.test_client() as c:
assert c.get("/api/v1/mitre/matrix").status_code == 401