test_rbac.py and test_internal_auth_secret.py each kept a hand-maintained
"MIRROR of app.main" copy of rbac_guard. The copies had already drifted:
test_rbac.py's copy was missing the #2213 X-Internal-Auth-Secret
defense-in-depth check entirely, so a regression in the real guard would not
have failed CI. Extracted rbac_guard (+ its constants) into app/core/rbac.py
(no DB/lifespan side effects) so app/main.py and both test files import and
exercise the exact same production code path instead of copies.
Verified the fix actually catches regressions: temporarily neutered the
secret-gate check in app/core/rbac.py, confirmed
test_internal_auth_secret.py went red (2 failures), then reverted — suite
back to green (31/31).
Also added tests/test_pdf_real_render.py: test_pdf_security.py stubs
WeasyPrint entirely, so it structurally cannot catch a real pagination
regression like the one just fixed in commit 42a50cf8 (extra trailing blank
5th page from the running @page header/footer). The new module renders the
REAL PDF via generate_trade_in_pdf and asserts the documented "4 pages, no
blank" invariant directly. It needs WeasyPrint's native Pango/cairo/GObject
libs (absent on this Windows sandbox and on ci-tradein.yml's bare
ubuntu-latest runner), so it self-skips via
pytest.skip(allow_module_level=True) wherever those aren't present, and runs
for real only where they are (e.g. `docker exec tradein-backend python -m
pytest -m pdf_render tests/test_pdf_real_render.py`) — see its docstring.
Existing mocked security tests are untouched (they check different things).
tests/conftest.py added (didn't exist before) to register the pdf_render
marker cleanly.
rbac_guard гейтил только /api/v1/admin/* → revoked (role=expired, roles.yaml
paths:[] deny:/**) и узко-скоупленные аккаунты сохраняли полный non-admin
API-доступ (напр. POST /api/v1/search — экспорт листингов + estimate-quota).
is_path_allowed (roles.yaml paths/deny) существовал, но НЕ вызывался (0 callers).
Fix: после admin-гейта вызываем is_path_allowed(role, external_path) для всех
non-bootstrap путей. roles.yaml globs — внешние (Caddy срезал /trade-in), поэтому
восстанавливаем внешний путь (_EXTERNAL_PREFIX + path). Bootstrap-пути /me и
/brand/* исключены — expired ДОЛЖЕН получить role=expired через /me (trial-экран)
и брендинг; без исключения trial-UX сломался бы (expired paths:[] → 403 на /me).
На сбой парса — fail-open + громкий лог (не лочим платящего pilot из-за конфиг-бага).
roles.yaml НЕ меняю — только энфорсю уже задекларированную политику. pilot/admin/
analyst доступ сохранён (verified), expired теперь 403 на non-admin API.
Тесты: +5 scope-кейсов (expired denied search/trade-in, allowed me/brand; pilot/
admin/analyst preserved) — 31 passed. Тест-harness middleware — mirror, обновлён.
Follow-up к смене user2 pilot→expired в roles.yaml. test_get_role_known_users
гонял user1..user10 == pilot циклом; user2 теперь expired. На PR #2472 этот
тест был skipped (paths-filter не матчил tradein-mvp/**, менялся только
auth/roles.yaml) → упал уже на полном деплое. Правка в обоих зеркалах
(tradein + main backend).