fix(m6): post-review pass — cache prefix, snapshot lock, perm-before-parse, LIKE escape

Addresses spec-reviewer + code-reviewer feedback on the M6 bundle:

Critical:
- frontend/src/lib/missions.ts: add `listPrefix()` so TanStack invalidation
  catches every filtered list variant; the previous `list()` returned
  `['missions','list',{}]` and only matched the exact empty-filter cache,
  leaving filtered tables stale after create/transition/delete.
- backend/app/services/missions.py: acquire the same per-scenario
  `pg_advisory_xact_lock` key used by `set_scenario_tests` before
  snapshotting; without it a concurrent M5 reorder could freeze a torn
  snapshot under READ COMMITTED. Sorted by key to avoid deadlocks with
  another snapshotter.

Important:
- backend/app/api/missions.py: `@require_perm("mission.update",
  "mission.archive")` on the transition endpoint so users without either
  perm get 403 before the body is parsed (no shape leak via 400).
- backend/app/services/missions.py: escape `%` / `_` / `\` in user-typed
  `q` / `client` LIKE search; users can no longer trigger wildcard
  semantics by typing literal `%`. Added `escape='\\'` arg on every .like().
- backend/app/services/missions.py: filter `MissionTest.deleted_at` and
  `MissionScenario.deleted_at` in the list-item and detail counts so M7+
  soft-deletes don't drift the totals silently.

Nits:
- backend/app/api/users.py: order `/users/roster` by email for stable
  rendering + deterministic e2e selectors.
- frontend/src/pages/MissionDetailPage.tsx: distinct accent per
  transition target (cyan/orange/green/teal) matching the status legend.
- e2e/tests/m6-missions.spec.ts: switch fragile `getByRole(name=/In
  Progress/i)` to the stable `mission-transition-in_progress` data-testid.

New tests:
- test_create_mission_rejects_soft_deleted_scenario
- test_transition_perm_gate_runs_before_payload_parse
- test_search_treats_wildcards_as_literals

Suite: 106 pytest passing (was 103), 43 Playwright passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-13 15:14:57 +02:00
parent 00b7557e30
commit e1b51db25f
9 changed files with 149 additions and 18 deletions

View File

@@ -4,6 +4,17 @@ All notable changes to this project will be documented here. Format: [Keep a Cha
## [Unreleased] ## [Unreleased]
### Fixed (post-M6 review pass — spec-reviewer + code-reviewer)
- **SPA cache invalidation only refreshed the empty-filter list** (`frontend/src/lib/missions.ts:136`): `missionKeys.list()` returns `['missions','list',{}]`. TanStack v5's `invalidateQueries({queryKey})` is prefix-based, but `{}` is treated as an atomic final element — so create / transition / delete called with that key only invalidated the *exact* empty-filter list, leaving any filtered variant stale until manual refetch. Added `missionKeys.listPrefix()` returning `['missions','list']` and switched all three mutation `onSuccess` paths to it.
- **Snapshot lacked the per-scenario advisory lock** (`backend/app/services/missions.py:467`): a concurrent `PUT /scenario-templates/{id}/tests` (M5 reorder, which deletes-then-reinserts join rows) running while `_snapshot_scenarios` walked `sc.tests` could freeze a torn snapshot — `selectinload` re-queries under READ COMMITTED so a partial view was possible. Added `_lock_scenario_ids_for_snapshot` that acquires the same `pg_advisory_xact_lock` key used by `set_scenario_tests` (blake2b digest of the scenario UUID, sorted to avoid deadlocks). Snapshot and reorder now serialise per scenario.
- **Transition endpoint leaked its body shape via 400 before the perm gate** (`backend/app/api/missions.py:441`): a user without `mission.update` or `mission.archive` POSTing `{"status":"x"}` got a Pydantic 400 instead of 403. Added `@require_perm("mission.update", "mission.archive")` so the gate fires before the parse; the inner refinement still enforces the per-target perm. Test `test_transition_perm_gate_runs_before_payload_parse`.
- **LIKE wildcards in user-typed search were honoured as SQL wildcards** (`backend/app/services/missions.py:632,637`): `?q=%` matched every mission. Added `_escape_like` that pre-escapes `%`, `_`, `\` and a matching `escape='\\'` argument on every `.like(...)` call. Test `test_search_treats_wildcards_as_literals`.
- **Counts ignored soft-deleted mission children** (`backend/app/services/missions.py:587,597`): `tests_count` and the detail view summed `len(sc.tests)` without filtering `MissionTest.deleted_at`. Harmless today (M6 doesn't soft-delete mission tests), but would drift silently once M7+ surfaces `state=skipped/blocked`. Added the filter in both `_to_list_item` and `_scenario_views`.
- **`/users/roster` was unordered** (`backend/app/api/users.py:73`): the wizard's member list shuffled rows on every refetch. Sorted by `email` for predictable rendering + stable e2e selectors.
- **Frontend transition button accent collapsed `in_progress` and `completed` into one colour** (`frontend/src/pages/MissionDetailPage.tsx:97`): both rendered cyan, so the status legend in the list didn't match the transition button. Added a `TRANSITION_BUTTON_ACCENT` map mirroring `MISSION_STATUS_ACCENT` (cyan/orange/green/teal).
- **Soft-deleted source scenario was a silent foot-gun**: `_load_scenario_templates_for_snapshot` already rejected it, but no test pinned the behaviour. Added `test_create_mission_rejects_soft_deleted_scenario` so future refactors can't regress to "freeze a tombstoned scenario into a fresh mission".
- **E2E wizard assertion used `getByRole('button', { name: /In Progress/i })`** (`e2e/tests/m6-missions.spec.ts:287`): the accessible name is `→ In Progress` and the arrow Unicode is brittle. Switched to `getByTestId('mission-transition-in_progress')`.
### Added — M6 (Missions & snapshot) ### Added — M6 (Missions & snapshot)
- **CRUD `missions`** (`app/services/missions.py` + `app/api/missions.py`): - **CRUD `missions`** (`app/services/missions.py` + `app/api/missions.py`):
- Fields: name, client_target, date_start, date_end, status (`draft/in_progress/completed/archived`), description (markdown), visibility_mode (frozen to `whitebox` in v1). - Fields: name, client_target, date_start, date_end, status (`draft/in_progress/completed/archived`), description (markdown), visibility_mode (frozen to `whitebox` in v1).

View File

@@ -430,10 +430,14 @@ def set_members(mission_id: str):
@bp.post("/<mission_id>/transition") @bp.post("/<mission_id>/transition")
@require_auth @require_auth
@require_perm("mission.update", "mission.archive")
def transition(mission_id: str): def transition(mission_id: str):
"""Status transition. Gate logic mirrors the perm seed: `mission.archive` """Status transition. The outer decorator gates the endpoint on holding
EITHER `mission.update` or `mission.archive` — so a request with neither
perm sees 403 before its body is even parsed (no shape leak via 400).
The inner refinement then enforces the per-target rule: `mission.archive`
is required when the target is `archived`; `mission.update` covers the is required when the target is `archived`; `mission.update` covers the
other transitions. Admins bypass both checks. other transitions. Admins bypass via the decorator's `is_admin` check.
""" """
mid = _parse_uuid_or_400(mission_id) mid = _parse_uuid_or_400(mission_id)
if mid is None: if mid is None:

View File

@@ -69,6 +69,7 @@ def list_roster():
""" """
q = request.args.get("q") or None q = request.args.get("q") or None
rows = users_svc.list_users(q=q, is_active=True, limit=200, offset=0)[0] rows = users_svc.list_users(q=q, is_active=True, limit=200, offset=0)[0]
# Sort by email for predictable rendering and stable e2e selectors.
return jsonify( return jsonify(
{ {
"items": [ "items": [
@@ -77,8 +78,10 @@ def list_roster():
"email": u.email, "email": u.email,
"display_name": u.display_name, "display_name": u.display_name,
} }
for u in rows for u in sorted(
if u.deleted_at is None (u for u in rows if u.deleted_at is None),
key=lambda x: x.email,
)
] ]
} }
) )

View File

@@ -13,12 +13,13 @@ allowed (perm codes); this service enforces *which mission* is visible.
from __future__ import annotations from __future__ import annotations
import hashlib
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from typing import Any, Iterable from typing import Any, Iterable
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select, text
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.db.session import session_scope from app.db.session import session_scope
@@ -216,6 +217,40 @@ def _validate_role_hint(value: str) -> str:
return value return value
def _escape_like(raw: str) -> str:
"""Escape LIKE wildcards so user-typed `%` / `_` / `\\` stay literal."""
return raw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _lock_scenario_ids_for_snapshot(s: Session, scenario_ids: list[uuid.UUID]) -> None:
"""Acquire a per-scenario `pg_advisory_xact_lock` for every source scenario
we're about to snapshot.
Why: a concurrent admin invoking `set_scenario_tests(scenario_id)` (M5)
deletes-then-reinserts the `scenario_template_tests` join rows mid-transaction.
Under READ COMMITTED, `_snapshot_scenarios` could observe a partial view
(selectinload re-queries) and freeze a torn snapshot. Sharing the same lock
key as `app.services.scenario_templates.set_scenario_tests` makes the
snapshot wait until the reorder commits (and vice versa).
The lock keys are derived deterministically from the scenario UUIDs via
blake2b (cf. lessons: `hash()` is randomised per-worker). We sort the keys
before acquiring to avoid deadlocks with another snapshotter that holds
them in a different order.
"""
if not scenario_ids:
return
keys: list[int] = []
for sid in scenario_ids:
digest = hashlib.blake2b(sid.bytes, digest_size=8).digest()
keys.append(int.from_bytes(digest, "big", signed=True))
for key in sorted(keys):
s.execute(
text("SELECT pg_advisory_xact_lock(CAST(:key AS bigint))"),
{"key": key},
)
def _is_member(s: Session, mission_id: uuid.UUID, viewer_id: uuid.UUID) -> bool: def _is_member(s: Session, mission_id: uuid.UUID, viewer_id: uuid.UUID) -> bool:
return ( return (
s.scalar( s.scalar(
@@ -390,6 +425,7 @@ def _snapshot_scenarios(
if not scenario_ids: if not scenario_ids:
return return
_lock_scenario_ids_for_snapshot(s, scenario_ids)
sc_by_id = _load_scenario_templates_for_snapshot(s, scenario_ids) sc_by_id = _load_scenario_templates_for_snapshot(s, scenario_ids)
# Collect the underlying test_template ids in stable order. # Collect the underlying test_template ids in stable order.
@@ -495,10 +531,16 @@ def _member_views(s: Session, mission: Mission) -> list[MissionMemberView]:
def _scenario_views(scenarios: list[MissionScenario]) -> list[MissionScenarioView]: def _scenario_views(scenarios: list[MissionScenario]) -> list[MissionScenarioView]:
"""Assemble scenario views. `mission_scenarios` and `mission_tests` both
carry `SoftDeleteMixin`; M6 doesn't surface soft-deletion of those rows in
any endpoint, but the filter is applied here so future deletions (M7+)
don't drift the rendered list silently."""
views: list[MissionScenarioView] = [] views: list[MissionScenarioView] = []
for sc in sorted(scenarios, key=lambda s_: s_.position): live = [sc for sc in scenarios if sc.deleted_at is None]
for sc in sorted(live, key=lambda s_: s_.position):
test_views: list[MissionTestView] = [] test_views: list[MissionTestView] = []
for t in sorted(sc.tests, key=lambda t_: t_.position): live_tests = [t for t in sc.tests if t.deleted_at is None]
for t in sorted(live_tests, key=lambda t_: t_.position):
tag_views = [ tag_views = [
MissionMitreTagView( MissionMitreTagView(
kind=tag.mitre_kind, kind=tag.mitre_kind,
@@ -571,8 +613,12 @@ def _to_detail_view(s: Session, m: Mission) -> MissionView:
def _to_list_item(m: Mission) -> MissionListItemView: def _to_list_item(m: Mission) -> MissionListItemView:
# Cheap counts via the loaded relationships (selectinloaded by the caller). # Cheap counts via the loaded relationships (selectinloaded by the caller).
# We filter soft-deleted children consistently with `_scenario_views` so
# the list and the detail page agree.
live_scenarios = [sc for sc in m.scenarios if sc.deleted_at is None] live_scenarios = [sc for sc in m.scenarios if sc.deleted_at is None]
tests_count = sum(len(sc.tests) for sc in live_scenarios) tests_count = sum(
len([t for t in sc.tests if t.deleted_at is None]) for sc in live_scenarios
)
return MissionListItemView( return MissionListItemView(
id=m.id, id=m.id,
name=m.name, name=m.name,
@@ -629,15 +675,15 @@ def list_missions(
stmt = stmt.where(Mission.status == status) stmt = stmt.where(Mission.status == status)
count_stmt = count_stmt.where(Mission.status == status) count_stmt = count_stmt.where(Mission.status == status)
if client: if client:
like = f"%{client.lower()}%" like = f"%{_escape_like(client.lower())}%"
cond = func.lower(Mission.client_target).like(like) cond = func.lower(Mission.client_target).like(like, escape="\\")
stmt = stmt.where(cond) stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond) count_stmt = count_stmt.where(cond)
if q: if q:
like = f"%{q.lower()}%" like = f"%{_escape_like(q.lower())}%"
cond = or_( cond = or_(
func.lower(Mission.name).like(like), func.lower(Mission.name).like(like, escape="\\"),
func.lower(Mission.description_md).like(like), func.lower(Mission.description_md).like(like, escape="\\"),
) )
stmt = stmt.where(cond) stmt = stmt.where(cond)
count_stmt = count_stmt.where(cond) count_stmt = count_stmt.where(cond)

View File

@@ -368,6 +368,32 @@ def test_create_mission_rejects_unknown_scenario(client, admin_token):
assert r.get_json()["error"] == "unknown_scenario_template" assert r.get_json()["error"] == "unknown_scenario_template"
def test_create_mission_rejects_soft_deleted_scenario(client, admin_token):
# Build a scenario, soft-delete it, then try to snapshot — must 400 with
# `unknown_scenario_template` so we don't silently freeze a tombstoned
# template into a new mission.
t = _make_test_template(client, admin_token, name="sd-rejection-t")
sc = client.post(
"/api/v1/scenario-templates",
headers=_bearer(admin_token),
json={"name": "sd-rejection-sc", "test_template_ids": [t["id"]]},
).get_json()
del_r = client.delete(
f"/api/v1/scenario-templates/{sc['id']}", headers=_bearer(admin_token)
)
assert del_r.status_code == 200
r = client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={
"name": "sd-rejection-mission",
"scenario_template_ids": [sc["id"]],
},
)
assert r.status_code == 400
assert r.get_json()["error"] == "unknown_scenario_template"
def test_create_mission_validates_dates(client, admin_token): def test_create_mission_validates_dates(client, admin_token):
r = client.post( r = client.post(
"/api/v1/missions", "/api/v1/missions",
@@ -459,6 +485,35 @@ def test_list_requires_mission_read_perm(client, noperm_user):
assert r.status_code == 403 assert r.status_code == 403
def test_transition_perm_gate_runs_before_payload_parse(client, blue_user):
"""A user without `mission.update` or `mission.archive` should see 403,
not 400, even when posting a malformed body — otherwise the endpoint's
shape leaks via the validation error message."""
# blue_user only has mission.read + mission.write_blue_fields, so neither
# mission.update nor mission.archive is held.
r = client.post(
"/api/v1/missions/00000000-0000-0000-0000-000000000000/transition",
headers=_bearer(blue_user["token"]),
json={"status": "garbage-not-a-valid-shape"},
)
assert r.status_code == 403
def test_search_treats_wildcards_as_literals(client, admin_token):
"""User-typed `%` and `_` must NOT act as SQL LIKE wildcards."""
client.post(
"/api/v1/missions",
headers=_bearer(admin_token),
json={"name": "no-wildcards-here"},
)
# Without escaping, `?q=%` would match every mission. With escaping, it
# only matches names that literally contain `%`.
r = client.get("/api/v1/missions?q=%25", headers=_bearer(admin_token))
assert r.status_code == 200
names = [it["name"] for it in r.get_json()["items"]]
assert "no-wildcards-here" not in names
def test_archive_requires_mission_archive_not_just_update(client, admin_token): def test_archive_requires_mission_archive_not_just_update(client, admin_token):
"""A user with mission.update but no mission.archive cannot archive.""" """A user with mission.update but no mission.archive cannot archive."""
# blue_user only has mission.read + mission.write_blue_fields — no update either. # blue_user only has mission.read + mission.write_blue_fields — no update either.

View File

@@ -284,7 +284,7 @@ test.describe('M6 — Missions', () => {
// Should land on the detail page // Should land on the detail page
await expect(page).toHaveURL(/\/missions\/[0-9a-f-]+$/); await expect(page).toHaveURL(/\/missions\/[0-9a-f-]+$/);
await expect(page.getByRole('button', { name: /In Progress/i })).toBeVisible(); await expect(page.getByTestId('mission-transition-in_progress')).toBeVisible();
await expect(page.getByTestId('mission-tab-tests')).toBeVisible(); await expect(page.getByTestId('mission-tab-tests')).toBeVisible();
// Tests tab renders 3 snapshotted tests // Tests tab renders 3 snapshotted tests
await expect(page.getByText('spa-wizard-t1')).toBeVisible(); await expect(page.getByText('spa-wizard-t1')).toBeVisible();

View File

@@ -133,6 +133,11 @@ export interface TransitionPayload {
} }
export const missionKeys = { export const missionKeys = {
/** Prefix-only key — pass this to `invalidateQueries` to refresh every
* filtered variant. Matching is prefix-based: `['missions','list',{q:'x'}]`
* also gets invalidated.
*/
listPrefix: () => ['missions', 'list'] as const,
list: (filters?: MissionFilters) => ['missions', 'list', filters ?? {}] as const, list: (filters?: MissionFilters) => ['missions', 'list', filters ?? {}] as const,
detail: (id: string) => ['missions', 'detail', id] as const, detail: (id: string) => ['missions', 'detail', id] as const,
}; };

View File

@@ -27,6 +27,13 @@ const ALLOWED_TRANSITIONS: Record<MissionStatus, MissionStatus[]> = {
archived: [], archived: [],
}; };
const TRANSITION_BUTTON_ACCENT: Record<MissionStatus, 'cyan' | 'orange' | 'green' | 'teal'> = {
draft: 'cyan',
in_progress: 'orange',
completed: 'green',
archived: 'teal',
};
function useMission(id: string) { function useMission(id: string) {
return useQuery({ return useQuery({
queryKey: missionKeys.detail(id), queryKey: missionKeys.detail(id),
@@ -55,14 +62,14 @@ export function MissionDetailPage() {
apiPost<Mission>(`/missions/${missionId}/transition`, body), apiPost<Mission>(`/missions/${missionId}/transition`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) }); qc.invalidateQueries({ queryKey: missionKeys.detail(missionId) });
qc.invalidateQueries({ queryKey: missionKeys.list() }); qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
}, },
}); });
const remove = useMutation({ const remove = useMutation({
mutationFn: () => apiDelete<{ ok: true }>(`/missions/${missionId}`), mutationFn: () => apiDelete<{ ok: true }>(`/missions/${missionId}`),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: missionKeys.list() }); qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
navigate('/missions'); navigate('/missions');
}, },
}); });
@@ -94,7 +101,7 @@ export function MissionDetailPage() {
{allowedNext.map((target) => ( {allowedNext.map((target) => (
<Button <Button
key={target} key={target}
accent={target === 'archived' ? 'teal' : 'cyan'} accent={TRANSITION_BUTTON_ACCENT[target]}
onClick={() => transition.mutate({ status: target })} onClick={() => transition.mutate({ status: target })}
data-testid={`mission-transition-${target}`} data-testid={`mission-transition-${target}`}
disabled={transition.isPending} disabled={transition.isPending}

View File

@@ -100,7 +100,7 @@ export function MissionsCreatePage() {
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (body: CreateMissionPayload) => apiPost<Mission>('/missions', body), mutationFn: (body: CreateMissionPayload) => apiPost<Mission>('/missions', body),
onSuccess: (created) => { onSuccess: (created) => {
qc.invalidateQueries({ queryKey: missionKeys.list() }); qc.invalidateQueries({ queryKey: missionKeys.listPrefix() });
navigate(`/missions/${created.id}`); navigate(`/missions/${created.id}`);
}, },
}); });