refactor(security): убрать мёртвую проверку админского токена (#2775) #2776
7 changed files with 69 additions and 120 deletions
|
|
@ -27,7 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800
|
|||
SCRAPE_KN_DEFAULT_REGIONS=66
|
||||
# Путь к Playwright storage_state.json (commited в git, обновляется --save-state).
|
||||
SCRAPE_KN_STATE_PATH=data/playwright_state.json
|
||||
# DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен).
|
||||
# Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep.
|
||||
# Переменная сохранена в core/deps.py для быстрого rollback.
|
||||
SCRAPE_ADMIN_TOKEN=
|
||||
# SCRAPE_ADMIN_TOKEN удалён в #2775. App-level admin-auth сняли ещё в PR #437,
|
||||
# а поле держали «для быстрого rollback» — за полтора месяца у него не появилось
|
||||
# ни одного вызывающего. `/api/v1/admin/*` закрыт middleware rbac_guard
|
||||
# (app/main.py, role != admin → 403) + Caddy basic_auth (PR #426).
|
||||
|
|
|
|||
|
|
@ -146,9 +146,6 @@ class Settings(BaseSettings):
|
|||
# Path to a pre-captured Playwright storage_state.json (committed in repo,
|
||||
# used by worker to skip cold-start WAF challenge).
|
||||
scrape_kn_state_path: str = "data/playwright_state.json"
|
||||
# Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
|
||||
# Empty string = endpoint disabled.
|
||||
scrape_admin_token: str = ""
|
||||
|
||||
# ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
|
||||
# DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
"""Shared FastAPI dependencies."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def verify_admin_token(
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
) -> None:
|
||||
"""Verify admin token header. Raises 503 if not configured, 401 if invalid or missing."""
|
||||
if not settings.scrape_admin_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
|
||||
)
|
||||
if x_admin_token != settings.scrape_admin_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token")
|
||||
|
||||
|
||||
AdminTokenAuth = Annotated[None, Depends(verify_admin_token)]
|
||||
|
|
@ -15,10 +15,6 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from app.main import app
|
||||
|
||||
# Токен для тестов (не реальный)
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
|
||||
|
||||
|
||||
def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None):
|
||||
"""Создать mock db session с преднастроенными ответами."""
|
||||
|
|
@ -97,7 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]:
|
|||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_create_job_pilot_returns_job_id() -> None:
|
||||
"""POST /cadastre/jobs scope=pilot → job_id + targets_total."""
|
||||
quarters_50 = [f"66:41:{i:07d}" for i in range(50)]
|
||||
|
|
@ -114,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None:
|
|||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs",
|
||||
json={"scope": "pilot"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
|
|
@ -127,7 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_create_job_manual_list() -> None:
|
||||
"""POST /cadastre/jobs scope=manual_list с явным списком."""
|
||||
db = _make_mock_db()
|
||||
|
|
@ -146,7 +139,6 @@ def test_create_job_manual_list() -> None:
|
|||
"scope": "manual_list",
|
||||
"quarters": ["66:41:0303161", "66:41:0303162"],
|
||||
},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
|
|
@ -156,7 +148,6 @@ def test_create_job_manual_list() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_create_job_manual_list_empty_quarters_returns_400() -> None:
|
||||
"""scope=manual_list без quarters → 400."""
|
||||
db = _make_mock_db()
|
||||
|
|
@ -170,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None:
|
|||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs",
|
||||
json={"scope": "manual_list"},
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_list_jobs_returns_list() -> None:
|
||||
"""GET /cadastre/jobs → список jobs."""
|
||||
db = _make_mock_db(job_row=_make_sample_job_row())
|
||||
|
|
@ -194,7 +183,7 @@ def test_list_jobs_returns_list() -> None:
|
|||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert isinstance(body, list)
|
||||
|
|
@ -206,7 +195,6 @@ def test_list_jobs_returns_list() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_get_job_not_found_returns_404() -> None:
|
||||
"""GET /cadastre/jobs/9999 → 404."""
|
||||
db = MagicMock()
|
||||
|
|
@ -220,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None:
|
|||
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS)
|
||||
response = client.get("/api/v1/admin/cadastre/jobs/9999")
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_cancel_job_success() -> None:
|
||||
"""POST /cadastre/jobs/42/cancel → {cancelled: true}."""
|
||||
db = MagicMock()
|
||||
|
|
@ -243,7 +230,6 @@ def test_cancel_job_success() -> None:
|
|||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs/42/cancel",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -253,7 +239,6 @@ def test_cancel_job_success() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_cancel_job_not_found_returns_404() -> None:
|
||||
"""POST /cadastre/jobs/9999/cancel когда job не найден → 404."""
|
||||
db = MagicMock()
|
||||
|
|
@ -270,7 +255,6 @@ def test_cancel_job_not_found_returns_404() -> None:
|
|||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/admin/cadastre/jobs/9999/cancel",
|
||||
headers=ADMIN_HEADERS,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
- валидный запрос без year → scope all_years_2022_2026, task_id в ответе
|
||||
- валидный запрос с year=2026 → scope year_2026
|
||||
- year < 2022 или > 2030 → 422
|
||||
- отсутствие X-Admin-Token → 401/503
|
||||
|
||||
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие
|
||||
X-Admin-Token → 401/503» стояла в этом докстринге, но соответствующего теста в
|
||||
файле нет. Гейт `/api/v1/admin/*` — middleware `rbac_guard` (app/main.py),
|
||||
покрыт tests/test_rbac.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -16,8 +20,6 @@ from fastapi.testclient import TestClient
|
|||
|
||||
from app.main import app
|
||||
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN}
|
||||
ENDPOINT = "/api/v1/admin/scrape/ekburg-permits"
|
||||
|
||||
|
||||
|
|
@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock:
|
|||
return result
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_trigger_refresh_all_returns_task_id() -> None:
|
||||
"""POST без year → refresh_all queued, scope=all_years_2022_2026."""
|
||||
mock_result = _mock_task("task-all-001")
|
||||
|
|
@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None:
|
|||
):
|
||||
mock_refresh_all.apply_async.return_value = mock_result
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS)
|
||||
response = client.post(ENDPOINT, json={})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None:
|
|||
assert "queued_at" in body
|
||||
|
||||
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_trigger_refresh_year_returns_task_id() -> None:
|
||||
"""POST year=2026 → refresh_year queued, scope=year_2026."""
|
||||
mock_result = _mock_task("task-year-002")
|
||||
|
|
@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None:
|
|||
):
|
||||
mock_refresh_year.apply_async.return_value = mock_result
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS)
|
||||
response = client.post(ENDPOINT, json={"year": 2026})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -67,9 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None:
|
|||
|
||||
|
||||
@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999])
|
||||
@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN)
|
||||
def test_trigger_invalid_year_returns_422(bad_year: int) -> None:
|
||||
"""year вне диапазона [2022, 2030] → 422 Unprocessable Entity."""
|
||||
client = TestClient(app)
|
||||
response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS)
|
||||
response = client.post(ENDPOINT, json={"year": bad_year})
|
||||
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"
|
||||
|
|
|
|||
|
|
@ -6,9 +6,15 @@
|
|||
- GET /{id} → 200 / 404
|
||||
- PUT /{id} → 200 / 404
|
||||
- DELETE /{id} → 204 / 404
|
||||
- 401 при отсутствии X-Admin-Token
|
||||
- 422 при невалидных weights (неизвестная категория, вес вне диапазона)
|
||||
|
||||
Авторизация здесь НЕ проверяется и никогда не проверялась: строка «401 при
|
||||
отсутствии X-Admin-Token» стояла в этом докстринге, но соответствующего теста в
|
||||
файле нет — заголовок просто отправлялся во все запросы и никем не читался
|
||||
(app-level токен снят в PR #437). Гейт `/api/v1/admin/*` живёт в middleware
|
||||
`rbac_guard` (app/main.py, `_ADMIN_API_RE` → 403 для role != admin) и покрыт
|
||||
tests/test_rbac.py.
|
||||
|
||||
Mock-based: get_db переопределяется через dependency override.
|
||||
"""
|
||||
|
||||
|
|
@ -24,9 +30,6 @@ from app.core.db import get_db
|
|||
from app.main import app
|
||||
from app.services.site_finder.weight_profiles import WeightProfile
|
||||
|
||||
_ADMIN_TOKEN = "test-admin-token"
|
||||
_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN}
|
||||
|
||||
_NOW = datetime.now(UTC)
|
||||
|
||||
|
||||
|
|
@ -50,9 +53,7 @@ def _make_profile(
|
|||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
"""TestClient с переопределённым SCRAPE_ADMIN_TOKEN."""
|
||||
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
|
|
@ -76,7 +77,7 @@ def _clear_overrides():
|
|||
# ── GET list ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_list_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET ?user_id= → 200 + пустой список."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -85,10 +86,9 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
|
|||
"app.api.v1.admin_weight_profiles.list_profiles",
|
||||
lambda db, user_id: [],
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-x"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
|
@ -96,9 +96,7 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat
|
|||
_clear_overrides()
|
||||
|
||||
|
||||
def test_list_returns_profiles(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_list_returns_profiles(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET ?user_id= → 200 + список профилей."""
|
||||
profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")]
|
||||
mock = MagicMock()
|
||||
|
|
@ -108,10 +106,9 @@ def test_list_returns_profiles(
|
|||
"app.api.v1.admin_weight_profiles.list_profiles",
|
||||
lambda db, user_id: profiles,
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
|
|
@ -124,7 +121,7 @@ def test_list_returns_profiles(
|
|||
# ── POST create ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_create_then_get(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""POST создаёт профиль, возвращает его со статусом 201."""
|
||||
created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5})
|
||||
mock = MagicMock()
|
||||
|
|
@ -134,7 +131,7 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
|
|||
"app.api.v1.admin_weight_profiles.create_profile",
|
||||
lambda db, payload: created,
|
||||
)
|
||||
r = client_with_token.post(
|
||||
r = client.post(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
json={
|
||||
"user_id": "user-1",
|
||||
|
|
@ -142,7 +139,6 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
|
|||
"weights": {"school": 2.0, "park": 1.5},
|
||||
"is_default": False,
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
|
|
@ -153,30 +149,28 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk
|
|||
_clear_overrides()
|
||||
|
||||
|
||||
def test_create_validation_unknown_category(client_with_token: TestClient) -> None:
|
||||
def test_create_validation_unknown_category(client: TestClient) -> None:
|
||||
"""POST с неизвестной POI-категорией → 422 (Pydantic validation)."""
|
||||
r = client_with_token.post(
|
||||
r = client.post(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
json={
|
||||
"user_id": "user-1",
|
||||
"profile_name": "Bad",
|
||||
"weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None:
|
||||
def test_create_validation_weight_out_of_bounds(client: TestClient) -> None:
|
||||
"""POST с весом вне [-2, 3] → 422."""
|
||||
r = client_with_token.post(
|
||||
r = client.post(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
json={
|
||||
"user_id": "user-1",
|
||||
"profile_name": "Bad",
|
||||
"weights": {"school": 99.0},
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
|
@ -184,7 +178,7 @@ def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -
|
|||
# ── GET one ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_get_profile_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET /{id}?user_id= → 200."""
|
||||
profile = _make_profile(7)
|
||||
mock = MagicMock()
|
||||
|
|
@ -194,10 +188,9 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
|
|||
"app.api.v1.admin_weight_profiles.get_profile",
|
||||
lambda db, user_id, profile_id: profile,
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles/7",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == 7
|
||||
|
|
@ -205,9 +198,7 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo
|
|||
_clear_overrides()
|
||||
|
||||
|
||||
def test_get_profile_not_found(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_get_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""GET /{id} несуществующего профиля → 404."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -216,10 +207,9 @@ def test_get_profile_not_found(
|
|||
"app.api.v1.admin_weight_profiles.get_profile",
|
||||
lambda db, user_id, profile_id: None,
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles/999",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 404
|
||||
finally:
|
||||
|
|
@ -229,7 +219,7 @@ def test_get_profile_not_found(
|
|||
# ── PUT update ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_update_profile(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""PUT /{id} → 200 + обновлённый профиль."""
|
||||
updated = _make_profile(3, profile_name="Обновлённый")
|
||||
mock = MagicMock()
|
||||
|
|
@ -239,11 +229,10 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
|
|||
"app.api.v1.admin_weight_profiles.update_profile",
|
||||
lambda db, user_id, profile_id, payload: updated,
|
||||
)
|
||||
r = client_with_token.put(
|
||||
r = client.put(
|
||||
"/api/v1/admin/site-finder/weight-profiles/3",
|
||||
params={"user_id": "user-1"},
|
||||
json={"profile_name": "Обновлённый"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["profile_name"] == "Обновлённый"
|
||||
|
|
@ -251,9 +240,7 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke
|
|||
_clear_overrides()
|
||||
|
||||
|
||||
def test_update_profile_not_found(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_update_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""PUT /{id} несуществующего → 404."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -262,11 +249,10 @@ def test_update_profile_not_found(
|
|||
"app.api.v1.admin_weight_profiles.update_profile",
|
||||
lambda db, user_id, profile_id, payload: None,
|
||||
)
|
||||
r = client_with_token.put(
|
||||
r = client.put(
|
||||
"/api/v1/admin/site-finder/weight-profiles/999",
|
||||
params={"user_id": "user-1"},
|
||||
json={"profile_name": "X"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 404
|
||||
finally:
|
||||
|
|
@ -276,7 +262,7 @@ def test_update_profile_not_found(
|
|||
# ── DELETE ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_delete_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""DELETE /{id} → 204."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -285,17 +271,16 @@ def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.Monke
|
|||
"app.api.v1.admin_weight_profiles.delete_profile",
|
||||
lambda db, user_id, profile_id: True,
|
||||
)
|
||||
r = client_with_token.delete(
|
||||
r = client.delete(
|
||||
"/api/v1/admin/site-finder/weight-profiles/5",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 204
|
||||
finally:
|
||||
_clear_overrides()
|
||||
|
||||
|
||||
def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_delete_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""DELETE /{id} несуществующего → 404."""
|
||||
mock = MagicMock()
|
||||
_override_db(mock)
|
||||
|
|
@ -304,10 +289,9 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
|
|||
"app.api.v1.admin_weight_profiles.delete_profile",
|
||||
lambda db, user_id, profile_id: False,
|
||||
)
|
||||
r = client_with_token.delete(
|
||||
r = client.delete(
|
||||
"/api/v1/admin/site-finder/weight-profiles/999",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 404
|
||||
finally:
|
||||
|
|
@ -318,7 +302,7 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon
|
|||
|
||||
|
||||
def test_list_include_system_calls_with_system(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GET ?include_system=true вызывает list_profiles_with_system, возвращает presets."""
|
||||
system_profile = _make_profile(
|
||||
|
|
@ -332,10 +316,9 @@ def test_list_include_system_calls_with_system(
|
|||
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
|
||||
lambda db, user_id: [user_profile, system_profile],
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-1", "include_system": "true"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
|
|
@ -348,7 +331,7 @@ def test_list_include_system_calls_with_system(
|
|||
|
||||
|
||||
def test_list_without_include_system_does_not_call_with_system(
|
||||
client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""GET без include_system → list_profiles (только пользовательские профили)."""
|
||||
user_profile = _make_profile(1, user_id="user-1")
|
||||
|
|
@ -364,10 +347,9 @@ def test_list_without_include_system_does_not_call_with_system(
|
|||
"app.api.v1.admin_weight_profiles.list_profiles_with_system",
|
||||
lambda db, user_id: called_with_system.append(True) or [],
|
||||
)
|
||||
r = client_with_token.get(
|
||||
r = client.get(
|
||||
"/api/v1/admin/site-finder/weight-profiles",
|
||||
params={"user_id": "user-1"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == 1
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
| `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) |
|
||||
| `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** |
|
||||
| `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** |
|
||||
| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) |
|
||||
| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — |
|
||||
|
||||
### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`)
|
||||
|
||||
|
|
@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`):
|
|||
|
||||
---
|
||||
|
||||
## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
|
||||
## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance)
|
||||
|
||||
Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
|
||||
**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436
|
||||
(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth.
|
||||
Поле оставлено в `core/deps.py` только для быстрого rollback.
|
||||
Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime».
|
||||
**Ротировать нечего: переменной больше нет.**
|
||||
|
||||
**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока
|
||||
`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F**
|
||||
(процедура: `sed` в `backend/.env.runtime` → `up -d --force-recreate --no-deps backend beat`,
|
||||
downtime отсутствует). Фактический прод-прогон ротации — операционное действие
|
||||
(Anton), не выполняется в рамках этого PR.
|
||||
История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран
|
||||
со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и
|
||||
dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до
|
||||
#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему
|
||||
репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте —
|
||||
в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно
|
||||
означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть
|
||||
хранение поля не экономило ничего. Удалено вместе с `core/deps.py`,
|
||||
`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах.
|
||||
|
||||
Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**:
|
||||
1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only`
|
||||
для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста).
|
||||
2. Caddy basic_auth на весь `gendsgn.ru` (PR #426).
|
||||
|
||||
Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно:
|
||||
`Settings.model_config` — `extra="ignore"`, лишняя переменная безвредна.
|
||||
|
||||
`JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует** —
|
||||
добавить в реестр (класс **F**) при внедрении JWT-аутентификации.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue