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:
Knacky
2026-05-12 20:05:00 +02:00
parent a559823386
commit ce4bd40551
7 changed files with 267 additions and 56 deletions

View File

@@ -145,14 +145,19 @@ export function AdminScenariosPage() {
onError: (e) => setError(humanError(e)),
});
// updateMeta and setTests both invalidate on success so a partial failure
// (metadata saved, reorder rejected) still leaves the cache consistent
// with whichever step landed.
const updateMeta = useMutation({
mutationFn: ({ id, name, description }: { id: string; name: string; description: string | null }) =>
apiPatch<ScenarioTemplate>(`/scenario-templates/${id}`, { name, description }),
onSettled: () => qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] }),
});
const setTests = useMutation({
mutationFn: ({ id, test_template_ids }: { id: string; test_template_ids: string[] }) =>
apiPut<ScenarioTemplate>(`/scenario-templates/${id}/tests`, { test_template_ids }),
onSettled: () => qc.invalidateQueries({ queryKey: ['templates', 'scenarios'] }),
});
const remove = useMutation({
@@ -223,12 +228,15 @@ export function AdminScenariosPage() {
return map;
}, [catalogue.data]);
// Tests available for inclusion (excluding already-picked + soft-deleted).
// Tests offered for inclusion. The same test_template MAY appear multiple
// times in a scenario (chained operations are a real purple-team pattern,
// cf. `scenario_template_tests` UNIQUE on `(scenario_id, position)`, not
// on `test_template_id`). So we do NOT exclude already-picked items —
// only soft-deleted ones, which the backend would reject.
const availableTests = useMemo<TestTemplate[]>(() => {
if (!catalogue.data) return [];
const picked = new Set(form.test_ids);
return catalogue.data.items.filter((t) => !picked.has(t.id) && !t.deleted_at);
}, [catalogue.data, form.test_ids]);
return catalogue.data.items.filter((t) => !t.deleted_at);
}, [catalogue.data]);
return (
<>