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
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.
118 lines
5.2 KiB
Python
118 lines
5.2 KiB
Python
"""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()
|