fix(slice5): validate raw metadata input + prune stale bulk selections

Code-review follow-ups:
- Validate the raw submitted value before apply_values coerces it, in both
  the single-edit and bulk endpoints — a scalar set onto a tags field now
  rejects (422 / rejected) instead of silently char-splitting into a list.
- Catalog prunes bulk selections to the currently-visible (filtered) entries
  after each list fetch, so the bulk bar can't act on rows the user has
  filtered out of view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-07 21:03:29 -07:00
parent 8eee907893
commit 7886840362
4 changed files with 76 additions and 7 deletions
+9 -6
View File
@@ -89,11 +89,13 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
if st is None:
raise HTTPException(404, f"{md_path} not found")
new_entry = metadata_mod.apply_values(st.entry, body.values)
problems = metadata_schema.validate(
metadata_mod.metadata_dict(new_entry), fields)
# Validate the *raw* submitted values (INV-4): catch a type mismatch
# before `apply_values` coerces it — e.g. a scalar handed to a `tags`
# field would otherwise char-split into a valid-looking list.
problems = metadata_schema.validate(body.values, fields)
if problems:
raise HTTPException(422, {"problems": [p.as_dict() for p in problems]})
new_entry = metadata_mod.apply_values(st.entry, body.values)
files = metadata_mod.write_entry_files(md_path, new_entry, st)
try:
@@ -153,14 +155,15 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
rejected.append({"slug": slug, "reason": "not found"})
continue
new_value = _apply_op(st.entry, body.op, body.field, body.value)
new_entry = metadata_mod.apply_values(st.entry, {body.field: new_value})
problems = metadata_schema.validate(
metadata_mod.metadata_dict(new_entry), fields)
# Validate the *raw* new value before coercion (see edit_meta) so a
# scalar `set` onto a tags field is rejected, not char-split.
problems = metadata_schema.validate({body.field: new_value}, fields)
if problems:
rejected.append({"slug": slug,
"reason": "; ".join(p.message for p in problems)})
continue
applied.append(slug)
new_entry = metadata_mod.apply_values(st.entry, {body.field: new_value})
if metadata_mod.metadata_dict(new_entry) != metadata_mod.metadata_dict(st.entry):
all_ops.extend(metadata_mod.write_entry_files(md_path, new_entry, st))
@@ -119,6 +119,39 @@ def test_bulk_remove_tag(app_with_fake_gitea):
assert _sidecar(fake, "a")["tags"] == ["y"]
def test_bulk_set_scalar_on_tags_rejected(app_with_fake_gitea):
# A scalar `set` onto a tags field must reject, not char-split into a list.
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"tags": {"type": "tags"}})
_seed_legacy(fake, "a", tags=["x"])
_refresh()
_login_owner(client)
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "set",
"field": "tags", "value": "checkout"})
assert r.status_code == 200, r.text
assert r.json()["applied"] == []
assert len(r.json()["rejected"]) == 1
assert r.json()["committed"] is False
assert not _has_sidecar(fake, "a")
def test_bulk_set_list_on_tags_ok(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_set_fields({"tags": {"type": "tags"}})
_seed_legacy(fake, "a", tags=["x"])
_refresh()
_login_owner(client)
r = client.post(f"{BASE}/meta/bulk",
json={"slugs": ["a"], "op": "set",
"field": "tags", "value": ["x", "y"]})
assert r.status_code == 200, r.text
assert r.json()["applied"] == ["a"]
assert _sidecar(fake, "a")["tags"] == ["x", "y"]
def test_bulk_add_remove_requires_tags_field(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client: