gendesign/tradein-mvp/backend/tests/test_internal_auth_secret.py
bot-backend 64f3f8769f
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m27s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/tests): stop mirroring rbac_guard, add real WeasyPrint page-count regression test
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.
2026-07-27 00:41:01 +03:00

118 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Defense-in-depth: shared-secret gate поверх trusted-header auth (#2213).
Backend доверяет X-Authenticated-User (его ставит Caddy). На общей docker-сети
gendesign_shared любой контейнер мог бы отправить поддельный
`X-Authenticated-User: admin` напрямую на tradein-backend:8000, минуя Caddy.
Общий секрет X-Internal-Auth-Secret закрывает дыру: если TRADEIN_INTERNAL_AUTH_SECRET
задан, каждый запрос с X-Authenticated-User обязан нести валидный секрет (constant-time),
иначе 401. Пусто = fail-open (backward-compat до провижининга).
Использует РЕАЛЬНЫЙ rbac_guard (app/core/rbac.py) — тот же, что регистрирует
app/main.py в проде. Раньше здесь была hand-maintained копия ("MIRROR of
rbac_guard из app/main.py"); соседний test_rbac.py держал СВОЮ отдельную
копию, которая успела отстать (потеряла именно этот secret-gate) — регрессия
в реальном guard'е могла бы пройти CI незамеченной. См. app/core/rbac.py.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.core import auth as auth_mod
from app.core import config
from app.core.rbac import rbac_guard
@pytest.fixture(autouse=True)
def _reset_auth_cache() -> None:
auth_mod.reset_cache_for_tests()
def _build_test_app() -> FastAPI:
"""Test app используя РЕАЛЬНЫЙ rbac_guard (с #2213 secret-gate)."""
app = FastAPI()
app.middleware("http")(rbac_guard)
@app.get("/api/v1/ping")
async def ping() -> dict:
return {"ok": True}
return app
@pytest.fixture
def client() -> TestClient:
return TestClient(_build_test_app())
# ---------------------------------------------------------------------------
# (в) секрет НЕ задан → backward-compat: X-Authenticated-User достаточно.
# ---------------------------------------------------------------------------
def test_secret_unset_backward_compat(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "")
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_secret_unset_ignores_provided_secret(client: TestClient, monkeypatch) -> None:
# Если секрет не задан — присланный клиентом X-Internal-Auth-Secret игнорируется.
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "")
resp = client.get(
"/api/v1/ping",
headers={"X-Authenticated-User": "admin", "X-Internal-Auth-Secret": "whatever"},
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# (а) секрет задан + заголовок юзера БЕЗ секрета → 401.
# ---------------------------------------------------------------------------
def test_secret_set_missing_header_401(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "admin"})
assert resp.status_code == 401
assert "internal auth secret" in resp.json()["detail"].lower()
def test_secret_set_wrong_secret_401(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
resp = client.get(
"/api/v1/ping",
headers={"X-Authenticated-User": "admin", "X-Internal-Auth-Secret": "wrong"},
)
assert resp.status_code == 401
assert "internal auth secret" in resp.json()["detail"].lower()
# ---------------------------------------------------------------------------
# (б) секрет задан + оба заголовка корректны → 200 (существующее поведение).
# ---------------------------------------------------------------------------
def test_secret_set_correct_secret_200(client: TestClient, monkeypatch) -> None:
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
resp = client.get(
"/api/v1/ping",
headers={"X-Authenticated-User": "admin", "X-Internal-Auth-Secret": "s3cr3t-value"},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_secret_set_no_user_header_still_401(client: TestClient, monkeypatch) -> None:
# Секрет без X-Authenticated-User — no-auth 401 (secret-gate не ослабляет базу).
monkeypatch.setattr(config.settings, "tradein_internal_auth_secret", "s3cr3t-value")
resp = client.get("/api/v1/ping", headers={"X-Internal-Auth-Secret": "s3cr3t-value"})
assert resp.status_code == 401
assert "no authenticated user" in resp.json()["detail"].lower()