Files
6krrt/tests/test_admin_bodies_are_strict.py
adlee-was-taken 6b9826c27e fix(admin): unknown request keys are errors, and base providers are read-only
Sweep findings #2 and #7 (plans/admin-portal-functional-sweep.md).

#2. Every admin request body now inherits `_AdminBody` with
`extra="forbid"`. This is the rule StrictModel already enforces for the
config models, for the reason CLAUDE.md gives: with Pydantic's default a
typo "loads cleanly, does nothing, and still looks configured".

On this surface the default was destructive, not merely untidy.
`_CloudFallbackBody` takes a nested `{"cloud_fallback": {...}}`, so a caller
sending the flat shape the field names suggest had every key discarded,
leaving the field None -- which is the documented signal to REMOVE the
block. Trying to save a cloud classifier deleted it and returned 200 "A
restart is required for this change to take effect". The quiet version:
`POST /api/profiles/` with `{"name":"x","min_teir":3}` returned 200 and
created a profile with no filters at all.

Note the asymmetry this removes. The inner dict was already strict, because
RouterConfig validates it on the way to disk -- `{"timeout_secondz": 20}`
correctly 422'd. Only the outer wrapper was loose, so validation got
stricter the deeper you went.

Strictness immediately found a real client/server mismatch: the profiles UI
sent `name` on the update path, which `_ProfileUpdateBody` has no field for
(the name is in the URL; the endpoint cannot rename). It was being silently
dropped. Both save paths now strip it. Verified in the browser -- inline
edit and duplicate-then-create both still save, with allowed_model_ids
preserved.

#7. `POST /api/providers/{name}` had no provenance check at all, making it
the one way to write to something the portal itself labels "base config /
read-only". The result could not be undone from the portal: the write forks
the provider into config.local.yaml, and DELETE then refuses if it is the
default_provider, so hand-editing the one file in this deployment that is
not in git was the only way back. It now refuses a base-only definition
exactly as delete does.

One POST serves both create and update, so this also means an overlay entry
cannot shadow a base provider -- matching admin_profile_create, which
refuses that with a 409.

Which exposed a smaller thing worth fixing: both profile 403s advised
"add an overlay profile with the same name to override it", and
admin_profile_create rejects precisely that. The message now says what the
API actually supports.

The new provider test also demonstrates the fixture shape that fixes sweep
finding #12 -- pointing `base_dir` at tmp_path isolates a test from the
machine's real config.local.yaml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-08 18:30:06 -04:00

5.4 KiB