Compare commits
No commits in common. "5f6db8dbb7dca298e1301ef0c88ed49f5e0141a4" and "910bfed5f1990245ff4110dc5ef20e7bff87be95" have entirely different histories.
5f6db8dbb7
...
910bfed5f1
2 changed files with 16 additions and 71 deletions
|
|
@ -11,7 +11,6 @@ import logging
|
||||||
import time
|
import time
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
|
|
@ -69,22 +68,6 @@ def _assert_allowed_url(url: str) -> None:
|
||||||
raise HTTPException(status_code=400, detail="host not allowed")
|
raise HTTPException(status_code=400, detail="host not allowed")
|
||||||
|
|
||||||
|
|
||||||
def _safe_http_error(status_code: int, public_detail: str, log_context: str) -> HTTPException:
|
|
||||||
"""Defense-in-depth: не отдаём текст исключения в HTTP-ответ.
|
|
||||||
|
|
||||||
Текст psycopg/httpx-исключений может содержать DSN-фрагменты, внутренние
|
|
||||||
хосты, пути. Логируем полный exception с коротким error-id через
|
|
||||||
``logger.exception`` (уйдёт в GlitchTip), а в HTTP отдаём generic detail
|
|
||||||
с тем же error-id для корреляции.
|
|
||||||
|
|
||||||
ВАЖНО: вызывать только внутри ``except``-блока — ``logger.exception``
|
|
||||||
полагается на активный exc_info.
|
|
||||||
"""
|
|
||||||
error_id = uuid4().hex[:8]
|
|
||||||
logger.exception("%s (error-id=%s)", log_context, error_id)
|
|
||||||
return HTTPException(status_code=status_code, detail=f"{public_detail} (error-id: {error_id})")
|
|
||||||
|
|
||||||
|
|
||||||
_ALL_SOURCES = ["avito", "cian", "yandex"]
|
_ALL_SOURCES = ["avito", "cian", "yandex"]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -564,16 +547,14 @@ async def scrape_avito_house(
|
||||||
try:
|
try:
|
||||||
enrichment = await fetch_house_catalog(house_url)
|
enrichment = await fetch_house_catalog(house_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise _safe_http_error(
|
logger.exception("avito-house: fetch failed for %s", house_url)
|
||||||
502, "fetch failed", f"avito-house: fetch failed for {house_url}"
|
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
|
||||||
) from e
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
counters = save_house_catalog_enrichment(db, enrichment)
|
counters = save_house_catalog_enrichment(db, enrichment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise _safe_http_error(
|
logger.exception("avito-house: save failed for %s", house_url)
|
||||||
500, "save failed", f"avito-house: save failed for {house_url}"
|
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
|
||||||
) from e
|
|
||||||
|
|
||||||
logger.info("avito-house ok: %s → %s", house_url, counters)
|
logger.info("avito-house ok: %s → %s", house_url, counters)
|
||||||
return {"ok": True, "house_url": house_url, "counters": counters}
|
return {"ok": True, "house_url": house_url, "counters": counters}
|
||||||
|
|
@ -595,16 +576,14 @@ async def scrape_avito_detail(
|
||||||
try:
|
try:
|
||||||
enrichment = await fetch_detail(item_url)
|
enrichment = await fetch_detail(item_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise _safe_http_error(
|
logger.exception("avito-detail: fetch failed for %s", item_url)
|
||||||
502, "fetch failed", f"avito-detail: fetch failed for {item_url}"
|
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
|
||||||
) from e
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updated = save_detail_enrichment(db, enrichment)
|
updated = save_detail_enrichment(db, enrichment)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise _safe_http_error(
|
logger.exception("avito-detail: save failed for %s", item_url)
|
||||||
500, "save failed", f"avito-detail: save failed for {item_url}"
|
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
|
||||||
) from e
|
|
||||||
|
|
||||||
if not updated:
|
if not updated:
|
||||||
# Listing с этим source_id ещё не существует в БД — был bypass через
|
# Listing с этим source_id ещё не существует в БД — был bypass через
|
||||||
|
|
@ -646,9 +625,8 @@ async def scrape_avito_detail_backfill(
|
||||||
try:
|
try:
|
||||||
result = await run_avito_detail_backfill(db, run_id=run_id, params=params)
|
result = await run_avito_detail_backfill(db, run_id=run_id, params=params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise _safe_http_error(
|
logger.exception("avito-detail-backfill: run_id=%d crashed", run_id)
|
||||||
502, "backfill failed", f"avito-detail-backfill: run_id={run_id} crashed"
|
raise HTTPException(status_code=502, detail=f"backfill failed: {e}") from e
|
||||||
) from e
|
|
||||||
|
|
||||||
logger.info("avito-detail-backfill ok: run_id=%d %s", run_id, result.to_dict())
|
logger.info("avito-detail-backfill ok: run_id=%d %s", run_id, result.to_dict())
|
||||||
return {"ok": True, "run_id": run_id, "counters": result.to_dict()}
|
return {"ok": True, "run_id": run_id, "counters": result.to_dict()}
|
||||||
|
|
@ -688,22 +666,17 @@ async def scrape_avito_imv(
|
||||||
has_loggia=has_loggia,
|
has_loggia=has_loggia,
|
||||||
)
|
)
|
||||||
except IMVAddressNotFoundError as e:
|
except IMVAddressNotFoundError as e:
|
||||||
# Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
|
raise HTTPException(status_code=404, detail=f"IMV address not found: {e}") from e
|
||||||
# без traceback, чтобы не шуметь exception-событиями в GlitchTip. Адрес — в лог,
|
|
||||||
# не в HTTP-ответ.
|
|
||||||
error_id = uuid4().hex[:8]
|
|
||||||
logger.warning("avito-imv: address not found for %s (error-id=%s)", address, error_id)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404, detail=f"IMV address not found (error-id: {error_id})"
|
|
||||||
) from e
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise _safe_http_error(502, "fetch failed", f"avito-imv: fetch failed for {address}") from e
|
logger.exception("avito-imv: fetch failed for %s", address)
|
||||||
|
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
eval_id = save_imv_evaluation(db, result)
|
eval_id = save_imv_evaluation(db, result)
|
||||||
history_saved = save_imv_placement_history(db, eval_id, result.placement_history)
|
history_saved = save_imv_placement_history(db, eval_id, result.placement_history)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise _safe_http_error(500, "save failed", f"avito-imv: save failed for {address}") from e
|
logger.exception("avito-imv: save failed for %s", address)
|
||||||
|
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d",
|
"avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
@ -564,31 +564,3 @@ def test_data_quality_pct_in_range(client: TestClient) -> None:
|
||||||
assert (
|
assert (
|
||||||
0.0 <= pct <= 100.0
|
0.0 <= pct <= 100.0
|
||||||
), f"source={src['source']} field={field_name} pct={pct} вне [0,100]"
|
), f"source={src['source']} field={field_name} pct={pct} вне [0,100]"
|
||||||
|
|
||||||
|
|
||||||
# ── Security: текст исключения не утекает в HTTP-ответ (#2234) ────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_error_detail_hides_exception_text(client: TestClient) -> None:
|
|
||||||
"""Внутренняя зависимость кидает исключение с 'секретным' текстом →
|
|
||||||
|
|
||||||
в теле ответа НЕТ секрета, есть generic detail + error-id; статус-код прежний.
|
|
||||||
Роут /scrape/avito-detail: fetch_detail падает с DSN-подобным сообщением.
|
|
||||||
"""
|
|
||||||
secret = "postgres://user:SECRETPASS@internal-host:5432/db"
|
|
||||||
with patch(
|
|
||||||
"app.api.v1.admin.fetch_detail",
|
|
||||||
new=AsyncMock(side_effect=Exception(secret)),
|
|
||||||
):
|
|
||||||
r = client.post(
|
|
||||||
"/api/v1/admin/scrape/avito-detail",
|
|
||||||
params={"item_url": "/ekaterinburg/kvartiry/1-k_kvartira_100"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert r.status_code == 502
|
|
||||||
body = r.json()
|
|
||||||
detail = body["detail"]
|
|
||||||
assert "SECRETPASS" not in detail
|
|
||||||
assert secret not in detail
|
|
||||||
assert "error-id:" in detail
|
|
||||||
assert detail.startswith("fetch failed")
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue