fix(m5): post-review pass — AND filter, advisory lock, N+1, item caps, mutation cache
Spec-reviewer + code-reviewer findings applied:
Must-fix
- Filter combinator AND-semantics: tactic+technique+subtechnique now intersect
(one IN subquery per facet) instead of being pooled into one OR. Reviewers
flagged both the wrong default semantics and the theoretical UUID-collision
risk of pooling tactic/technique/sub UUIDs into a shared list across
three columns.
- Front-end mutation cache hygiene: updateMeta + setTests both
`onSettled: invalidate` so a partial failure leaves the cache consistent.
Should-fix
- Per-scenario pg_advisory_xact_lock on set_scenario_tests — serialises
concurrent reorders, mirrors M4 /mitre/sync pattern.
- Backend/front consistency on duplicate tests in a scenario: the
UNIQUE(scenario_id, position) constraint already allows the same
test_template multiple times (chained ops), so the catalogue picker no
longer excludes already-picked items.
Nice-to-have
- N+1 eradicated in test_template view rendering: _to_views_batch
builds {uuid → MitreRow} maps in 3 queries up-front; list endpoint
now issues 4 queries total regardless of list size.
- Wire-level item length caps on tags (64) and expected_iocs (255)
via Annotated[str, StringConstraints(...)] — returns 400 instead of
bubbling up StringDataRightTruncation.
- 4 new pytest covering the AND-filter, extra="forbid" rejection,
empty mitre_tags clearing, and the 65-char tag cap. Total now
81 pytest + 38 e2e pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -490,3 +490,78 @@ def test_scenario_perm_required(client, admin_token):
|
||||
_, eve_token = _bootstrap_user_without_perms(client, admin_token, "scn-eve")
|
||||
r = client.get("/api/v1/scenario-templates", headers=_bearer(eve_token))
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# === Post-review fixes ======================================================
|
||||
|
||||
|
||||
def test_list_filter_combines_facets_with_and_semantics(client, admin_token):
|
||||
"""A template tagged only `TA0002` is NOT in `?tactic=TA0002&technique=T1059`.
|
||||
|
||||
Pre-fix the OR-combined query would return it. AND-combined semantics
|
||||
(one IN subquery per facet) restrict the set to templates matching ALL
|
||||
requested facets.
|
||||
"""
|
||||
a = _make_test(
|
||||
client,
|
||||
admin_token,
|
||||
name="and-tactic-only",
|
||||
mitre_tags=[{"kind": "tactic", "external_id": "TA0002"}],
|
||||
)
|
||||
b = _make_test(
|
||||
client,
|
||||
admin_token,
|
||||
name="and-both-tags",
|
||||
mitre_tags=[
|
||||
{"kind": "tactic", "external_id": "TA0002"},
|
||||
{"kind": "technique", "external_id": "T1059"},
|
||||
],
|
||||
)
|
||||
r = client.get(
|
||||
"/api/v1/test-templates?tactic=TA0002&technique=T1059",
|
||||
headers=_bearer(admin_token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
names = [it["name"] for it in r.get_json()["items"]]
|
||||
assert "and-both-tags" in names
|
||||
assert "and-tactic-only" not in names
|
||||
_ = a, b # silence unused vars from linter
|
||||
|
||||
|
||||
def test_create_test_template_rejects_extra_fields(client, admin_token):
|
||||
"""`model_config = {"extra": "forbid"}` — unknown fields must 400."""
|
||||
r = client.post(
|
||||
"/api/v1/test-templates",
|
||||
headers=_bearer(admin_token),
|
||||
json={"name": "extra-test", "rogue_field": "smuggled"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_update_test_template_explicit_empty_mitre_clears(client, admin_token):
|
||||
"""`PUT { mitre_tags: [] }` is an explicit clear, not a no-op."""
|
||||
body = _make_test(
|
||||
client,
|
||||
admin_token,
|
||||
name="clear-tags",
|
||||
mitre_tags=[{"kind": "technique", "external_id": "T1059"}],
|
||||
)
|
||||
assert len(body["mitre_tags"]) == 1
|
||||
r = client.put(
|
||||
f"/api/v1/test-templates/{body['id']}",
|
||||
headers=_bearer(admin_token),
|
||||
json={"mitre_tags": []},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["mitre_tags"] == []
|
||||
|
||||
|
||||
def test_tag_item_length_capped_at_64(client, admin_token):
|
||||
"""Individual `tags` items must be ≤ 64 chars at the wire layer."""
|
||||
long_tag = "x" * 65
|
||||
r = client.post(
|
||||
"/api/v1/test-templates",
|
||||
headers=_bearer(admin_token),
|
||||
json={"name": "long-tag", "tags": [long_tag]},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user