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:
@@ -368,6 +368,32 @@ def test_create_mission_rejects_unknown_scenario(client, admin_token):
|
||||
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):
|
||||
r = client.post(
|
||||
"/api/v1/missions",
|
||||
@@ -459,6 +485,35 @@ def test_list_requires_mission_read_perm(client, noperm_user):
|
||||
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):
|
||||
"""A user with mission.update but no mission.archive cannot archive."""
|
||||
# blue_user only has mission.read + mission.write_blue_fields — no update either.
|
||||
|
||||
Reference in New Issue
Block a user